mirror of
				https://github.com/go-gitea/gitea.git
				synced 2025-10-31 19:06:18 +01:00 
			
		
		
		
	Serve pre-defined files in "public", add "security.txt", add CORS header for ".well-known" (#25974)
Replace #25892 Close #21942 Close #25464 Major changes: 1. Serve "robots.txt" and ".well-known/security.txt" in the "public" custom path * All files in "public/.well-known" can be served, just like "public/assets" 3. Add a test for ".well-known/security.txt" 4. Simplify the "FileHandlerFunc" logic, now the paths are consistent so the code can be simpler 5. Add CORS header for ".well-known" endpoints 6. Add logs to tell users they should move some of their legacy custom public files ``` 2023/07/19 13:00:37 cmd/web.go:178:serveInstalled() [E] Found legacy public asset "img" in CustomPath. Please move it to /work/gitea/custom/public/assets/img 2023/07/19 13:00:37 cmd/web.go:182:serveInstalled() [E] Found legacy public asset "robots.txt" in CustomPath. Please move it to /work/gitea/custom/public/robots.txt ``` This PR is not breaking. --------- Co-authored-by: silverwind <me@silverwind.io> Co-authored-by: Giteabot <teabot@gitea.io>
This commit is contained in:
		
							
								
								
									
										16
									
								
								cmd/web.go
									
									
									
									
									
								
							
							
						
						
									
										16
									
								
								cmd/web.go
									
									
									
									
									
								
							| @@ -15,9 +15,11 @@ import ( | |||||||
|  |  | ||||||
| 	_ "net/http/pprof" // Used for debugging if enabled and a web server is running | 	_ "net/http/pprof" // Used for debugging if enabled and a web server is running | ||||||
|  |  | ||||||
|  | 	"code.gitea.io/gitea/modules/container" | ||||||
| 	"code.gitea.io/gitea/modules/graceful" | 	"code.gitea.io/gitea/modules/graceful" | ||||||
| 	"code.gitea.io/gitea/modules/log" | 	"code.gitea.io/gitea/modules/log" | ||||||
| 	"code.gitea.io/gitea/modules/process" | 	"code.gitea.io/gitea/modules/process" | ||||||
|  | 	"code.gitea.io/gitea/modules/public" | ||||||
| 	"code.gitea.io/gitea/modules/setting" | 	"code.gitea.io/gitea/modules/setting" | ||||||
| 	"code.gitea.io/gitea/routers" | 	"code.gitea.io/gitea/routers" | ||||||
| 	"code.gitea.io/gitea/routers/install" | 	"code.gitea.io/gitea/routers/install" | ||||||
| @@ -175,6 +177,20 @@ func serveInstalled(ctx *cli.Context) error { | |||||||
| 		} | 		} | ||||||
| 	} | 	} | ||||||
|  |  | ||||||
|  | 	// in old versions, user's custom web files are placed in "custom/public", and they were served as "http://domain.com/assets/xxx" | ||||||
|  | 	// now, Gitea only serves pre-defined files in the "custom/public" folder basing on the web root, the user should move their custom files to "custom/public/assets" | ||||||
|  | 	publicFiles, _ := public.AssetFS().ListFiles(".") | ||||||
|  | 	publicFilesSet := container.SetOf(publicFiles...) | ||||||
|  | 	publicFilesSet.Remove(".well-known") | ||||||
|  | 	publicFilesSet.Remove("assets") | ||||||
|  | 	publicFilesSet.Remove("robots.txt") | ||||||
|  | 	for _, fn := range publicFilesSet.Values() { | ||||||
|  | 		log.Error("Found legacy public asset %q in CustomPath. Please move it to %s/public/assets/%s", fn, setting.CustomPath, fn) | ||||||
|  | 	} | ||||||
|  | 	if _, err := os.Stat(filepath.Join(setting.CustomPath, "robots.txt")); err == nil { | ||||||
|  | 		log.Error(`Found legacy public asset "robots.txt" in CustomPath. Please move it to %s/public/robots.txt`, setting.CustomPath) | ||||||
|  | 	} | ||||||
|  |  | ||||||
| 	routers.InitWebInstalled(graceful.GetManager().HammerContext()) | 	routers.InitWebInstalled(graceful.GetManager().HammerContext()) | ||||||
|  |  | ||||||
| 	// We check that AppDataPath exists here (it should have been created during installation) | 	// We check that AppDataPath exists here (it should have been created during installation) | ||||||
|   | |||||||
| @@ -56,7 +56,11 @@ is set under the "Configuration" tab on the site administration page. | |||||||
|  |  | ||||||
| To make Gitea serve custom public files (like pages and images), use the folder | To make Gitea serve custom public files (like pages and images), use the folder | ||||||
| `$GITEA_CUSTOM/public/` as the webroot. Symbolic links will be followed. | `$GITEA_CUSTOM/public/` as the webroot. Symbolic links will be followed. | ||||||
| At the moment, only files in the `public/assets/` folder are served. | At the moment, only the following files are served: | ||||||
|  |  | ||||||
|  | - `public/robots.txt` | ||||||
|  | - files in the `public/.well-known/` folder | ||||||
|  | - files in the `public/assets/` folder | ||||||
|  |  | ||||||
| For example, a file `image.png` stored in `$GITEA_CUSTOM/public/assets/`, can be accessed with | For example, a file `image.png` stored in `$GITEA_CUSTOM/public/assets/`, can be accessed with | ||||||
| the url `http://gitea.domain.tld/assets/image.png`. | the url `http://gitea.domain.tld/assets/image.png`. | ||||||
|   | |||||||
| @@ -28,27 +28,15 @@ func AssetFS() *assetfs.LayeredFS { | |||||||
| 	return assetfs.Layered(CustomAssets(), BuiltinAssets()) | 	return assetfs.Layered(CustomAssets(), BuiltinAssets()) | ||||||
| } | } | ||||||
|  |  | ||||||
| // AssetsHandlerFunc implements the static handler for serving custom or original assets. | // FileHandlerFunc implements the static handler for serving files in "public" assets | ||||||
| func AssetsHandlerFunc(prefix string) http.HandlerFunc { | func FileHandlerFunc() http.HandlerFunc { | ||||||
| 	assetFS := AssetFS() | 	assetFS := AssetFS() | ||||||
| 	prefix = strings.TrimSuffix(prefix, "/") + "/" |  | ||||||
| 	return func(resp http.ResponseWriter, req *http.Request) { | 	return func(resp http.ResponseWriter, req *http.Request) { | ||||||
| 		subPath := req.URL.Path |  | ||||||
| 		if !strings.HasPrefix(subPath, prefix) { |  | ||||||
| 			return |  | ||||||
| 		} |  | ||||||
| 		subPath = strings.TrimPrefix(subPath, prefix) |  | ||||||
|  |  | ||||||
| 		if req.Method != "GET" && req.Method != "HEAD" { | 		if req.Method != "GET" && req.Method != "HEAD" { | ||||||
| 			resp.WriteHeader(http.StatusNotFound) | 			resp.WriteHeader(http.StatusNotFound) | ||||||
| 			return | 			return | ||||||
| 		} | 		} | ||||||
|  | 		handleRequest(resp, req, assetFS, req.URL.Path) | ||||||
| 		if handleRequest(resp, req, assetFS, subPath) { |  | ||||||
| 			return |  | ||||||
| 		} |  | ||||||
|  |  | ||||||
| 		resp.WriteHeader(http.StatusNotFound) |  | ||||||
| 	} | 	} | ||||||
| } | } | ||||||
|  |  | ||||||
| @@ -71,16 +59,17 @@ func setWellKnownContentType(w http.ResponseWriter, file string) { | |||||||
| 	} | 	} | ||||||
| } | } | ||||||
|  |  | ||||||
| func handleRequest(w http.ResponseWriter, req *http.Request, fs http.FileSystem, file string) bool { | func handleRequest(w http.ResponseWriter, req *http.Request, fs http.FileSystem, file string) { | ||||||
| 	// actually, fs (http.FileSystem) is designed to be a safe interface, relative paths won't bypass its parent directory, it's also fine to do a clean here | 	// actually, fs (http.FileSystem) is designed to be a safe interface, relative paths won't bypass its parent directory, it's also fine to do a clean here | ||||||
| 	f, err := fs.Open(util.PathJoinRelX("assets", file)) | 	f, err := fs.Open(util.PathJoinRelX(file)) | ||||||
| 	if err != nil { | 	if err != nil { | ||||||
| 		if os.IsNotExist(err) { | 		if os.IsNotExist(err) { | ||||||
| 			return false | 			w.WriteHeader(http.StatusNotFound) | ||||||
|  | 			return | ||||||
| 		} | 		} | ||||||
| 		w.WriteHeader(http.StatusInternalServerError) | 		w.WriteHeader(http.StatusInternalServerError) | ||||||
| 		log.Error("[Static] Open %q failed: %v", file, err) | 		log.Error("[Static] Open %q failed: %v", file, err) | ||||||
| 		return true | 		return | ||||||
| 	} | 	} | ||||||
| 	defer f.Close() | 	defer f.Close() | ||||||
|  |  | ||||||
| @@ -88,17 +77,16 @@ func handleRequest(w http.ResponseWriter, req *http.Request, fs http.FileSystem, | |||||||
| 	if err != nil { | 	if err != nil { | ||||||
| 		w.WriteHeader(http.StatusInternalServerError) | 		w.WriteHeader(http.StatusInternalServerError) | ||||||
| 		log.Error("[Static] %q exists, but fails to open: %v", file, err) | 		log.Error("[Static] %q exists, but fails to open: %v", file, err) | ||||||
| 		return true | 		return | ||||||
| 	} | 	} | ||||||
|  |  | ||||||
| 	// Try to serve index file | 	// need to serve index file? (no at the moment) | ||||||
| 	if fi.IsDir() { | 	if fi.IsDir() { | ||||||
| 		w.WriteHeader(http.StatusNotFound) | 		w.WriteHeader(http.StatusNotFound) | ||||||
| 		return true | 		return | ||||||
| 	} | 	} | ||||||
|  |  | ||||||
| 	serveContent(w, req, fi, fi.ModTime(), f) | 	serveContent(w, req, fi, fi.ModTime(), f) | ||||||
| 	return true |  | ||||||
| } | } | ||||||
|  |  | ||||||
| type GzipBytesProvider interface { | type GzipBytesProvider interface { | ||||||
|   | |||||||
| @@ -349,9 +349,4 @@ func loadServerFrom(rootCfg ConfigProvider) { | |||||||
| 	default: | 	default: | ||||||
| 		LandingPageURL = LandingPage(landingPage) | 		LandingPageURL = LandingPage(landingPage) | ||||||
| 	} | 	} | ||||||
|  |  | ||||||
| 	HasRobotsTxt, err = util.IsFile(path.Join(CustomPath, "robots.txt")) |  | ||||||
| 	if err != nil { |  | ||||||
| 		log.Error("Unable to check if %s is a file. Error: %v", path.Join(CustomPath, "robots.txt"), err) |  | ||||||
| 	} |  | ||||||
| } | } | ||||||
|   | |||||||
							
								
								
									
										6
									
								
								public/.well-known/security.txt
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										6
									
								
								public/.well-known/security.txt
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,6 @@ | |||||||
|  | # This site is running a Gitea instance. | ||||||
|  | # Gitea related security problems could be reported to Gitea community. | ||||||
|  | # Site related security problems should be reported to this site's admin. | ||||||
|  | Contact: https://github.com/go-gitea/gitea/blob/main/SECURITY.md | ||||||
|  | Policy: https://github.com/go-gitea/gitea/blob/main/SECURITY.md | ||||||
|  | Preferred-Languages: en | ||||||
| @@ -20,7 +20,7 @@ import ( | |||||||
| func Routes() *web.Route { | func Routes() *web.Route { | ||||||
| 	base := web.NewRoute() | 	base := web.NewRoute() | ||||||
| 	base.Use(common.ProtocolMiddlewares()...) | 	base.Use(common.ProtocolMiddlewares()...) | ||||||
| 	base.Methods("GET, HEAD", "/assets/*", public.AssetsHandlerFunc("/assets/")) | 	base.Methods("GET, HEAD", "/assets/*", public.FileHandlerFunc()) | ||||||
|  |  | ||||||
| 	r := web.NewRoute() | 	r := web.NewRoute() | ||||||
| 	r.Use(common.Sessioner(), Contexter()) | 	r.Use(common.Sessioner(), Contexter()) | ||||||
|   | |||||||
| @@ -34,9 +34,12 @@ func DummyOK(w http.ResponseWriter, req *http.Request) { | |||||||
| } | } | ||||||
|  |  | ||||||
| func RobotsTxt(w http.ResponseWriter, req *http.Request) { | func RobotsTxt(w http.ResponseWriter, req *http.Request) { | ||||||
| 	filePath := util.FilePathJoinAbs(setting.CustomPath, "robots.txt") | 	robotsTxt := util.FilePathJoinAbs(setting.CustomPath, "public/robots.txt") | ||||||
|  | 	if ok, _ := util.IsExist(robotsTxt); !ok { | ||||||
|  | 		robotsTxt = util.FilePathJoinAbs(setting.CustomPath, "robots.txt") // the legacy "robots.txt" | ||||||
|  | 	} | ||||||
| 	httpcache.SetCacheControlInHeader(w.Header(), setting.StaticCacheTime) | 	httpcache.SetCacheControlInHeader(w.Header(), setting.StaticCacheTime) | ||||||
| 	http.ServeFile(w, req, filePath) | 	http.ServeFile(w, req, robotsTxt) | ||||||
| } | } | ||||||
|  |  | ||||||
| func StaticRedirect(target string) func(w http.ResponseWriter, req *http.Request) { | func StaticRedirect(target string) func(w http.ResponseWriter, req *http.Request) { | ||||||
|   | |||||||
| @@ -108,7 +108,7 @@ func Routes() *web.Route { | |||||||
| 	routes := web.NewRoute() | 	routes := web.NewRoute() | ||||||
|  |  | ||||||
| 	routes.Head("/", misc.DummyOK) // for health check - doesn't need to be passed through gzip handler | 	routes.Head("/", misc.DummyOK) // for health check - doesn't need to be passed through gzip handler | ||||||
| 	routes.Methods("GET, HEAD", "/assets/*", CorsHandler(), public.AssetsHandlerFunc("/assets/")) | 	routes.Methods("GET, HEAD", "/assets/*", CorsHandler(), public.FileHandlerFunc()) | ||||||
| 	routes.Methods("GET, HEAD", "/avatars/*", storageHandler(setting.Avatar.Storage, "avatars", storage.Avatars)) | 	routes.Methods("GET, HEAD", "/avatars/*", storageHandler(setting.Avatar.Storage, "avatars", storage.Avatars)) | ||||||
| 	routes.Methods("GET, HEAD", "/repo-avatars/*", storageHandler(setting.RepoAvatar.Storage, "repo-avatars", storage.RepoAvatars)) | 	routes.Methods("GET, HEAD", "/repo-avatars/*", storageHandler(setting.RepoAvatar.Storage, "repo-avatars", storage.RepoAvatars)) | ||||||
| 	routes.Methods("GET, HEAD", "/apple-touch-icon.png", misc.StaticRedirect("/assets/img/apple-touch-icon.png")) | 	routes.Methods("GET, HEAD", "/apple-touch-icon.png", misc.StaticRedirect("/assets/img/apple-touch-icon.png")) | ||||||
| @@ -132,15 +132,12 @@ func Routes() *web.Route { | |||||||
| 		routes.Methods("GET,HEAD", "/captcha/*", append(mid, captcha.Captchaer(context.GetImageCaptcha()))...) | 		routes.Methods("GET,HEAD", "/captcha/*", append(mid, captcha.Captchaer(context.GetImageCaptcha()))...) | ||||||
| 	} | 	} | ||||||
|  |  | ||||||
| 	if setting.HasRobotsTxt { |  | ||||||
| 		routes.Get("/robots.txt", append(mid, misc.RobotsTxt)...) |  | ||||||
| 	} |  | ||||||
|  |  | ||||||
| 	if setting.Metrics.Enabled { | 	if setting.Metrics.Enabled { | ||||||
| 		prometheus.MustRegister(metrics.NewCollector()) | 		prometheus.MustRegister(metrics.NewCollector()) | ||||||
| 		routes.Get("/metrics", append(mid, Metrics)...) | 		routes.Get("/metrics", append(mid, Metrics)...) | ||||||
| 	} | 	} | ||||||
|  |  | ||||||
|  | 	routes.Get("/robots.txt", append(mid, misc.RobotsTxt)...) | ||||||
| 	routes.Get("/ssh_info", misc.SSHInfo) | 	routes.Get("/ssh_info", misc.SSHInfo) | ||||||
| 	routes.Get("/api/healthz", healthcheck.Check) | 	routes.Get("/api/healthz", healthcheck.Check) | ||||||
|  |  | ||||||
| @@ -336,8 +333,7 @@ func registerRoutes(m *web.Route) { | |||||||
|  |  | ||||||
| 	// FIXME: not all routes need go through same middleware. | 	// FIXME: not all routes need go through same middleware. | ||||||
| 	// Especially some AJAX requests, we can reduce middleware number to improve performance. | 	// Especially some AJAX requests, we can reduce middleware number to improve performance. | ||||||
| 	// Routers. |  | ||||||
| 	// for health check |  | ||||||
| 	m.Get("/", Home) | 	m.Get("/", Home) | ||||||
| 	m.Get("/sitemap.xml", sitemapEnabled, ignExploreSignIn, HomeSitemap) | 	m.Get("/sitemap.xml", sitemapEnabled, ignExploreSignIn, HomeSitemap) | ||||||
| 	m.Group("/.well-known", func() { | 	m.Group("/.well-known", func() { | ||||||
| @@ -349,7 +345,8 @@ func registerRoutes(m *web.Route) { | |||||||
| 		m.Get("/change-password", func(ctx *context.Context) { | 		m.Get("/change-password", func(ctx *context.Context) { | ||||||
| 			ctx.Redirect(setting.AppSubURL + "/user/settings/account") | 			ctx.Redirect(setting.AppSubURL + "/user/settings/account") | ||||||
| 		}) | 		}) | ||||||
| 	}) | 		m.Any("/*", CorsHandler(), public.FileHandlerFunc()) | ||||||
|  | 	}, CorsHandler()) | ||||||
|  |  | ||||||
| 	m.Group("/explore", func() { | 	m.Group("/explore", func() { | ||||||
| 		m.Get("", func(ctx *context.Context) { | 		m.Get("", func(ctx *context.Context) { | ||||||
|   | |||||||
| @@ -38,6 +38,7 @@ func TestLinksNoLogin(t *testing.T) { | |||||||
| 		"/user2/repo1/projects/1", | 		"/user2/repo1/projects/1", | ||||||
| 		"/assets/img/404.png", | 		"/assets/img/404.png", | ||||||
| 		"/assets/img/500.png", | 		"/assets/img/500.png", | ||||||
|  | 		"/.well-known/security.txt", | ||||||
| 	} | 	} | ||||||
|  |  | ||||||
| 	for _, link := range links { | 	for _, link := range links { | ||||||
|   | |||||||
		Reference in New Issue
	
	Block a user