From 1edb57eda9826a5b7402e92d516d6b9097eb8ad3 Mon Sep 17 00:00:00 2001
From: Giteabot <teabot@gitea.io>
Date: Sun, 5 Mar 2023 05:59:58 -0500
Subject: [PATCH] Fix various bugs for "install" page (#23194) (#23286)

Backport #23194

## TLDR

* Fix the broken page / broken image problem when click "Install"
* Fix the Password Hash Algorithm display problem for #22942
* Close #20089
* Close #23183
* Close #23184

## Details

### The broken page / broken image problem when clicking on "Install"
(Redirect failed after install - #23184)

Before: when clicking on "install", all new requests will fail, because the
server has been restarted. Users just see a broken page with broken
images, sometimes the server is not ready but the user would have been
redirect to "/user/login" page, then the users see a new broken page
(connection refused or something wrong ...)


After: only check InstallLock=true for necessary handlers, and sleep for
a while before restarting the server, then the browser has enough time
to load the "post-install" page. And there is a script to check whether
"/user/login" is ready, the user will only be redirected to the login
page when the server is ready.

### During new instance setup fill 'Gitea Base URL' with
window.location.origin - #20089

If the "app_url" input contains `localhost` (the default value from
config), use current window's location href as the `app_url` (aka
ROOT_URL)


### Fix the Password Hash Algorithm display problem for "Provide the
ability to set password hash algorithm parameters #22942"

Before: the UI shows `pbkdf2$50000$50`

<details>


![image](https://user-images.githubusercontent.com/2114189/221917143-e1e54798-1698-4fee-a18d-00c48081fc39.png)

</details>

After: the UI shows `pbkdf2`

<details>


![image](https://user-images.githubusercontent.com/2114189/221916999-97a15be8-2ebb-4a01-bf93-dac18e354fcc.png)

</details>



### GET data: net::ERR_INVALID_URL #23183

Cause by empty `data:` in `<link rel="manifest"
href="data:{{.ManifestData}}">`

Co-authored-by: wxiaoguang <wxiaoguang@gmail.com>
Co-authored-by: Jason Song <i@wolfogre.com>
Co-authored-by: Lunny Xiao <xiaolunwen@gmail.com>
Co-authored-by: techknowlogick <techknowlogick@gitea.io>
---
 modules/auth/password/hash/setting.go | 33 +++++++++++++-----
 options/locale/locale_en-US.ini       |  2 +-
 routers/install/install.go            | 48 ++++++++++++++++++---------
 routers/install/routes.go             | 13 ++++++--
 templates/base/head.tmpl              |  2 +-
 templates/post-install.tmpl           |  4 +--
 web_src/js/features/install.js        | 39 +++++++++++++++++++++-
 7 files changed, 109 insertions(+), 32 deletions(-)

diff --git a/modules/auth/password/hash/setting.go b/modules/auth/password/hash/setting.go
index 7016974304..f0715f31e1 100644
--- a/modules/auth/password/hash/setting.go
+++ b/modules/auth/password/hash/setting.go
@@ -41,9 +41,8 @@ var RecommendedHashAlgorithms = []string{
 	"pbkdf2_hi",
 }
 
-// SetDefaultPasswordHashAlgorithm will take a provided algorithmName and dealias it to
-// a complete algorithm specification.
-func SetDefaultPasswordHashAlgorithm(algorithmName string) (string, *PasswordHashAlgorithm) {
+// hashAlgorithmToSpec converts an algorithm name or a specification to a full algorithm specification
+func hashAlgorithmToSpec(algorithmName string) string {
 	if algorithmName == "" {
 		algorithmName = DefaultHashAlgorithmName
 	}
@@ -52,10 +51,26 @@ func SetDefaultPasswordHashAlgorithm(algorithmName string) (string, *PasswordHas
 		algorithmName = alias
 		alias, has = aliasAlgorithmNames[algorithmName]
 	}
-
-	// algorithmName should now be a full algorithm specification
-	// e.g. pbkdf2$50000$50 rather than pbdkf2
-	DefaultHashAlgorithm = Parse(algorithmName)
-
-	return algorithmName, DefaultHashAlgorithm
+	return algorithmName
+}
+
+// SetDefaultPasswordHashAlgorithm will take a provided algorithmName and de-alias it to
+// a complete algorithm specification.
+func SetDefaultPasswordHashAlgorithm(algorithmName string) (string, *PasswordHashAlgorithm) {
+	algoSpec := hashAlgorithmToSpec(algorithmName)
+	// now we get a full specification, e.g. pbkdf2$50000$50 rather than pbdkf2
+	DefaultHashAlgorithm = Parse(algoSpec)
+	return algoSpec, DefaultHashAlgorithm
+}
+
+// ConfigHashAlgorithm will try to find a "recommended algorithm name" defined by RecommendedHashAlgorithms for config
+// This function is not fast and is only used for the installation page
+func ConfigHashAlgorithm(algorithm string) string {
+	algorithm = hashAlgorithmToSpec(algorithm)
+	for _, recommAlgo := range RecommendedHashAlgorithms {
+		if algorithm == hashAlgorithmToSpec(recommAlgo) {
+			return recommAlgo
+		}
+	}
+	return algorithm
 }
diff --git a/options/locale/locale_en-US.ini b/options/locale/locale_en-US.ini
index df66ce2339..b2fb837bf0 100644
--- a/options/locale/locale_en-US.ini
+++ b/options/locale/locale_en-US.ini
@@ -237,7 +237,6 @@ internal_token_failed = Failed to generate internal token: %v
 secret_key_failed = Failed to generate secret key: %v
 save_config_failed = Failed to save configuration: %v
 invalid_admin_setting = Administrator account setting is invalid: %v
-install_success = Welcome! Thank you for choosing Gitea. Have fun and take care!
 invalid_log_root_path = The log path is invalid: %v
 default_keep_email_private = Hide Email Addresses by Default
 default_keep_email_private_popup = Hide email addresses of new user accounts by default.
@@ -248,6 +247,7 @@ default_enable_timetracking_popup = Enable time tracking for new repositories by
 no_reply_address = Hidden Email Domain
 no_reply_address_helper = Domain name for users with a hidden email address. For example, the username 'joe' will be logged in Git as 'joe@noreply.example.org' if the hidden email domain is set to 'noreply.example.org'.
 password_algorithm = Password Hash Algorithm
+invalid_password_algorithm = Invalid password hash algorithm
 password_algorithm_helper = Set the password hashing algorithm. Algorithms have differing requirements and strength. `argon2` whilst having good characteristics uses a lot of memory and may be inappropriate for small systems.
 enable_update_checker = Enable Update Checker
 enable_update_checker_helper = Checks for new version releases periodically by connecting to gitea.io.
diff --git a/routers/install/install.go b/routers/install/install.go
index a3d64e5f73..a377c2950b 100644
--- a/routers/install/install.go
+++ b/routers/install/install.go
@@ -59,11 +59,6 @@ func Init(ctx goctx.Context) func(next http.Handler) http.Handler {
 	dbTypeNames := getSupportedDbTypeNames()
 	return func(next http.Handler) http.Handler {
 		return http.HandlerFunc(func(resp http.ResponseWriter, req *http.Request) {
-			if setting.InstallLock {
-				resp.Header().Add("Refresh", "1; url="+setting.AppURL+"user/login")
-				_ = rnd.HTML(resp, http.StatusOK, string(tplPostInstall), nil)
-				return
-			}
 			locale := middleware.Locale(resp, req)
 			startTime := time.Now()
 			ctx := context.Context{
@@ -93,6 +88,11 @@ func Init(ctx goctx.Context) func(next http.Handler) http.Handler {
 
 // Install render installation page
 func Install(ctx *context.Context) {
+	if setting.InstallLock {
+		InstallDone(ctx)
+		return
+	}
+
 	form := forms.InstallForm{}
 
 	// Database settings
@@ -162,7 +162,7 @@ func Install(ctx *context.Context) {
 	form.DefaultAllowCreateOrganization = setting.Service.DefaultAllowCreateOrganization
 	form.DefaultEnableTimetracking = setting.Service.DefaultEnableTimetracking
 	form.NoReplyAddress = setting.Service.NoReplyAddress
-	form.PasswordAlgorithm = setting.PasswordHashAlgo
+	form.PasswordAlgorithm = hash.ConfigHashAlgorithm(setting.PasswordHashAlgo)
 
 	middleware.AssignForm(form, ctx.Data)
 	ctx.HTML(http.StatusOK, tplInstall)
@@ -234,6 +234,11 @@ func checkDatabase(ctx *context.Context, form *forms.InstallForm) bool {
 
 // SubmitInstall response for submit install items
 func SubmitInstall(ctx *context.Context) {
+	if setting.InstallLock {
+		InstallDone(ctx)
+		return
+	}
+
 	var err error
 
 	form := *web.GetForm(ctx).(*forms.InstallForm)
@@ -277,7 +282,6 @@ func SubmitInstall(ctx *context.Context) {
 	setting.Database.Charset = form.Charset
 	setting.Database.Path = form.DbPath
 	setting.Database.LogSQL = !setting.IsProd
-	setting.PasswordHashAlgo = form.PasswordAlgorithm
 
 	if !checkDatabase(ctx, &form) {
 		return
@@ -499,6 +503,12 @@ func SubmitInstall(ctx *context.Context) {
 	}
 
 	if len(form.PasswordAlgorithm) > 0 {
+		var algorithm *hash.PasswordHashAlgorithm
+		setting.PasswordHashAlgo, algorithm = hash.SetDefaultPasswordHashAlgorithm(form.PasswordAlgorithm)
+		if algorithm == nil {
+			ctx.RenderWithErr(ctx.Tr("install.invalid_password_algorithm"), tplInstall, &form)
+			return
+		}
 		cfg.Section("security").Key("PASSWORD_HASH_ALGO").SetValue(form.PasswordAlgorithm)
 	}
 
@@ -571,18 +581,26 @@ func SubmitInstall(ctx *context.Context) {
 	}
 
 	log.Info("First-time run install finished!")
+	InstallDone(ctx)
 
-	ctx.Flash.Success(ctx.Tr("install.install_success"))
-
-	ctx.RespHeader().Add("Refresh", "1; url="+setting.AppURL+"user/login")
-	ctx.HTML(http.StatusOK, tplPostInstall)
-
-	// Now get the http.Server from this request and shut it down
-	// NB: This is not our hammerable graceful shutdown this is http.Server.Shutdown
-	srv := ctx.Value(http.ServerContextKey).(*http.Server)
 	go func() {
+		// Sleep for a while to make sure the user's browser has loaded the post-install page and its assets (images, css, js)
+		// What if this duration is not long enough? That's impossible -- if the user can't load the simple page in time, how could they install or use Gitea in the future ....
+		time.Sleep(3 * time.Second)
+
+		// Now get the http.Server from this request and shut it down
+		// NB: This is not our hammerable graceful shutdown this is http.Server.Shutdown
+		srv := ctx.Value(http.ServerContextKey).(*http.Server)
 		if err := srv.Shutdown(graceful.GetManager().HammerContext()); err != nil {
 			log.Error("Unable to shutdown the install server! Error: %v", err)
 		}
+
+		// After the HTTP server for "install" shuts down, the `runWeb()` will continue to run the "normal" server
 	}()
 }
+
+// InstallDone shows the "post-install" page, makes it easier to develop the page.
+// The name is not called as "PostInstall" to avoid misinterpretation as a handler for "POST /install"
+func InstallDone(ctx *context.Context) { //nolint
+	ctx.HTML(http.StatusOK, tplPostInstall)
+}
diff --git a/routers/install/routes.go b/routers/install/routes.go
index 9aa5a88d24..a8efc92fe1 100644
--- a/routers/install/routes.go
+++ b/routers/install/routes.go
@@ -6,6 +6,7 @@ package install
 import (
 	goctx "context"
 	"fmt"
+	"html"
 	"net/http"
 	"path"
 
@@ -37,7 +38,7 @@ func installRecovery(ctx goctx.Context) func(next http.Handler) http.Handler {
 				// Why we need this? The first recover will try to render a beautiful
 				// error page for user, but the process can still panic again, then
 				// we have to just recover twice and send a simple error page that
-				// should not panic any more.
+				// should not panic anymore.
 				defer func() {
 					if err := recover(); err != nil {
 						combinedErr := fmt.Sprintf("PANIC: %v\n%s", err, log.Stack(2))
@@ -107,8 +108,9 @@ func Routes(ctx goctx.Context) *web.Route {
 
 	r.Use(installRecovery(ctx))
 	r.Use(Init(ctx))
-	r.Get("/", Install)
+	r.Get("/", Install) // it must be on the root, because the "install.js" use the window.location to replace the "localhost" AppURL
 	r.Post("/", web.Bind(forms.InstallForm{}), SubmitInstall)
+	r.Get("/post-install", InstallDone)
 	r.Get("/api/healthz", healthcheck.Check)
 
 	r.NotFound(web.Wrap(installNotFound))
@@ -116,5 +118,10 @@ func Routes(ctx goctx.Context) *web.Route {
 }
 
 func installNotFound(w http.ResponseWriter, req *http.Request) {
-	http.Redirect(w, req, setting.AppURL, http.StatusFound)
+	w.Header().Add("Content-Type", "text/html; charset=utf-8")
+	w.Header().Add("Refresh", fmt.Sprintf("1; url=%s", setting.AppSubURL+"/"))
+	// do not use 30x status, because the "post-install" page needs to use 404/200 to detect if Gitea has been installed.
+	// the fetch API could follow 30x requests to the page with 200 status.
+	w.WriteHeader(http.StatusNotFound)
+	_, _ = fmt.Fprintf(w, `Not Found. <a href="%s">Go to default page</a>.`, html.EscapeString(setting.AppSubURL+"/"))
 }
diff --git a/templates/base/head.tmpl b/templates/base/head.tmpl
index c552dcfd2d..d179140b23 100644
--- a/templates/base/head.tmpl
+++ b/templates/base/head.tmpl
@@ -4,7 +4,7 @@
 	<meta charset="utf-8">
 	<meta name="viewport" content="width=device-width, initial-scale=1">
 	<title>{{if .Title}}{{.Title | RenderEmojiPlain}} - {{end}}{{if .Repository.Name}}{{.Repository.Name}} - {{end}}{{AppName}}</title>
-	<link rel="manifest" href="data:{{.ManifestData}}">
+	{{if .ManifestData}}<link rel="manifest" href="data:{{.ManifestData}}">{{end}}
 	<meta name="theme-color" content="{{ThemeColorMetaTag}}">
 	<meta name="default-theme" content="{{DefaultTheme}}">
 	<meta name="author" content="{{if .Repository}}{{.Owner.Name}}{{else}}{{MetaAuthor}}{{end}}">
diff --git a/templates/post-install.tmpl b/templates/post-install.tmpl
index 0d6cd3082c..f237a6e01b 100644
--- a/templates/post-install.tmpl
+++ b/templates/post-install.tmpl
@@ -1,5 +1,5 @@
 {{template "base/head" .}}
-<div role="main" aria-label="{{.Title}}" class="page-content install">
+<div role="main" aria-label="{{.Title}}" class="page-content install post-install">
 	<div class="ui container">
 		<div class="ui grid">
 			<div class="sixteen wide column content">
@@ -13,7 +13,7 @@
 					</div>
 					<div class="ui stackable middle very relaxed page grid">
 						<div class="sixteen wide center aligned centered column">
-							<p><a href="{{AppSubUrl}}/user/login">{{AppSubUrl}}/user/login</a></p>
+							<p><a id="goto-user-login" href="{{AppSubUrl}}/user/login">{{.locale.Tr "loading"}}</a></p>
 						</div>
 					</div>
 				</div>
diff --git a/web_src/js/features/install.js b/web_src/js/features/install.js
index 2ba6fe1279..23122ca4c3 100644
--- a/web_src/js/features/install.js
+++ b/web_src/js/features/install.js
@@ -2,10 +2,18 @@ import $ from 'jquery';
 import {hideElem, showElem} from '../utils/dom.js';
 
 export function initInstall() {
-  if ($('.page-content.install').length === 0) {
+  const $page = $('.page-content.install');
+  if ($page.length === 0) {
     return;
   }
+  if ($page.is('.post-install')) {
+    initPostInstall();
+  } else {
+    initPreInstall();
+  }
+}
 
+function initPreInstall() {
   const defaultDbUser = 'gitea';
   const defaultDbName = 'gitea';
 
@@ -40,6 +48,18 @@ export function initInstall() {
     } // else: for SQLite3, the default path is always prepared by backend code (setting)
   }).trigger('change');
 
+  const $appUrl = $('#app_url');
+  const configAppUrl = $appUrl.val();
+  if (configAppUrl.includes('://localhost')) {
+    $appUrl.val(window.location.href);
+  }
+
+  const $domain = $('#domain');
+  const configDomain = $domain.val().trim();
+  if (configDomain === 'localhost') {
+    $domain.val(window.location.hostname);
+  }
+
   // TODO: better handling of exclusive relations.
   $('#offline-mode input').on('change', function () {
     if ($(this).is(':checked')) {
@@ -83,3 +103,20 @@ export function initInstall() {
     }
   });
 }
+
+function initPostInstall() {
+  const el = document.getElementById('goto-user-login');
+  if (!el) return;
+
+  const targetUrl = el.getAttribute('href');
+  let tid = setInterval(async () => {
+    try {
+      const resp = await fetch(targetUrl);
+      if (tid && resp.status === 200) {
+        clearInterval(tid);
+        tid = null;
+        window.location.href = targetUrl;
+      }
+    } catch {}
+  }, 1000);
+}