From 6ad60b50ab67544b1af13c2c607ab11f0cc16144 Mon Sep 17 00:00:00 2001 From: Luis Eduardo Date: Sat, 22 Feb 2025 17:42:15 +0000 Subject: [PATCH 01/27] Update FetchGoat name in support-project-v1.json --- assets/support-project-v1.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/assets/support-project-v1.json b/assets/support-project-v1.json index d844e45b..d5a16ad5 100644 --- a/assets/support-project-v1.json +++ b/assets/support-project-v1.json @@ -30,7 +30,7 @@ ], "silver": [ { - "name": "FetchGoat - Simplifying Logistics", + "name": "FetchGoat", "logo": "https://raw.githubusercontent.com/eduardolat/pgbackweb/refs/heads/develop/assets/sponsors/FetchGoat.png", "link": "https://fetchgoat.com?utm_source=pgbackweb&utm_medium=referral&utm_campaign=sponsorship" }, From ada595e47e88c417b5e27db2b8c6c339d17f9a92 Mon Sep 17 00:00:00 2001 From: Luis Eduardo Date: Sat, 22 Feb 2025 17:45:43 +0000 Subject: [PATCH 02/27] =?UTF-8?q?=F0=9F=92=96=20Update=20FetchGoat=20spons?= =?UTF-8?q?orship=20details=20in=20support-project-v1.json?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- assets/support-project-v1.json | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/assets/support-project-v1.json b/assets/support-project-v1.json index d5a16ad5..00760418 100644 --- a/assets/support-project-v1.json +++ b/assets/support-project-v1.json @@ -29,15 +29,15 @@ } ], "silver": [ - { - "name": "FetchGoat", - "logo": "https://raw.githubusercontent.com/eduardolat/pgbackweb/refs/heads/develop/assets/sponsors/FetchGoat.png", - "link": "https://fetchgoat.com?utm_source=pgbackweb&utm_medium=referral&utm_campaign=sponsorship" - }, { "name": "Become a silver sponsor", "logo": "/images/plus.png", "link": "https://buymeacoffee.com/eduardolat" + }, + { + "name": "FetchGoat - Simplifying Logistics", + "logo": "https://raw.githubusercontent.com/eduardolat/pgbackweb/refs/heads/develop/assets/sponsors/FetchGoat.png", + "link": "https://fetchgoat.com?utm_source=pgbackweb&utm_medium=referral&utm_campaign=sponsorship" } ], "bronze": [ From db60518f86a8726ae79394c15195379c54209bde Mon Sep 17 00:00:00 2001 From: Luis Eduardo Date: Fri, 14 Mar 2025 17:21:24 +0000 Subject: [PATCH 03/27] Add Hetzner logo to assets --- assets/logos/hetzner.png | Bin 0 -> 2752 bytes 1 file changed, 0 insertions(+), 0 deletions(-) create mode 100644 assets/logos/hetzner.png diff --git a/assets/logos/hetzner.png b/assets/logos/hetzner.png new file mode 100644 index 0000000000000000000000000000000000000000..04939f694bf8c6d12b0ae0cae481d6b82e442888 GIT binary patch literal 2752 zcma)8YgAKb7NteN8XjuJ;0ro~Q>Q+_%0LJZQou;57>bAt0|b;&YAGO(5FjKOVM?kf zR8c_*AgBdw2?`PjAw-}pPC-aC@(6jzD_jF40TL1d$(@_Dbvm==N7p*%p0oGa-#*`; zZ>@XrQ*5BkhV2{7%*U+-ZQ|_ygS#q%qGpjtw(wdvAW-lW)w-Hk=4L>~|tdFfW?_+r-G$FGH_7D8Qms z-tnmG#p~W#Cw>&7_d0$e?%)jWoj5cKwU$GUEiYEjONyHzL!>CDG~Xt}jAo~} zbUAlcubWeJuf8qd%<%|qlo!Oxh;^NH8oTU4&EB^SIWPaThzQZn9^0yQ&=Rt2|8QOz zo+^)cml88R(Mi*uy)hAcVbAr=Gvaf2R0xe&H}ob|+o=97@>6MZ z_i^CpVsQM_NQp6ytn5ZE?`B>LxfcH>I1&vuhGw6BDzIo5GE*9kpF%C$EDeS;-*c5R zA37@@xeeAkqn`{!k2szCj2Z=Fm1(*Sm(^29RvFAYUG~1Gy{oi)fYx3g^FtvM=H4If zlP+VV?_f1U`FX@b|Cp?wC_B<7_U!Gbr3;7wK1;OrO?1S-hl+Z)-x+of+&B!cbXkA> zs&F=+o*?Dl3L`X0zrTXS=d(6dBJlyVQ$^X)qK(5lq-9pEcqt0MBPS&)K^)dvZAC>& z*-0@>&4$YC5K)1naOjqXfu9!d%aZ#2Tu`Qxkl3~4A!X-9$c@>RmjEfI9rtJXDKHh; zAu&v=1SHhPaEZ;dFjZWao5H>$&2DSQ9Zii3z^*Hw$ZOuYgyUPF9Pm0}Hy zKNPO|nKqp^Lb3gJs25p`#F^xN3L9mfz232~qBH?&M3E<+CHUDLf$j%sECP}9j&lT~E)(KdRy+&#u)^eOb=Q_n zrHk_QE7LR!4CgsD}}~ z7QF<3djJ@6u3>qU1W-IW;6}praY3=WdZy>G`;rtUGAMf@0lC7QB<$>HoWBrw(6GAD z=b0-adruE)7w(aS`g-@(vFr7-K|3@vYqK*w z`>&VI_e!d=mIf(gTK4J-YIBdTUP--3{+02&t#MU)&rVgNRqICGX$wjM;1<#)ttoD7=Ay%I<~%Ff zq*~DG_VS%Tu+P8&K{^qzZ6y+T^Wb;rA-P1W-cHNZ_H?l*Abe}F?kDbGZrP+_{Br@m zCl#E^P$@Z&Qep?a*2cBX>GHd`E*RP$+ygg}#D!;DZ}!$x(O*#HyiyEWJ*Ou%!1ND- zBp*tcmW&Gc56s5VFd!bLpS}dJq&>7c{enA8e=-Ptn!K6Ru~~QWx$7lzf37>AxvkQt zY;*FHfSjXu6=h)it85qD_dQPE9GSrK?AR^2K%}?!tST#1wNSUR0=%%LEPgOnQ4_+F zBgd}|Enfpx=?|mVkp$yT9M5J_q|QfA=2H8rDHNRM=mo>F# zwpIv;K~>4*d_?s7rC;A0^s72WE+mZ7AK?5z!@WvcN%F#w?7U2%oE{SR+OW3h_AiZf zRFT==HOig2JxGbj_hP4;tJBKm#n@WSIV$B~a=zTeJ2jQeez1`86t7CfF+65Np zrjA5zIOn1pm7HN6)D1SeUui^yQaj&Q?W+%nwb$YK)r~OyUKux{fNZ0Fc8tv9&VI?G zTKQMuUn@IWUNvqZuBK}vE|G+BWwnPKJ5-AbgKacn8=69%7z8=T=O#;YZC*Hv`+Gni zp}C)<*swBal@SOW1s9(L5D+xoO20&oSWr@LY0xX<)|%@XiO*m;?}{1?MGdXs!5ymY z?O>C62CQ9O2mGiJDxn8zA<8+CQ4*b4L<~T5Y6_r-)?nElRayzq}0r6ggX<$?2=z|O8^hG@TM*5B3(k9 z|80TIcNQjxDUx36P$P0~IiQ11j`8!O+R1hdZ^((PX!)hEN*Ka-%E=)N)_+EWrWy2!fj6y|&ga0>w)dstqBv-VQ`(dc57%9q@V9aLeaiE$q^ZHMUO>wQ;Jb*NGK z|Km&=tDiMBVw3*Y9W{>s1?fMCiQGXyxEjaBuv_mI<{SbnwWp!pAtThzGKOCWjpBZV zBKzB{zku;v3<{dMT8j2ZluvBQZ4zSG+l=a8aI?@p_o0cCVbU$C_7Or;AftRgLKDsl z6QLBPMJT(VJeF=D*+;Obvi4HnW9HdaG=w%L0~>rJK~E+LOp1Gq%4oe8W>ymugvmR_pg-al#s$aiVIL!+}{H$MvU64tx@Q)TQzg}UZ^PM6wYSGSaTx67 jfcDCjyA=Lgv!GdMOGCdbzB?HD-82irU=P#% Date: Fri, 14 Mar 2025 17:27:28 +0000 Subject: [PATCH 04/27] Add Hetzner referral link --- assets/support-project-v1.json | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/assets/support-project-v1.json b/assets/support-project-v1.json index 00760418..407ee7e4 100644 --- a/assets/support-project-v1.json +++ b/assets/support-project-v1.json @@ -6,6 +6,12 @@ "link": "https://app.hapi.trade/rewards?code=LUIJER5", "description": "Join Hapi to easily buy stocks and crypto! Enjoy a 100% chance to win-win up to $500 in crypto rewards in your first deposit!" }, + { + "name": "Hetzner", + "logo": "https://raw.githubusercontent.com/eduardolat/pgbackweb/refs/heads/develop/assets/logos/hetzner.png", + "link": "https://hetzner.cloud/?ref=TdOypLgK8yGR", + "description": "Get started with Hetzner Cloud today and enjoy €20 in free credits just for signing up through this link! Their powerful cloud servers offer excellent performance at competitive prices. After you spend your first €10, I'll receive €10 in credits too, making it a win-win for both of us!" + }, { "name": "Digital Ocean", "logo": "/images/third-party/digital-ocean.png", From 8054b127e75372a501a8d34ab2c72e7abb367d8c Mon Sep 17 00:00:00 2001 From: Luis Eduardo Date: Fri, 14 Mar 2025 17:34:40 +0000 Subject: [PATCH 05/27] Update Hetzner logo in support-project-v1.json and add new horizontal logo asset --- assets/logos/hetzner-horizontal.png | Bin 0 -> 1595 bytes assets/support-project-v1.json | 2 +- 2 files changed, 1 insertion(+), 1 deletion(-) create mode 100644 assets/logos/hetzner-horizontal.png diff --git a/assets/logos/hetzner-horizontal.png b/assets/logos/hetzner-horizontal.png new file mode 100644 index 0000000000000000000000000000000000000000..6731f30bbbd4f0408a64ed47849fd6fd491352d2 GIT binary patch literal 1595 zcmV-B2E_S^P)lSJ0v;bmiI`lc*3jTGrke zqzPfuUDw_iL~{t6me<}GBoATJ$y@a%cU+|=h|Tq_dgHoEnnT#MZ`B)dU0@Z$rjs|a z(d@cPSFoAo9TCPRceimLZJZal4Pw)Tv5^p?%6Wk`!{`icZD+N?U6Hf(DE;Qg_N7i( zU~wNUC%hy4+FXt%VfsyWgcR^_L!+0m6vIEI9G*SlQw6g)s*grt!qMh9kRNzQc(f6k zoG*ALF3D=B4cH7HAv?px0NRjg(1r+u$5m0=2)X8rrzK+>#A#-*!MD_PnKqjmPW$FI zh||tsgU_vr+D7@RjTQ?V)YbfY8$4_qwT<|zjjauxgV<2jHk;r!60?mWhhzm@2sqhf z^=jk0H+#5#cC(??#@NQaHjXyMYUB5uhPC0bP_hg59T^u2+c zR+}h#gSBZWF`KV(o6Bx6$0s)VZY=y_8@$P><#jf@(OBDH?=_>1?&_g`bvJHPjpin= zKBt}G`q{IM@oT-Xr42ZMjX}`HIOS`5gSBDrHpa;d5THjq=Dj*U#)8^|f3O*?#4T@9rEbAcCRvxU`()W$FpwT+r`w?<3a zHkzZhktsJhOG?|OrN!7_Z4$S+#%-e})aFWw+o-s0#Jx5xCvKy8F*bX+eoow`q_}OA z^WH$qsoSW?ZNzzRKqzsWmX~7FuT4p*+vF6vjdHxnsU>xrl4G|KyPKSN{T%1LPUEkE zTEZSduQn|wZlgbGcALi1M!D4{CrH4(HLu^Ldix&E6C3S!Pg*SZ2Cz24_M~rYZG$vk zKWCHgMnk@Bu(&W4=(8LQN42Mk`gaNynWQkfeh&W`D6`x?DG3{KxLczoWt-~4v>cnP z=wG5FY*X%t60uPZwc(U)8cN1S>}!)#{5I|Jp3_esN0wyMt4+%Z*gV>io%+Q|#ztA+ zfzYO?o!;)hkXSHez>^Q*(+o zP@HAhocq_$DcV4B(y&ohwP`s`8z@c^Hey+uG;N?b3$WSrUs9?zP@EKOl*N{smSk<9 zI4RhO#jo3xl&;M{aRPjI_a{%c!5^rrC%NUGZH^|{v4IU1$DfUDN|$pQ*2XfLeb`t= z=O#8-oM1Mcc<9a9{-^n|ux%EKbZeZ`Kw7W*8lRt^0vdtF$mW{?_~pzlNChu*3aiNy^7-sC|B3Ap3!5;%86#@o1;| to+>wbCi>!hREVMK~~0k_i+FK002ovPDHLkV1jxc5g`Bo literal 0 HcmV?d00001 diff --git a/assets/support-project-v1.json b/assets/support-project-v1.json index 407ee7e4..d133c114 100644 --- a/assets/support-project-v1.json +++ b/assets/support-project-v1.json @@ -8,7 +8,7 @@ }, { "name": "Hetzner", - "logo": "https://raw.githubusercontent.com/eduardolat/pgbackweb/refs/heads/develop/assets/logos/hetzner.png", + "logo": "https://raw.githubusercontent.com/eduardolat/pgbackweb/refs/heads/develop/assets/logos/hetzner-horizontal.png", "link": "https://hetzner.cloud/?ref=TdOypLgK8yGR", "description": "Get started with Hetzner Cloud today and enjoy €20 in free credits just for signing up through this link! Their powerful cloud servers offer excellent performance at competitive prices. After you spend your first €10, I'll receive €10 in credits too, making it a win-win for both of us!" }, From 2e0447d500018888779db7d9545569f6ee9a66ea Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?S=C3=BCli=20Patrik=20P=C3=A9ter?= Date: Fri, 3 Oct 2025 08:21:37 +0200 Subject: [PATCH 06/27] Adds support for path prefix configuration Introduces configurable path prefix to serve application under subpath (e.g., /pgbackweb) Implements validation for path prefixes in environment variables Updates all redirects and routes to use the configured path prefix This allows deploying the app at a custom URL path while maintaining correct functionality. --- README.md | 5 ++ cmd/app/main.go | 6 +- internal/config/env.go | 1 + internal/config/env_validate.go | 4 + internal/util/pathutil/pathutil.go | 35 +++++++++ internal/util/pathutil/pathutil_test.go | 78 +++++++++++++++++++ internal/validate/path_prefix.go | 43 ++++++++++ internal/validate/path_prefix_test.go | 78 +++++++++++++++++++ internal/view/middleware/require_auth.go | 11 ++- internal/view/middleware/require_no_auth.go | 6 +- internal/view/router.go | 12 ++- internal/view/web/auth/create_first_user.go | 5 +- internal/view/web/auth/login.go | 5 +- internal/view/web/auth/logout.go | 5 +- .../web/dashboard/backups/create_backup.go | 3 +- .../dashboard/databases/create_database.go | 3 +- .../destinations/create_destination.go | 3 +- .../web/dashboard/webhooks/create_webhook.go | 3 +- internal/view/web/router.go | 7 +- 19 files changed, 289 insertions(+), 24 deletions(-) create mode 100644 internal/util/pathutil/pathutil.go create mode 100644 internal/util/pathutil/pathutil_test.go create mode 100644 internal/validate/path_prefix.go create mode 100644 internal/validate/path_prefix_test.go diff --git a/README.md b/README.md index 805867d9..064a35bf 100644 --- a/README.md +++ b/README.md @@ -79,6 +79,7 @@ services: environment: PBW_ENCRYPTION_KEY: "my_secret_key" # Change this to a strong key PBW_POSTGRES_CONN_STRING: "postgresql://postgres:password@postgres:5432/pgbackweb?sslmode=disable" + # PBW_PATH_PREFIX: "/pgbackweb" # Optional: Use this if serving under a subpath TZ: "America/Guatemala" # Set your timezone, optional depends_on: postgres: @@ -119,6 +120,10 @@ You only need to configure the following environment variables: - `PBW_LISTEN_PORT`: Port for the server to listen on, default 8085 (optional) +- `PBW_PATH_PREFIX`: Path prefix for the application URL. Use this when serving + the application under a subpath (e.g., `/pgbackweb`). Must start with `/` and + not end with `/`. Default is empty (optional) + - `TZ`: Your [timezone](https://en.wikipedia.org/wiki/List_of_tz_database_time_zones#List) (optional). Default is `UTC`. This impacts logging, backup filenames and diff --git a/cmd/app/main.go b/cmd/app/main.go index 632deec9..322952e5 100644 --- a/cmd/app/main.go +++ b/cmd/app/main.go @@ -8,6 +8,7 @@ import ( "github.com/eduardolat/pgbackweb/internal/integration" "github.com/eduardolat/pgbackweb/internal/logger" "github.com/eduardolat/pgbackweb/internal/service" + "github.com/eduardolat/pgbackweb/internal/util/pathutil" "github.com/eduardolat/pgbackweb/internal/view" "github.com/labstack/echo/v4" ) @@ -18,6 +19,9 @@ func main() { logger.FatalError("error getting environment variables", logger.KV{"error": err}) } + // Initialize the path prefix utility + pathutil.SetPathPrefix(env.PBW_PATH_PREFIX) + cr, err := cron.New() if err != nil { logger.FatalError("error initializing cron scheduler", logger.KV{"error": err}) @@ -44,7 +48,7 @@ func main() { app := echo.New() app.HideBanner = true app.HidePort = true - view.MountRouter(app, servs) + view.MountRouter(app, servs, env) address := env.PBW_LISTEN_HOST + ":" + env.PBW_LISTEN_PORT logger.Info("server started at http://localhost:"+env.PBW_LISTEN_PORT, logger.KV{ diff --git a/internal/config/env.go b/internal/config/env.go index b4067ee7..31d39852 100644 --- a/internal/config/env.go +++ b/internal/config/env.go @@ -12,6 +12,7 @@ type Env struct { PBW_POSTGRES_CONN_STRING string `env:"PBW_POSTGRES_CONN_STRING,required"` PBW_LISTEN_HOST string `env:"PBW_LISTEN_HOST" envDefault:"0.0.0.0"` PBW_LISTEN_PORT string `env:"PBW_LISTEN_PORT" envDefault:"8085"` + PBW_PATH_PREFIX string `env:"PBW_PATH_PREFIX" envDefault:""` } var ( diff --git a/internal/config/env_validate.go b/internal/config/env_validate.go index b538a8b1..07aa366d 100644 --- a/internal/config/env_validate.go +++ b/internal/config/env_validate.go @@ -16,5 +16,9 @@ func validateEnv(env Env) error { return fmt.Errorf("invalid listen port %s, valid values are 1-65535", env.PBW_LISTEN_PORT) } + if !validate.PathPrefix(env.PBW_PATH_PREFIX) { + return fmt.Errorf("invalid path prefix %s, must start with / and not end with / (or be empty)", env.PBW_PATH_PREFIX) + } + return nil } diff --git a/internal/util/pathutil/pathutil.go b/internal/util/pathutil/pathutil.go new file mode 100644 index 00000000..d67e7779 --- /dev/null +++ b/internal/util/pathutil/pathutil.go @@ -0,0 +1,35 @@ +package pathutil + +import "sync" + +var ( + pathPrefix string + pathPrefixOnce sync.Once +) + +// SetPathPrefix sets the path prefix once. This should be called during +// application initialization with the value from the environment config. +func SetPathPrefix(prefix string) { + pathPrefixOnce.Do(func() { + pathPrefix = prefix + }) +} + +// GetPathPrefix returns the configured path prefix. +func GetPathPrefix() string { + return pathPrefix +} + +// BuildPath constructs a full path by prepending the configured path prefix +// to the given path. If no prefix is configured, returns the path as-is. +// +// Examples: +// - BuildPath("/dashboard") with prefix "/pgbackweb" -> "/pgbackweb/dashboard" +// - BuildPath("/dashboard") with no prefix -> "/dashboard" +// - BuildPath("") with prefix "/pgbackweb" -> "/pgbackweb" +func BuildPath(path string) string { + if pathPrefix == "" { + return path + } + return pathPrefix + path +} diff --git a/internal/util/pathutil/pathutil_test.go b/internal/util/pathutil/pathutil_test.go new file mode 100644 index 00000000..2676332f --- /dev/null +++ b/internal/util/pathutil/pathutil_test.go @@ -0,0 +1,78 @@ +package pathutil + +import "testing" + +func TestBuildPath(t *testing.T) { + t.Helper() + + tests := []struct { + name string + prefix string + path string + expected string + shouldSkip bool // Skip if prefix was already set + }{ + { + name: "no prefix configured", + prefix: "", + path: "/dashboard", + expected: "/dashboard", + }, + { + name: "with prefix - dashboard", + prefix: "/pgbackweb", + path: "/dashboard", + expected: "/pgbackweb/dashboard", + }, + { + name: "with prefix - api", + prefix: "/pgbackweb", + path: "/api/v1/health", + expected: "/pgbackweb/api/v1/health", + }, + { + name: "with prefix - root", + prefix: "/pgbackweb", + path: "", + expected: "/pgbackweb", + }, + { + name: "with prefix - auth", + prefix: "/pgbackweb", + path: "/auth/login", + expected: "/pgbackweb/auth/login", + }, + { + name: "empty prefix and empty path", + prefix: "", + path: "", + expected: "", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + // Reset for each test + pathPrefix = tt.prefix + result := BuildPath(tt.path) + if result != tt.expected { + t.Errorf("BuildPath(%q) with prefix %q = %q, want %q", tt.path, tt.prefix, result, tt.expected) + } + }) + } +} + +func TestGetPathPrefix(t *testing.T) { + t.Helper() + + // Set a test prefix + pathPrefix = "/test-prefix" + + result := GetPathPrefix() + if result != "/test-prefix" { + t.Errorf("GetPathPrefix() = %q, want %q", result, "/test-prefix") + } + + // Reset + pathPrefix = "" +} diff --git a/internal/validate/path_prefix.go b/internal/validate/path_prefix.go new file mode 100644 index 00000000..0d4ff44a --- /dev/null +++ b/internal/validate/path_prefix.go @@ -0,0 +1,43 @@ +package validate + +import "strings" + +// PathPrefix validates that a path prefix is correctly formatted. +// +// Valid path prefixes: +// - Empty string (no prefix) +// - Must start with / +// - Must NOT end with / +// - No whitespace allowed +// +// Examples: +// - "" -> true (no prefix) +// - "/api" -> true +// - "/pgbackweb" -> true +// - "/app/v1" -> true +// - "api" -> false (doesn't start with /) +// - "/api/" -> false (ends with /) +// - "/ api" -> false (contains whitespace) +func PathPrefix(pathPrefix string) bool { + // Empty string is valid (no prefix) + if pathPrefix == "" { + return true + } + + // Must start with / + if !strings.HasPrefix(pathPrefix, "/") { + return false + } + + // Must NOT end with / + if strings.HasSuffix(pathPrefix, "/") { + return false + } + + // No whitespace allowed + if strings.ContainsAny(pathPrefix, " \t\n\r") { + return false + } + + return true +} diff --git a/internal/validate/path_prefix_test.go b/internal/validate/path_prefix_test.go new file mode 100644 index 00000000..a770baf2 --- /dev/null +++ b/internal/validate/path_prefix_test.go @@ -0,0 +1,78 @@ +package validate + +import "testing" + +func TestPathPrefix(t *testing.T) { + t.Helper() + + tests := []struct { + name string + input string + expected bool + }{ + { + name: "empty string is valid", + input: "", + expected: true, + }, + { + name: "valid simple path", + input: "/api", + expected: true, + }, + { + name: "valid complex path", + input: "/pgbackweb", + expected: true, + }, + { + name: "valid nested path", + input: "/app/v1", + expected: true, + }, + { + name: "valid deep nested path", + input: "/api/app/v1", + expected: true, + }, + { + name: "invalid - doesn't start with slash", + input: "api", + expected: false, + }, + { + name: "invalid - ends with slash", + input: "/api/", + expected: false, + }, + { + name: "invalid - only slash", + input: "/", + expected: false, + }, + { + name: "invalid - contains space", + input: "/api path", + expected: false, + }, + { + name: "invalid - contains tab", + input: "/api\tpath", + expected: false, + }, + { + name: "invalid - contains newline", + input: "/api\npath", + expected: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := PathPrefix(tt.input) + if result != tt.expected { + t.Errorf("PathPrefix(%q) = %v, want %v", tt.input, result, tt.expected) + } + }) + } +} diff --git a/internal/view/middleware/require_auth.go b/internal/view/middleware/require_auth.go index 8541211b..f3fd2082 100644 --- a/internal/view/middleware/require_auth.go +++ b/internal/view/middleware/require_auth.go @@ -4,6 +4,7 @@ import ( "net/http" "github.com/eduardolat/pgbackweb/internal/logger" + "github.com/eduardolat/pgbackweb/internal/util/pathutil" "github.com/eduardolat/pgbackweb/internal/view/reqctx" "github.com/labstack/echo/v4" htmx "github.com/nodxdev/nodxgo-htmx" @@ -29,11 +30,13 @@ func (m *Middleware) RequireAuth(next echo.HandlerFunc) echo.HandlerFunc { } if usersQty == 0 { - htmx.ServerSetRedirect(c.Response().Header(), "/auth/create-first-user") - return c.Redirect(http.StatusFound, "/auth/create-first-user") + redirectPath := pathutil.BuildPath("/auth/create-first-user") + htmx.ServerSetRedirect(c.Response().Header(), redirectPath) + return c.Redirect(http.StatusFound, redirectPath) } - htmx.ServerSetRedirect(c.Response().Header(), "/auth/login") - return c.Redirect(http.StatusFound, "/auth/login") + redirectPath := pathutil.BuildPath("/auth/login") + htmx.ServerSetRedirect(c.Response().Header(), redirectPath) + return c.Redirect(http.StatusFound, redirectPath) } } diff --git a/internal/view/middleware/require_no_auth.go b/internal/view/middleware/require_no_auth.go index 03c4a5b6..c9a57e0f 100644 --- a/internal/view/middleware/require_no_auth.go +++ b/internal/view/middleware/require_no_auth.go @@ -3,6 +3,7 @@ package middleware import ( "net/http" + "github.com/eduardolat/pgbackweb/internal/util/pathutil" "github.com/eduardolat/pgbackweb/internal/view/reqctx" "github.com/labstack/echo/v4" htmx "github.com/nodxdev/nodxgo-htmx" @@ -13,8 +14,9 @@ func (m *Middleware) RequireNoAuth(next echo.HandlerFunc) echo.HandlerFunc { reqCtx := reqctx.GetCtx(c) if reqCtx.IsAuthed { - htmx.ServerSetRedirect(c.Response().Header(), "/dashboard") - return c.Redirect(http.StatusFound, "/dashboard") + redirectPath := pathutil.BuildPath("/dashboard") + htmx.ServerSetRedirect(c.Response().Header(), redirectPath) + return c.Redirect(http.StatusFound, redirectPath) } return next(c) diff --git a/internal/view/router.go b/internal/view/router.go index 730dda4e..1290c9e9 100644 --- a/internal/view/router.go +++ b/internal/view/router.go @@ -3,6 +3,7 @@ package view import ( "time" + "github.com/eduardolat/pgbackweb/internal/config" "github.com/eduardolat/pgbackweb/internal/service" "github.com/eduardolat/pgbackweb/internal/view/api" "github.com/eduardolat/pgbackweb/internal/view/middleware" @@ -11,20 +12,23 @@ import ( "github.com/labstack/echo/v4" ) -func MountRouter(app *echo.Echo, servs *service.Service) { +func MountRouter(app *echo.Echo, servs *service.Service, env config.Env) { mids := middleware.New(servs) + // Create the base group with the path prefix (if any) + baseGroup := app.Group(env.PBW_PATH_PREFIX) + browserCache := mids.NewBrowserCacheMiddleware( middleware.BrowserCacheMiddlewareConfig{ CacheDuration: time.Hour * 24 * 30, ExcludedFiles: []string{"/robots.txt"}, }, ) - app.Group("", browserCache).StaticFS("", static.StaticFs) + baseGroup.Group("", browserCache).StaticFS("", static.StaticFs) - apiGroup := app.Group("/api") + apiGroup := baseGroup.Group("/api") api.MountRouter(apiGroup, mids, servs) - webGroup := app.Group("", mids.InjectReqctx) + webGroup := baseGroup.Group("", mids.InjectReqctx) web.MountRouter(webGroup, mids, servs) } diff --git a/internal/view/web/auth/create_first_user.go b/internal/view/web/auth/create_first_user.go index 11d5e5d7..7d04e996 100644 --- a/internal/view/web/auth/create_first_user.go +++ b/internal/view/web/auth/create_first_user.go @@ -6,6 +6,7 @@ import ( "github.com/eduardolat/pgbackweb/internal/database/dbgen" "github.com/eduardolat/pgbackweb/internal/logger" "github.com/eduardolat/pgbackweb/internal/util/echoutil" + "github.com/eduardolat/pgbackweb/internal/util/pathutil" "github.com/eduardolat/pgbackweb/internal/validate" "github.com/eduardolat/pgbackweb/internal/view/web/component" "github.com/eduardolat/pgbackweb/internal/view/web/layout" @@ -29,7 +30,7 @@ func (h *handlers) createFirstUserPageHandler(c echo.Context) error { return c.String(http.StatusInternalServerError, "Internal server error") } if usersQty > 0 { - return c.Redirect(http.StatusFound, "/auth/login") + return c.Redirect(http.StatusFound, pathutil.BuildPath("/auth/login")) } return echoutil.RenderNodx(c, http.StatusOK, createFirstUserPage()) @@ -135,6 +136,6 @@ func (h *handlers) createFirstUserHandler(c echo.Context) error { } return respondhtmx.AlertWithRedirect( - c, "User created successfully", "/auth/login", + c, "User created successfully", pathutil.BuildPath("/auth/login"), ) } diff --git a/internal/view/web/auth/login.go b/internal/view/web/auth/login.go index bde5aab6..c0827247 100644 --- a/internal/view/web/auth/login.go +++ b/internal/view/web/auth/login.go @@ -5,6 +5,7 @@ import ( "github.com/eduardolat/pgbackweb/internal/logger" "github.com/eduardolat/pgbackweb/internal/util/echoutil" + "github.com/eduardolat/pgbackweb/internal/util/pathutil" "github.com/eduardolat/pgbackweb/internal/validate" "github.com/eduardolat/pgbackweb/internal/view/web/component" "github.com/eduardolat/pgbackweb/internal/view/web/layout" @@ -28,7 +29,7 @@ func (h *handlers) loginPageHandler(c echo.Context) error { return c.String(http.StatusInternalServerError, "Internal server error") } if usersQty == 0 { - return c.Redirect(http.StatusFound, "/auth/create-first-user") + return c.Redirect(http.StatusFound, pathutil.BuildPath("/auth/create-first-user")) } return echoutil.RenderNodx(c, http.StatusOK, loginPage()) @@ -108,5 +109,5 @@ func (h *handlers) loginHandler(c echo.Context) error { } h.servs.AuthService.SetSessionCookie(c, session.DecryptedToken) - return respondhtmx.Redirect(c, "/dashboard") + return respondhtmx.Redirect(c, pathutil.BuildPath("/dashboard")) } diff --git a/internal/view/web/auth/logout.go b/internal/view/web/auth/logout.go index 81d3f30d..d70356e1 100644 --- a/internal/view/web/auth/logout.go +++ b/internal/view/web/auth/logout.go @@ -1,6 +1,7 @@ package auth import ( + "github.com/eduardolat/pgbackweb/internal/util/pathutil" "github.com/eduardolat/pgbackweb/internal/view/reqctx" "github.com/eduardolat/pgbackweb/internal/view/web/respondhtmx" "github.com/labstack/echo/v4" @@ -15,7 +16,7 @@ func (h *handlers) logoutHandler(c echo.Context) error { } h.servs.AuthService.ClearSessionCookie(c) - return respondhtmx.Redirect(c, "/auth/login") + return respondhtmx.Redirect(c, pathutil.BuildPath("/auth/login")) } func (h *handlers) logoutAllSessionsHandler(c echo.Context) error { @@ -28,5 +29,5 @@ func (h *handlers) logoutAllSessionsHandler(c echo.Context) error { } h.servs.AuthService.ClearSessionCookie(c) - return respondhtmx.Redirect(c, "/auth/login") + return respondhtmx.Redirect(c, pathutil.BuildPath("/auth/login")) } diff --git a/internal/view/web/dashboard/backups/create_backup.go b/internal/view/web/dashboard/backups/create_backup.go index 49e4c31b..a88bbc9a 100644 --- a/internal/view/web/dashboard/backups/create_backup.go +++ b/internal/view/web/dashboard/backups/create_backup.go @@ -7,6 +7,7 @@ import ( "github.com/eduardolat/pgbackweb/internal/database/dbgen" "github.com/eduardolat/pgbackweb/internal/staticdata" "github.com/eduardolat/pgbackweb/internal/util/echoutil" + "github.com/eduardolat/pgbackweb/internal/util/pathutil" "github.com/eduardolat/pgbackweb/internal/validate" "github.com/eduardolat/pgbackweb/internal/view/web/component" "github.com/eduardolat/pgbackweb/internal/view/web/respondhtmx" @@ -70,7 +71,7 @@ func (h *handlers) createBackupHandler(c echo.Context) error { return respondhtmx.ToastError(c, err.Error()) } - return respondhtmx.Redirect(c, "/dashboard/backups") + return respondhtmx.Redirect(c, pathutil.BuildPath("/dashboard/backups")) } func (h *handlers) createBackupFormHandler(c echo.Context) error { diff --git a/internal/view/web/dashboard/databases/create_database.go b/internal/view/web/dashboard/databases/create_database.go index 88a65c16..0e95a697 100644 --- a/internal/view/web/dashboard/databases/create_database.go +++ b/internal/view/web/dashboard/databases/create_database.go @@ -4,6 +4,7 @@ import ( "database/sql" "github.com/eduardolat/pgbackweb/internal/database/dbgen" + "github.com/eduardolat/pgbackweb/internal/util/pathutil" "github.com/eduardolat/pgbackweb/internal/validate" "github.com/eduardolat/pgbackweb/internal/view/web/component" "github.com/eduardolat/pgbackweb/internal/view/web/respondhtmx" @@ -41,7 +42,7 @@ func (h *handlers) createDatabaseHandler(c echo.Context) error { return respondhtmx.ToastError(c, err.Error()) } - return respondhtmx.Redirect(c, "/dashboard/databases") + return respondhtmx.Redirect(c, pathutil.BuildPath("/dashboard/databases")) } func createDatabaseButton() nodx.Node { diff --git a/internal/view/web/dashboard/destinations/create_destination.go b/internal/view/web/dashboard/destinations/create_destination.go index ae0fe3b1..275944fc 100644 --- a/internal/view/web/dashboard/destinations/create_destination.go +++ b/internal/view/web/dashboard/destinations/create_destination.go @@ -2,6 +2,7 @@ package destinations import ( "github.com/eduardolat/pgbackweb/internal/database/dbgen" + "github.com/eduardolat/pgbackweb/internal/util/pathutil" "github.com/eduardolat/pgbackweb/internal/validate" "github.com/eduardolat/pgbackweb/internal/view/web/component" "github.com/eduardolat/pgbackweb/internal/view/web/respondhtmx" @@ -45,7 +46,7 @@ func (h *handlers) createDestinationHandler(c echo.Context) error { return respondhtmx.ToastError(c, err.Error()) } - return respondhtmx.Redirect(c, "/dashboard/destinations") + return respondhtmx.Redirect(c, pathutil.BuildPath("/dashboard/destinations")) } func createDestinationButton() nodx.Node { diff --git a/internal/view/web/dashboard/webhooks/create_webhook.go b/internal/view/web/dashboard/webhooks/create_webhook.go index 73c73a64..aa6632c7 100644 --- a/internal/view/web/dashboard/webhooks/create_webhook.go +++ b/internal/view/web/dashboard/webhooks/create_webhook.go @@ -6,6 +6,7 @@ import ( "github.com/eduardolat/pgbackweb/internal/database/dbgen" "github.com/eduardolat/pgbackweb/internal/util/echoutil" + "github.com/eduardolat/pgbackweb/internal/util/pathutil" "github.com/eduardolat/pgbackweb/internal/validate" "github.com/eduardolat/pgbackweb/internal/view/web/component" "github.com/eduardolat/pgbackweb/internal/view/web/respondhtmx" @@ -54,7 +55,7 @@ func (h *handlers) createWebhookHandler(c echo.Context) error { return respondhtmx.ToastError(c, err.Error()) } - return respondhtmx.Redirect(c, "/dashboard/webhooks") + return respondhtmx.Redirect(c, pathutil.BuildPath("/dashboard/webhooks")) } func (h *handlers) createWebhookFormHandler(c echo.Context) error { diff --git a/internal/view/web/router.go b/internal/view/web/router.go index 2b0ad1b2..ea76b2a8 100644 --- a/internal/view/web/router.go +++ b/internal/view/web/router.go @@ -5,6 +5,7 @@ import ( "github.com/eduardolat/pgbackweb/internal/logger" "github.com/eduardolat/pgbackweb/internal/service" + "github.com/eduardolat/pgbackweb/internal/util/pathutil" "github.com/eduardolat/pgbackweb/internal/view/middleware" "github.com/eduardolat/pgbackweb/internal/view/reqctx" "github.com/eduardolat/pgbackweb/internal/view/web/auth" @@ -21,7 +22,7 @@ func MountRouter( reqCtx := reqctx.GetCtx(c) if reqCtx.IsAuthed { - return c.Redirect(http.StatusFound, "/dashboard") + return c.Redirect(http.StatusFound, pathutil.BuildPath("/dashboard")) } usersQty, err := servs.UsersService.GetUsersQty(ctx) @@ -35,10 +36,10 @@ func MountRouter( } if usersQty == 0 { - return c.Redirect(http.StatusFound, "/auth/create-first-user") + return c.Redirect(http.StatusFound, pathutil.BuildPath("/auth/create-first-user")) } - return c.Redirect(http.StatusFound, "/auth/login") + return c.Redirect(http.StatusFound, pathutil.BuildPath("/auth/login")) }) authGroup := parent.Group("/auth") From e91d2026e655191e046c0466dfda470d9bc8d143 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?S=C3=BCli=20Patrik=20P=C3=A9ter?= Date: Fri, 3 Oct 2025 08:44:50 +0200 Subject: [PATCH 07/27] Refactors pathutil test suite for better isolation Updates tests to properly manage global state: - Uses defer pattern to restore original values after tests - Resets sync.Once to allow multiple SetPathPrefix calls in tests - Removes shouldSkip field from test cases as it's no longer needed --- internal/util/pathutil/pathutil_test.go | 37 +++++++++++++++---------- 1 file changed, 23 insertions(+), 14 deletions(-) diff --git a/internal/util/pathutil/pathutil_test.go b/internal/util/pathutil/pathutil_test.go index 2676332f..30a35a05 100644 --- a/internal/util/pathutil/pathutil_test.go +++ b/internal/util/pathutil/pathutil_test.go @@ -1,16 +1,18 @@ package pathutil -import "testing" +import ( + "sync" + "testing" +) func TestBuildPath(t *testing.T) { t.Helper() tests := []struct { - name string - prefix string - path string - expected string - shouldSkip bool // Skip if prefix was already set + name string + prefix string + path string + expected string }{ { name: "no prefix configured", @@ -52,8 +54,14 @@ func TestBuildPath(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - // Reset for each test - pathPrefix = tt.prefix + originalPrefix := pathPrefix + pathPrefixOnce = sync.Once{} + SetPathPrefix(tt.prefix) + defer func() { + pathPrefixOnce = sync.Once{} + pathPrefix = originalPrefix + }() + result := BuildPath(tt.path) if result != tt.expected { t.Errorf("BuildPath(%q) with prefix %q = %q, want %q", tt.path, tt.prefix, result, tt.expected) @@ -64,15 +72,16 @@ func TestBuildPath(t *testing.T) { func TestGetPathPrefix(t *testing.T) { t.Helper() - - // Set a test prefix - pathPrefix = "/test-prefix" + originalPrefix := pathPrefix + pathPrefixOnce = sync.Once{} + SetPathPrefix("/test-prefix") + defer func() { + pathPrefixOnce = sync.Once{} + pathPrefix = originalPrefix + }() result := GetPathPrefix() if result != "/test-prefix" { t.Errorf("GetPathPrefix() = %q, want %q", result, "/test-prefix") } - - // Reset - pathPrefix = "" } From 6965e14a41647f34cab66c6c4f0e51e5839eb1c9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?S=C3=BCli=20Patrik=20P=C3=A9ter?= Date: Fri, 3 Oct 2025 09:31:36 +0200 Subject: [PATCH 08/27] Supports subdirectory deployment with configurable path prefix Refactors URL handling to allow serving the app from a subdirectory. Updates all static file paths and dashboard navigation links to use a utility function that prepends the configured path prefix when generating URLs. --- internal/view/router.go | 12 ++++++++++- internal/view/static/static_fs.go | 10 ++++++++- internal/view/web/component/logotype.go | 3 ++- .../dashboard/executions/show_execution.go | 3 ++- internal/view/web/layout/dashboard_aside.go | 21 ++++++++++--------- 5 files changed, 35 insertions(+), 14 deletions(-) diff --git a/internal/view/router.go b/internal/view/router.go index 1290c9e9..ba69de1b 100644 --- a/internal/view/router.go +++ b/internal/view/router.go @@ -1,9 +1,11 @@ package view import ( + "io/fs" "time" "github.com/eduardolat/pgbackweb/internal/config" + "github.com/eduardolat/pgbackweb/internal/logger" "github.com/eduardolat/pgbackweb/internal/service" "github.com/eduardolat/pgbackweb/internal/view/api" "github.com/eduardolat/pgbackweb/internal/view/middleware" @@ -24,7 +26,15 @@ func MountRouter(app *echo.Echo, servs *service.Service, env config.Env) { ExcludedFiles: []string{"/robots.txt"}, }, ) - baseGroup.Group("", browserCache).StaticFS("", static.StaticFs) + + // Mount static files + staticFS, err := fs.Sub(static.StaticFs, ".") + if err != nil { + logger.FatalError("failed to create static filesystem", logger.KV{"error": err}) + } + + staticGroup := baseGroup.Group("", browserCache) + staticGroup.StaticFS("/", staticFS) apiGroup := baseGroup.Group("/api") api.MountRouter(apiGroup, mids, servs) diff --git a/internal/view/static/static_fs.go b/internal/view/static/static_fs.go index d1bb3a44..cc6e1967 100644 --- a/internal/view/static/static_fs.go +++ b/internal/view/static/static_fs.go @@ -5,6 +5,7 @@ import ( "sync" "github.com/eduardolat/pgbackweb/internal/util/cryptoutil" + "github.com/eduardolat/pgbackweb/internal/util/pathutil" ) //go:embed * @@ -28,6 +29,9 @@ func GetStaticSHA256() string { // SHA256 hash of the static filesystem to the query parameter. // // The hash is truncated to the first 8 characters for brevity. +// +// This function also prepends the configured path prefix so that static files +// are correctly referenced in HTML when the application is served under a subpath. func GetVersionedFilePath(filePath string) string { hash := GetStaticSHA256() @@ -35,5 +39,9 @@ func GetVersionedFilePath(filePath string) string { hash = hash[:8] } - return filePath + "?v=" + hash + // Prepend the path prefix to ensure static files are found + // when the app is served under a subpath like /pgbackweb + fullPath := pathutil.BuildPath(filePath) + + return fullPath + "?v=" + hash } diff --git a/internal/view/web/component/logotype.go b/internal/view/web/component/logotype.go index 2518435f..cfe13678 100644 --- a/internal/view/web/component/logotype.go +++ b/internal/view/web/component/logotype.go @@ -1,6 +1,7 @@ package component import ( + "github.com/eduardolat/pgbackweb/internal/util/pathutil" nodx "github.com/nodxdev/nodxgo" ) @@ -12,7 +13,7 @@ func Logotype() nodx.Node { }, nodx.Img( nodx.Class("w-[60px] h-auto"), - nodx.Src("/images/logo.png"), + nodx.Src(pathutil.BuildPath("/images/logo.png")), nodx.Alt("PG Back Web"), ), nodx.SpanEl( diff --git a/internal/view/web/dashboard/executions/show_execution.go b/internal/view/web/dashboard/executions/show_execution.go index f8224eaa..6d3287d4 100644 --- a/internal/view/web/dashboard/executions/show_execution.go +++ b/internal/view/web/dashboard/executions/show_execution.go @@ -5,6 +5,7 @@ import ( "path/filepath" "github.com/eduardolat/pgbackweb/internal/database/dbgen" + "github.com/eduardolat/pgbackweb/internal/util/pathutil" "github.com/eduardolat/pgbackweb/internal/util/timeutil" "github.com/eduardolat/pgbackweb/internal/view/web/component" "github.com/google/uuid" @@ -121,7 +122,7 @@ func showExecutionButton( nodx.Class("flex justify-end items-center space-x-2"), deleteExecutionButton(execution.ID), nodx.A( - nodx.Href("/dashboard/executions/"+execution.ID.String()+"/download"), + nodx.Href(pathutil.BuildPath("/dashboard/executions/"+execution.ID.String()+"/download")), nodx.Target("_blank"), nodx.Class("btn btn-primary"), component.SpanText("Download"), diff --git a/internal/view/web/layout/dashboard_aside.go b/internal/view/web/layout/dashboard_aside.go index 9f71fcc3..25459db1 100644 --- a/internal/view/web/layout/dashboard_aside.go +++ b/internal/view/web/layout/dashboard_aside.go @@ -3,6 +3,7 @@ package layout import ( "fmt" + "github.com/eduardolat/pgbackweb/internal/util/pathutil" nodx "github.com/nodxdev/nodxgo" alpine "github.com/nodxdev/nodxgo-alpine" htmx "github.com/nodxdev/nodxgo-htmx" @@ -22,7 +23,7 @@ func dashboardAside() nodx.Node { nodx.Href("https://github.com/eduardolat/pgbackweb"), nodx.Target("_blank"), nodx.Img( - nodx.Src("/images/logo.png"), + nodx.Src(pathutil.BuildPath("/images/logo.png")), nodx.Alt("PG Back Web"), nodx.Class("w-[50px] h-auto"), ), @@ -45,63 +46,63 @@ func dashboardAside() nodx.Node { dashboardAsideItem( lucide.LayoutDashboard, "Summary", - "/dashboard", + pathutil.BuildPath("/dashboard"), true, ), dashboardAsideItem( lucide.Database, "Databases", - "/dashboard/databases", + pathutil.BuildPath("/dashboard/databases"), false, ), dashboardAsideItem( lucide.HardDrive, "Destinations", - "/dashboard/destinations", + pathutil.BuildPath("/dashboard/destinations"), false, ), dashboardAsideItem( lucide.DatabaseBackup, "Backup tasks", - "/dashboard/backups", + pathutil.BuildPath("/dashboard/backups"), false, ), dashboardAsideItem( lucide.List, "Executions", - "/dashboard/executions", + pathutil.BuildPath("/dashboard/executions"), false, ), dashboardAsideItem( lucide.ArchiveRestore, "Restorations", - "/dashboard/restorations", + pathutil.BuildPath("/dashboard/restorations"), false, ), dashboardAsideItem( lucide.Webhook, "Webhooks", - "/dashboard/webhooks", + pathutil.BuildPath("/dashboard/webhooks"), false, ), dashboardAsideItem( lucide.User, "Profile", - "/dashboard/profile", + pathutil.BuildPath("/dashboard/profile"), false, ), dashboardAsideItem( lucide.Info, "About", - "/dashboard/about", + pathutil.BuildPath("/dashboard/about"), false, ), ), From 7658e21aaa147f65cad18ea1ab821efcc18291f7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?S=C3=BCli=20Patrik=20P=C3=A9ter?= Date: Fri, 3 Oct 2025 10:07:35 +0200 Subject: [PATCH 09/27] Refactors URL paths using pathutil.BuildPath function Consistently uses the pathutil.BuildPath function to build URLs across various web components instead of hardcoding them. --- internal/view/web/auth/create_first_user.go | 2 +- internal/view/web/auth/login.go | 2 +- internal/view/web/dashboard/backups/create_backup.go | 4 ++-- internal/view/web/dashboard/backups/delete_backup.go | 3 ++- internal/view/web/dashboard/backups/duplicate_backup.go | 3 ++- internal/view/web/dashboard/backups/edit_backup.go | 3 ++- internal/view/web/dashboard/backups/index.go | 3 ++- internal/view/web/dashboard/backups/manual_run.go | 3 ++- internal/view/web/dashboard/databases/delete_database.go | 3 ++- internal/view/web/dashboard/databases/index.go | 3 ++- internal/view/web/dashboard/databases/list_databases.go | 3 ++- .../view/web/dashboard/destinations/delete_destination.go | 3 ++- internal/view/web/dashboard/destinations/index.go | 3 ++- .../view/web/dashboard/destinations/list_destinations.go | 3 ++- internal/view/web/dashboard/executions/restore_execution.go | 5 +++-- .../view/web/dashboard/executions/soft_delete_execution.go | 3 ++- internal/view/web/dashboard/profile/close_all_sessions.go | 3 ++- internal/view/web/dashboard/profile/update_user.go | 3 ++- internal/view/web/dashboard/webhooks/create_webhook.go | 4 ++-- internal/view/web/dashboard/webhooks/delete_webhook.go | 3 ++- internal/view/web/dashboard/webhooks/duplicate_webhook.go | 3 ++- internal/view/web/dashboard/webhooks/edit_webhook.go | 5 +++-- internal/view/web/dashboard/webhooks/index.go | 3 ++- internal/view/web/dashboard/webhooks/run_webhook.go | 3 ++- internal/view/web/layout/dashboard_header.go | 5 +++-- 25 files changed, 51 insertions(+), 30 deletions(-) diff --git a/internal/view/web/auth/create_first_user.go b/internal/view/web/auth/create_first_user.go index 7d04e996..7ce89f26 100644 --- a/internal/view/web/auth/create_first_user.go +++ b/internal/view/web/auth/create_first_user.go @@ -41,7 +41,7 @@ func createFirstUserPage() nodx.Node { component.H1Text("Create first user"), nodx.FormEl( - htmx.HxPost("/auth/create-first-user"), + htmx.HxPost(pathutil.BuildPath("/auth/create-first-user")), htmx.HxDisabledELT("find button"), nodx.Class("mt-4 space-y-2"), diff --git a/internal/view/web/auth/login.go b/internal/view/web/auth/login.go index c0827247..3947e5e2 100644 --- a/internal/view/web/auth/login.go +++ b/internal/view/web/auth/login.go @@ -40,7 +40,7 @@ func loginPage() nodx.Node { component.H1Text("Login"), nodx.FormEl( - htmx.HxPost("/auth/login"), + htmx.HxPost(pathutil.BuildPath("/auth/login")), htmx.HxDisabledELT("find button"), nodx.Class("mt-4 space-y-2"), diff --git a/internal/view/web/dashboard/backups/create_backup.go b/internal/view/web/dashboard/backups/create_backup.go index a88bbc9a..6a71de31 100644 --- a/internal/view/web/dashboard/backups/create_backup.go +++ b/internal/view/web/dashboard/backups/create_backup.go @@ -106,7 +106,7 @@ func createBackupForm( serverTZ := time.Now().Location().String() return nodx.FormEl( - htmx.HxPost("/dashboard/backups"), + htmx.HxPost(pathutil.BuildPath("/dashboard/backups")), htmx.HxDisabledELT("find button"), nodx.Class("space-y-2 text-base"), @@ -323,7 +323,7 @@ func createBackupButton() nodx.Node { Title: "Create backup task", Content: []nodx.Node{ nodx.Div( - htmx.HxGet("/dashboard/backups/create-form"), + htmx.HxGet(pathutil.BuildPath("/dashboard/backups/create-form")), htmx.HxSwap("outerHTML"), htmx.HxTrigger("intersect once"), nodx.Class("p-10 flex justify-center"), diff --git a/internal/view/web/dashboard/backups/delete_backup.go b/internal/view/web/dashboard/backups/delete_backup.go index c2248b80..3cc267c4 100644 --- a/internal/view/web/dashboard/backups/delete_backup.go +++ b/internal/view/web/dashboard/backups/delete_backup.go @@ -1,6 +1,7 @@ package backups import ( + "github.com/eduardolat/pgbackweb/internal/util/pathutil" "github.com/eduardolat/pgbackweb/internal/view/web/component" "github.com/eduardolat/pgbackweb/internal/view/web/respondhtmx" "github.com/google/uuid" @@ -27,7 +28,7 @@ func (h *handlers) deleteBackupHandler(c echo.Context) error { func deleteBackupButton(backupID uuid.UUID) nodx.Node { return component.OptionsDropdownButton( - htmx.HxDelete("/dashboard/backups/"+backupID.String()), + htmx.HxDelete(pathutil.BuildPath("/dashboard/backups/"+backupID.String())), htmx.HxConfirm("Are you sure you want to delete this backup task?"), lucide.Trash(), component.SpanText("Delete backup task"), diff --git a/internal/view/web/dashboard/backups/duplicate_backup.go b/internal/view/web/dashboard/backups/duplicate_backup.go index a397a854..832730b2 100644 --- a/internal/view/web/dashboard/backups/duplicate_backup.go +++ b/internal/view/web/dashboard/backups/duplicate_backup.go @@ -1,6 +1,7 @@ package backups import ( + "github.com/eduardolat/pgbackweb/internal/util/pathutil" "github.com/eduardolat/pgbackweb/internal/view/web/component" "github.com/eduardolat/pgbackweb/internal/view/web/respondhtmx" "github.com/google/uuid" @@ -27,7 +28,7 @@ func (h *handlers) duplicateBackupHandler(c echo.Context) error { func duplicateBackupButton(backupID uuid.UUID) nodx.Node { return component.OptionsDropdownButton( - htmx.HxPost("/dashboard/backups/"+backupID.String()+"/duplicate"), + htmx.HxPost(pathutil.BuildPath("/dashboard/backups/"+backupID.String()+"/duplicate")), htmx.HxConfirm("Are you sure you want to duplicate this backup task?"), lucide.CopyPlus(), component.SpanText("Duplicate backup task"), diff --git a/internal/view/web/dashboard/backups/edit_backup.go b/internal/view/web/dashboard/backups/edit_backup.go index 543aa85f..691cd70c 100644 --- a/internal/view/web/dashboard/backups/edit_backup.go +++ b/internal/view/web/dashboard/backups/edit_backup.go @@ -6,6 +6,7 @@ import ( "github.com/eduardolat/pgbackweb/internal/database/dbgen" "github.com/eduardolat/pgbackweb/internal/staticdata" + "github.com/eduardolat/pgbackweb/internal/util/pathutil" "github.com/eduardolat/pgbackweb/internal/validate" "github.com/eduardolat/pgbackweb/internal/view/web/component" "github.com/eduardolat/pgbackweb/internal/view/web/respondhtmx" @@ -90,7 +91,7 @@ func editBackupButton(backup dbgen.BackupsServicePaginateBackupsRow) nodx.Node { Title: "Edit backup task", Content: []nodx.Node{ nodx.FormEl( - htmx.HxPost("/dashboard/backups/"+backup.ID.String()+"/edit"), + htmx.HxPost(pathutil.BuildPath("/dashboard/backups/"+backup.ID.String()+"/edit")), htmx.HxDisabledELT("find button"), nodx.Class("space-y-2 text-base"), diff --git a/internal/view/web/dashboard/backups/index.go b/internal/view/web/dashboard/backups/index.go index 59f1f482..d4eea595 100644 --- a/internal/view/web/dashboard/backups/index.go +++ b/internal/view/web/dashboard/backups/index.go @@ -4,6 +4,7 @@ import ( "net/http" "github.com/eduardolat/pgbackweb/internal/util/echoutil" + "github.com/eduardolat/pgbackweb/internal/util/pathutil" "github.com/eduardolat/pgbackweb/internal/view/reqctx" "github.com/eduardolat/pgbackweb/internal/view/web/component" "github.com/eduardolat/pgbackweb/internal/view/web/layout" @@ -50,7 +51,7 @@ func indexPage(reqCtx reqctx.Ctx) nodx.Node { ), nodx.Tbody( component.SkeletonTr(8), - htmx.HxGet("/dashboard/backups/list?page=1"), + htmx.HxGet(pathutil.BuildPath("/dashboard/backups/list?page=1")), htmx.HxTrigger("load"), ), ), diff --git a/internal/view/web/dashboard/backups/manual_run.go b/internal/view/web/dashboard/backups/manual_run.go index 309d0655..0e1a62db 100644 --- a/internal/view/web/dashboard/backups/manual_run.go +++ b/internal/view/web/dashboard/backups/manual_run.go @@ -3,6 +3,7 @@ package backups import ( "context" + "github.com/eduardolat/pgbackweb/internal/util/pathutil" "github.com/eduardolat/pgbackweb/internal/view/web/component" "github.com/eduardolat/pgbackweb/internal/view/web/respondhtmx" "github.com/google/uuid" @@ -27,7 +28,7 @@ func (h *handlers) manualRunHandler(c echo.Context) error { func manualRunbutton(backupID uuid.UUID) nodx.Node { return component.OptionsDropdownButton( - htmx.HxPost("/dashboard/backups/"+backupID.String()+"/run"), + htmx.HxPost(pathutil.BuildPath("/dashboard/backups/"+backupID.String()+"/run")), htmx.HxDisabledELT("this"), lucide.Zap(), component.SpanText("Run backup now"), diff --git a/internal/view/web/dashboard/databases/delete_database.go b/internal/view/web/dashboard/databases/delete_database.go index 4996d461..a35f6ad3 100644 --- a/internal/view/web/dashboard/databases/delete_database.go +++ b/internal/view/web/dashboard/databases/delete_database.go @@ -1,6 +1,7 @@ package databases import ( + "github.com/eduardolat/pgbackweb/internal/util/pathutil" "github.com/eduardolat/pgbackweb/internal/view/web/component" "github.com/eduardolat/pgbackweb/internal/view/web/respondhtmx" "github.com/google/uuid" @@ -27,7 +28,7 @@ func (h *handlers) deleteDatabaseHandler(c echo.Context) error { func deleteDatabaseButton(databaseID uuid.UUID) nodx.Node { return component.OptionsDropdownButton( - htmx.HxDelete("/dashboard/databases/"+databaseID.String()), + htmx.HxDelete(pathutil.BuildPath("/dashboard/databases/"+databaseID.String())), htmx.HxConfirm("Are you sure you want to delete this database?"), lucide.Trash(), component.SpanText("Delete database"), diff --git a/internal/view/web/dashboard/databases/index.go b/internal/view/web/dashboard/databases/index.go index 6e440f98..44330d27 100644 --- a/internal/view/web/dashboard/databases/index.go +++ b/internal/view/web/dashboard/databases/index.go @@ -4,6 +4,7 @@ import ( "net/http" "github.com/eduardolat/pgbackweb/internal/util/echoutil" + "github.com/eduardolat/pgbackweb/internal/util/pathutil" "github.com/eduardolat/pgbackweb/internal/view/reqctx" "github.com/eduardolat/pgbackweb/internal/view/web/component" "github.com/eduardolat/pgbackweb/internal/view/web/layout" @@ -42,7 +43,7 @@ func indexPage(reqCtx reqctx.Ctx) nodx.Node { ), nodx.Tbody( component.SkeletonTr(8), - htmx.HxGet("/dashboard/databases/list?page=1"), + htmx.HxGet(pathutil.BuildPath("/dashboard/databases/list?page=1")), htmx.HxTrigger("load"), ), ), diff --git a/internal/view/web/dashboard/databases/list_databases.go b/internal/view/web/dashboard/databases/list_databases.go index c4138c8f..1406c672 100644 --- a/internal/view/web/dashboard/databases/list_databases.go +++ b/internal/view/web/dashboard/databases/list_databases.go @@ -8,6 +8,7 @@ import ( "github.com/eduardolat/pgbackweb/internal/service/databases" "github.com/eduardolat/pgbackweb/internal/util/echoutil" "github.com/eduardolat/pgbackweb/internal/util/paginateutil" + "github.com/eduardolat/pgbackweb/internal/util/pathutil" "github.com/eduardolat/pgbackweb/internal/util/timeutil" "github.com/eduardolat/pgbackweb/internal/validate" "github.com/eduardolat/pgbackweb/internal/view/web/component" @@ -73,7 +74,7 @@ func listDatabases( ), editDatabaseButton(database), component.OptionsDropdownButton( - htmx.HxPost("/dashboard/databases/"+database.ID.String()+"/test"), + htmx.HxPost(pathutil.BuildPath("/dashboard/databases/"+database.ID.String()+"/test")), htmx.HxDisabledELT("this"), lucide.DatabaseZap(), component.SpanText("Test connection"), diff --git a/internal/view/web/dashboard/destinations/delete_destination.go b/internal/view/web/dashboard/destinations/delete_destination.go index 80b65be2..60a55f10 100644 --- a/internal/view/web/dashboard/destinations/delete_destination.go +++ b/internal/view/web/dashboard/destinations/delete_destination.go @@ -1,6 +1,7 @@ package destinations import ( + "github.com/eduardolat/pgbackweb/internal/util/pathutil" "github.com/eduardolat/pgbackweb/internal/view/web/component" "github.com/eduardolat/pgbackweb/internal/view/web/respondhtmx" "github.com/google/uuid" @@ -28,7 +29,7 @@ func (h *handlers) deleteDestinationHandler(c echo.Context) error { func deleteDestinationButton(destinationID uuid.UUID) nodx.Node { return component.OptionsDropdownButton( - htmx.HxDelete("/dashboard/destinations/"+destinationID.String()), + htmx.HxDelete(pathutil.BuildPath("/dashboard/destinations/"+destinationID.String())), htmx.HxConfirm("Are you sure you want to delete this destination?"), lucide.Trash(), component.SpanText("Delete destination"), diff --git a/internal/view/web/dashboard/destinations/index.go b/internal/view/web/dashboard/destinations/index.go index 8355c611..25fb1c2c 100644 --- a/internal/view/web/dashboard/destinations/index.go +++ b/internal/view/web/dashboard/destinations/index.go @@ -4,6 +4,7 @@ import ( "net/http" "github.com/eduardolat/pgbackweb/internal/util/echoutil" + "github.com/eduardolat/pgbackweb/internal/util/pathutil" "github.com/eduardolat/pgbackweb/internal/view/reqctx" "github.com/eduardolat/pgbackweb/internal/view/web/component" "github.com/eduardolat/pgbackweb/internal/view/web/layout" @@ -55,7 +56,7 @@ func indexPage(reqCtx reqctx.Ctx) nodx.Node { ), nodx.Tbody( component.SkeletonTr(8), - htmx.HxGet("/dashboard/destinations/list?page=1"), + htmx.HxGet(pathutil.BuildPath("/dashboard/destinations/list?page=1")), htmx.HxTrigger("load"), ), ), diff --git a/internal/view/web/dashboard/destinations/list_destinations.go b/internal/view/web/dashboard/destinations/list_destinations.go index b0eee351..ad360ee3 100644 --- a/internal/view/web/dashboard/destinations/list_destinations.go +++ b/internal/view/web/dashboard/destinations/list_destinations.go @@ -8,6 +8,7 @@ import ( "github.com/eduardolat/pgbackweb/internal/service/destinations" "github.com/eduardolat/pgbackweb/internal/util/echoutil" "github.com/eduardolat/pgbackweb/internal/util/paginateutil" + "github.com/eduardolat/pgbackweb/internal/util/pathutil" "github.com/eduardolat/pgbackweb/internal/util/timeutil" "github.com/eduardolat/pgbackweb/internal/validate" "github.com/eduardolat/pgbackweb/internal/view/web/component" @@ -71,7 +72,7 @@ func listDestinations( ), editDestinationButton(destination), component.OptionsDropdownButton( - htmx.HxPost("/dashboard/destinations/"+destination.ID.String()+"/test"), + htmx.HxPost(pathutil.BuildPath("/dashboard/destinations/"+destination.ID.String()+"/test")), htmx.HxDisabledELT("this"), lucide.PlugZap(), component.SpanText("Test connection"), diff --git a/internal/view/web/dashboard/executions/restore_execution.go b/internal/view/web/dashboard/executions/restore_execution.go index 45494f0d..6c59cdb5 100644 --- a/internal/view/web/dashboard/executions/restore_execution.go +++ b/internal/view/web/dashboard/executions/restore_execution.go @@ -7,6 +7,7 @@ import ( "github.com/eduardolat/pgbackweb/internal/database/dbgen" "github.com/eduardolat/pgbackweb/internal/util/echoutil" + "github.com/eduardolat/pgbackweb/internal/util/pathutil" "github.com/eduardolat/pgbackweb/internal/validate" "github.com/eduardolat/pgbackweb/internal/view/web/component" "github.com/eduardolat/pgbackweb/internal/view/web/respondhtmx" @@ -107,7 +108,7 @@ func restoreExecutionForm( databases []dbgen.DatabasesServiceGetAllDatabasesRow, ) nodx.Node { return nodx.FormEl( - htmx.HxPost("/dashboard/executions/"+execution.ID.String()+"/restore"), + htmx.HxPost(pathutil.BuildPath("/dashboard/executions/"+execution.ID.String()+"/restore")), htmx.HxConfirm("Are you sure you want to restore this backup?"), htmx.HxDisabledELT("find button"), @@ -222,7 +223,7 @@ func restoreExecutionButton(execution dbgen.ExecutionsServicePaginateExecutionsR Title: "Restore backup execution", Content: []nodx.Node{ nodx.Div( - htmx.HxGet("/dashboard/executions/"+execution.ID.String()+"/restore-form"), + htmx.HxGet(pathutil.BuildPath("/dashboard/executions/"+execution.ID.String()+"/restore-form")), htmx.HxSwap("outerHTML"), htmx.HxTrigger("intersect once"), nodx.Class("p-10 flex justify-center"), diff --git a/internal/view/web/dashboard/executions/soft_delete_execution.go b/internal/view/web/dashboard/executions/soft_delete_execution.go index e62abaf5..c08fa121 100644 --- a/internal/view/web/dashboard/executions/soft_delete_execution.go +++ b/internal/view/web/dashboard/executions/soft_delete_execution.go @@ -1,6 +1,7 @@ package executions import ( + "github.com/eduardolat/pgbackweb/internal/util/pathutil" "github.com/eduardolat/pgbackweb/internal/view/web/component" "github.com/eduardolat/pgbackweb/internal/view/web/respondhtmx" "github.com/google/uuid" @@ -28,7 +29,7 @@ func (h *handlers) deleteExecutionHandler(c echo.Context) error { func deleteExecutionButton(executionID uuid.UUID) nodx.Node { return nodx.Button( - htmx.HxDelete("/dashboard/executions/"+executionID.String()), + htmx.HxDelete(pathutil.BuildPath("/dashboard/executions/"+executionID.String())), htmx.HxDisabledELT("this"), htmx.HxConfirm("Are you sure you want to delete this execution? It will delete the backup file from the destination and it can't be recovered."), nodx.Class("btn btn-error btn-outline"), diff --git a/internal/view/web/dashboard/profile/close_all_sessions.go b/internal/view/web/dashboard/profile/close_all_sessions.go index ad3cfe93..1f09ea6b 100644 --- a/internal/view/web/dashboard/profile/close_all_sessions.go +++ b/internal/view/web/dashboard/profile/close_all_sessions.go @@ -2,6 +2,7 @@ package profile import ( "github.com/eduardolat/pgbackweb/internal/database/dbgen" + "github.com/eduardolat/pgbackweb/internal/util/pathutil" "github.com/eduardolat/pgbackweb/internal/util/timeutil" "github.com/eduardolat/pgbackweb/internal/view/web/component" nodx "github.com/nodxdev/nodxgo" @@ -15,7 +16,7 @@ func closeAllSessionsForm(sessions []dbgen.Session) nodx.Node { component.H2Text("Close all sessions"), component.PText("This will log you out from all devices including this one."), nodx.Button( - htmx.HxPost("/auth/logout-all"), + htmx.HxPost(pathutil.BuildPath("/auth/logout-all")), htmx.HxDisabledELT("this"), htmx.HxConfirm("Are you sure you want to close all your sessions?"), nodx.Class("mt-2 btn btn-error"), diff --git a/internal/view/web/dashboard/profile/update_user.go b/internal/view/web/dashboard/profile/update_user.go index 2e4f84be..63334023 100644 --- a/internal/view/web/dashboard/profile/update_user.go +++ b/internal/view/web/dashboard/profile/update_user.go @@ -4,6 +4,7 @@ import ( "database/sql" "github.com/eduardolat/pgbackweb/internal/database/dbgen" + "github.com/eduardolat/pgbackweb/internal/util/pathutil" "github.com/eduardolat/pgbackweb/internal/validate" "github.com/eduardolat/pgbackweb/internal/view/reqctx" "github.com/eduardolat/pgbackweb/internal/view/web/component" @@ -48,7 +49,7 @@ func updateUserForm(user dbgen.User) nodx.Node { return component.CardBox(component.CardBoxParams{ Children: []nodx.Node{ nodx.FormEl( - htmx.HxPost("/dashboard/profile"), + htmx.HxPost(pathutil.BuildPath("/dashboard/profile")), htmx.HxDisabledELT("find button"), nodx.Class("space-y-2"), diff --git a/internal/view/web/dashboard/webhooks/create_webhook.go b/internal/view/web/dashboard/webhooks/create_webhook.go index aa6632c7..691acf4a 100644 --- a/internal/view/web/dashboard/webhooks/create_webhook.go +++ b/internal/view/web/dashboard/webhooks/create_webhook.go @@ -87,7 +87,7 @@ func createWebhookForm( backups []dbgen.Backup, ) nodx.Node { return nodx.FormEl( - htmx.HxPost("/dashboard/webhooks/create"), + htmx.HxPost(pathutil.BuildPath("/dashboard/webhooks/create")), htmx.HxDisabledELT("find button[type='submit']"), nodx.Class("space-y-2"), @@ -112,7 +112,7 @@ func createWebhookButton() nodx.Node { Title: "Create webhook", Content: []nodx.Node{ nodx.Div( - htmx.HxGet("/dashboard/webhooks/create"), + htmx.HxGet(pathutil.BuildPath("/dashboard/webhooks/create")), htmx.HxSwap("outerHTML"), htmx.HxTrigger("intersect once"), nodx.Class("p-10 flex justify-center"), diff --git a/internal/view/web/dashboard/webhooks/delete_webhook.go b/internal/view/web/dashboard/webhooks/delete_webhook.go index 1f93341d..c86a10b5 100644 --- a/internal/view/web/dashboard/webhooks/delete_webhook.go +++ b/internal/view/web/dashboard/webhooks/delete_webhook.go @@ -1,6 +1,7 @@ package webhooks import ( + "github.com/eduardolat/pgbackweb/internal/util/pathutil" "github.com/eduardolat/pgbackweb/internal/view/web/component" "github.com/eduardolat/pgbackweb/internal/view/web/respondhtmx" "github.com/google/uuid" @@ -27,7 +28,7 @@ func (h *handlers) deleteWebhookHandler(c echo.Context) error { func deleteWebhookButton(webhookID uuid.UUID) nodx.Node { return component.OptionsDropdownButton( - htmx.HxDelete("/dashboard/webhooks/"+webhookID.String()), + htmx.HxDelete(pathutil.BuildPath("/dashboard/webhooks/"+webhookID.String())), htmx.HxConfirm("Are you sure you want to delete this webhook?"), lucide.Trash(), component.SpanText("Delete webhook"), diff --git a/internal/view/web/dashboard/webhooks/duplicate_webhook.go b/internal/view/web/dashboard/webhooks/duplicate_webhook.go index 6f2684ac..03119aa0 100644 --- a/internal/view/web/dashboard/webhooks/duplicate_webhook.go +++ b/internal/view/web/dashboard/webhooks/duplicate_webhook.go @@ -1,6 +1,7 @@ package webhooks import ( + "github.com/eduardolat/pgbackweb/internal/util/pathutil" "github.com/eduardolat/pgbackweb/internal/view/web/component" "github.com/eduardolat/pgbackweb/internal/view/web/respondhtmx" "github.com/google/uuid" @@ -27,7 +28,7 @@ func (h *handlers) duplicateWebhookHandler(c echo.Context) error { func duplicateWebhookButton(webhookID uuid.UUID) nodx.Node { return component.OptionsDropdownButton( - htmx.HxPost("/dashboard/webhooks/"+webhookID.String()+"/duplicate"), + htmx.HxPost(pathutil.BuildPath("/dashboard/webhooks/"+webhookID.String()+"/duplicate")), htmx.HxConfirm("Are you sure you want to duplicate this webhook?"), lucide.CopyPlus(), component.SpanText("Duplicate webhook"), diff --git a/internal/view/web/dashboard/webhooks/edit_webhook.go b/internal/view/web/dashboard/webhooks/edit_webhook.go index c19a0fc9..5ee23b66 100644 --- a/internal/view/web/dashboard/webhooks/edit_webhook.go +++ b/internal/view/web/dashboard/webhooks/edit_webhook.go @@ -6,6 +6,7 @@ import ( "github.com/eduardolat/pgbackweb/internal/database/dbgen" "github.com/eduardolat/pgbackweb/internal/util/echoutil" + "github.com/eduardolat/pgbackweb/internal/util/pathutil" "github.com/eduardolat/pgbackweb/internal/validate" "github.com/eduardolat/pgbackweb/internal/view/web/component" "github.com/eduardolat/pgbackweb/internal/view/web/respondhtmx" @@ -101,7 +102,7 @@ func editWebhookForm( backups []dbgen.Backup, ) nodx.Node { return nodx.FormEl( - htmx.HxPost("/dashboard/webhooks/"+webhook.ID.String()+"/edit"), + htmx.HxPost(pathutil.BuildPath("/dashboard/webhooks/"+webhook.ID.String()+"/edit")), htmx.HxDisabledELT("find button[type='submit']"), nodx.Class("space-y-2"), @@ -126,7 +127,7 @@ func editWebhookButton(webhookID uuid.UUID) nodx.Node { Title: "Edit webhook", Content: []nodx.Node{ nodx.Div( - htmx.HxGet("/dashboard/webhooks/"+webhookID.String()+"/edit"), + htmx.HxGet(pathutil.BuildPath("/dashboard/webhooks/"+webhookID.String()+"/edit")), htmx.HxSwap("outerHTML"), htmx.HxTrigger("intersect once"), nodx.Class("p-10 flex justify-center"), diff --git a/internal/view/web/dashboard/webhooks/index.go b/internal/view/web/dashboard/webhooks/index.go index 06c50057..f46a5127 100644 --- a/internal/view/web/dashboard/webhooks/index.go +++ b/internal/view/web/dashboard/webhooks/index.go @@ -4,6 +4,7 @@ import ( "net/http" "github.com/eduardolat/pgbackweb/internal/util/echoutil" + "github.com/eduardolat/pgbackweb/internal/util/pathutil" "github.com/eduardolat/pgbackweb/internal/view/reqctx" "github.com/eduardolat/pgbackweb/internal/view/web/component" "github.com/eduardolat/pgbackweb/internal/view/web/layout" @@ -45,7 +46,7 @@ func indexPage(reqCtx reqctx.Ctx) nodx.Node { ), nodx.Tbody( component.SkeletonTr(8), - htmx.HxGet("/dashboard/webhooks/list?page=1"), + htmx.HxGet(pathutil.BuildPath("/dashboard/webhooks/list?page=1")), htmx.HxTrigger("load"), ), ), diff --git a/internal/view/web/dashboard/webhooks/run_webhook.go b/internal/view/web/dashboard/webhooks/run_webhook.go index 690dd439..171d2a43 100644 --- a/internal/view/web/dashboard/webhooks/run_webhook.go +++ b/internal/view/web/dashboard/webhooks/run_webhook.go @@ -4,6 +4,7 @@ import ( "context" "github.com/eduardolat/pgbackweb/internal/logger" + "github.com/eduardolat/pgbackweb/internal/util/pathutil" "github.com/eduardolat/pgbackweb/internal/view/web/component" "github.com/eduardolat/pgbackweb/internal/view/web/respondhtmx" "github.com/google/uuid" @@ -43,7 +44,7 @@ func (h *handlers) runWebhookHandler(c echo.Context) error { func runWebhookButton(webhookID uuid.UUID) nodx.Node { return component.OptionsDropdownButton( - htmx.HxPost("/dashboard/webhooks/"+webhookID.String()+"/run"), + htmx.HxPost(pathutil.BuildPath("/dashboard/webhooks/"+webhookID.String()+"/run")), htmx.HxDisabledELT("this"), lucide.Zap(), component.SpanText("Run webhook now"), diff --git a/internal/view/web/layout/dashboard_header.go b/internal/view/web/layout/dashboard_header.go index c929161d..b5d8006f 100644 --- a/internal/view/web/layout/dashboard_header.go +++ b/internal/view/web/layout/dashboard_header.go @@ -1,6 +1,7 @@ package layout import ( + "github.com/eduardolat/pgbackweb/internal/util/pathutil" "github.com/eduardolat/pgbackweb/internal/view/web/component" nodx "github.com/nodxdev/nodxgo" htmx "github.com/nodxdev/nodxgo-htmx" @@ -28,7 +29,7 @@ func dashboardHeader() nodx.Node { nodx.Div( nodx.Class("flex justify-end items-center space-x-2"), nodx.Div( - htmx.HxGet("/dashboard/health-button"), + htmx.HxGet(pathutil.BuildPath("/dashboard/health-button")), htmx.HxSwap("outerHTML"), htmx.HxTrigger("load once"), ), @@ -40,7 +41,7 @@ func dashboardHeader() nodx.Node { lucide.ExternalLink(), ), nodx.Button( - htmx.HxPost("/auth/logout"), + htmx.HxPost(pathutil.BuildPath("/auth/logout")), htmx.HxDisabledELT("this"), nodx.Class("btn btn-ghost btn-neutral"), component.SpanText("Log out"), From 7b81f8add1d0812d34c5494a48374f951afc2719 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?S=C3=BCli=20Patrik=20P=C3=A9ter?= Date: Fri, 3 Oct 2025 10:23:27 +0200 Subject: [PATCH 10/27] Standardizes URL path formatting in dashboard components Replaces string concatenation with fmt.Sprintf for building URLs This ensures consistent and more secure path handling across all dashboard operations like delete, edit, duplicate, etc. --- internal/view/web/dashboard/backups/delete_backup.go | 4 +++- internal/view/web/dashboard/backups/duplicate_backup.go | 4 +++- internal/view/web/dashboard/backups/edit_backup.go | 2 +- internal/view/web/dashboard/backups/manual_run.go | 3 ++- internal/view/web/dashboard/databases/create_database.go | 2 +- internal/view/web/dashboard/databases/delete_database.go | 4 +++- internal/view/web/dashboard/databases/edit_database.go | 3 ++- internal/view/web/dashboard/databases/list_databases.go | 2 +- .../view/web/dashboard/destinations/create_destination.go | 2 +- .../view/web/dashboard/destinations/delete_destination.go | 4 +++- internal/view/web/dashboard/destinations/edit_destination.go | 3 ++- .../view/web/dashboard/destinations/list_destinations.go | 2 +- internal/view/web/dashboard/executions/restore_execution.go | 4 ++-- internal/view/web/dashboard/executions/show_execution.go | 3 ++- .../view/web/dashboard/executions/soft_delete_execution.go | 4 +++- internal/view/web/dashboard/webhooks/delete_webhook.go | 4 +++- internal/view/web/dashboard/webhooks/duplicate_webhook.go | 4 +++- internal/view/web/dashboard/webhooks/edit_webhook.go | 5 +++-- internal/view/web/dashboard/webhooks/run_webhook.go | 3 ++- 19 files changed, 41 insertions(+), 21 deletions(-) diff --git a/internal/view/web/dashboard/backups/delete_backup.go b/internal/view/web/dashboard/backups/delete_backup.go index 3cc267c4..a3d841a2 100644 --- a/internal/view/web/dashboard/backups/delete_backup.go +++ b/internal/view/web/dashboard/backups/delete_backup.go @@ -1,6 +1,8 @@ package backups import ( + "fmt" + "github.com/eduardolat/pgbackweb/internal/util/pathutil" "github.com/eduardolat/pgbackweb/internal/view/web/component" "github.com/eduardolat/pgbackweb/internal/view/web/respondhtmx" @@ -28,7 +30,7 @@ func (h *handlers) deleteBackupHandler(c echo.Context) error { func deleteBackupButton(backupID uuid.UUID) nodx.Node { return component.OptionsDropdownButton( - htmx.HxDelete(pathutil.BuildPath("/dashboard/backups/"+backupID.String())), + htmx.HxDelete(pathutil.BuildPath(fmt.Sprintf("/dashboard/backups/%s", backupID))), htmx.HxConfirm("Are you sure you want to delete this backup task?"), lucide.Trash(), component.SpanText("Delete backup task"), diff --git a/internal/view/web/dashboard/backups/duplicate_backup.go b/internal/view/web/dashboard/backups/duplicate_backup.go index 832730b2..f60cd36b 100644 --- a/internal/view/web/dashboard/backups/duplicate_backup.go +++ b/internal/view/web/dashboard/backups/duplicate_backup.go @@ -1,6 +1,8 @@ package backups import ( + "fmt" + "github.com/eduardolat/pgbackweb/internal/util/pathutil" "github.com/eduardolat/pgbackweb/internal/view/web/component" "github.com/eduardolat/pgbackweb/internal/view/web/respondhtmx" @@ -28,7 +30,7 @@ func (h *handlers) duplicateBackupHandler(c echo.Context) error { func duplicateBackupButton(backupID uuid.UUID) nodx.Node { return component.OptionsDropdownButton( - htmx.HxPost(pathutil.BuildPath("/dashboard/backups/"+backupID.String()+"/duplicate")), + htmx.HxPost(pathutil.BuildPath(fmt.Sprintf("/dashboard/backups/%s/duplicate", backupID))), htmx.HxConfirm("Are you sure you want to duplicate this backup task?"), lucide.CopyPlus(), component.SpanText("Duplicate backup task"), diff --git a/internal/view/web/dashboard/backups/edit_backup.go b/internal/view/web/dashboard/backups/edit_backup.go index 691cd70c..4a9e2bfe 100644 --- a/internal/view/web/dashboard/backups/edit_backup.go +++ b/internal/view/web/dashboard/backups/edit_backup.go @@ -91,7 +91,7 @@ func editBackupButton(backup dbgen.BackupsServicePaginateBackupsRow) nodx.Node { Title: "Edit backup task", Content: []nodx.Node{ nodx.FormEl( - htmx.HxPost(pathutil.BuildPath("/dashboard/backups/"+backup.ID.String()+"/edit")), + htmx.HxPost(pathutil.BuildPath(fmt.Sprintf("/dashboard/backups/%s/edit", backup.ID))), htmx.HxDisabledELT("find button"), nodx.Class("space-y-2 text-base"), diff --git a/internal/view/web/dashboard/backups/manual_run.go b/internal/view/web/dashboard/backups/manual_run.go index 0e1a62db..61c64c5a 100644 --- a/internal/view/web/dashboard/backups/manual_run.go +++ b/internal/view/web/dashboard/backups/manual_run.go @@ -2,6 +2,7 @@ package backups import ( "context" + "fmt" "github.com/eduardolat/pgbackweb/internal/util/pathutil" "github.com/eduardolat/pgbackweb/internal/view/web/component" @@ -28,7 +29,7 @@ func (h *handlers) manualRunHandler(c echo.Context) error { func manualRunbutton(backupID uuid.UUID) nodx.Node { return component.OptionsDropdownButton( - htmx.HxPost(pathutil.BuildPath("/dashboard/backups/"+backupID.String()+"/run")), + htmx.HxPost(pathutil.BuildPath(fmt.Sprintf("/dashboard/backups/%s/run", backupID))), htmx.HxDisabledELT("this"), lucide.Zap(), component.SpanText("Run backup now"), diff --git a/internal/view/web/dashboard/databases/create_database.go b/internal/view/web/dashboard/databases/create_database.go index 0e95a697..47815afd 100644 --- a/internal/view/web/dashboard/databases/create_database.go +++ b/internal/view/web/dashboard/databases/create_database.go @@ -48,7 +48,7 @@ func (h *handlers) createDatabaseHandler(c echo.Context) error { func createDatabaseButton() nodx.Node { htmxAttributes := func(url string) nodx.Node { return nodx.Group( - htmx.HxPost(url), + htmx.HxPost(pathutil.BuildPath(url)), htmx.HxInclude("#add-database-form"), htmx.HxDisabledELT(".add-database-btn"), htmx.HxIndicator("#add-database-loading"), diff --git a/internal/view/web/dashboard/databases/delete_database.go b/internal/view/web/dashboard/databases/delete_database.go index a35f6ad3..e2066c58 100644 --- a/internal/view/web/dashboard/databases/delete_database.go +++ b/internal/view/web/dashboard/databases/delete_database.go @@ -1,6 +1,8 @@ package databases import ( + "fmt" + "github.com/eduardolat/pgbackweb/internal/util/pathutil" "github.com/eduardolat/pgbackweb/internal/view/web/component" "github.com/eduardolat/pgbackweb/internal/view/web/respondhtmx" @@ -28,7 +30,7 @@ func (h *handlers) deleteDatabaseHandler(c echo.Context) error { func deleteDatabaseButton(databaseID uuid.UUID) nodx.Node { return component.OptionsDropdownButton( - htmx.HxDelete(pathutil.BuildPath("/dashboard/databases/"+databaseID.String())), + htmx.HxDelete(pathutil.BuildPath(fmt.Sprintf("/dashboard/databases/%s", databaseID))), htmx.HxConfirm("Are you sure you want to delete this database?"), lucide.Trash(), component.SpanText("Delete database"), diff --git a/internal/view/web/dashboard/databases/edit_database.go b/internal/view/web/dashboard/databases/edit_database.go index 6503668e..04d028d5 100644 --- a/internal/view/web/dashboard/databases/edit_database.go +++ b/internal/view/web/dashboard/databases/edit_database.go @@ -4,6 +4,7 @@ import ( "database/sql" "github.com/eduardolat/pgbackweb/internal/database/dbgen" + "github.com/eduardolat/pgbackweb/internal/util/pathutil" "github.com/eduardolat/pgbackweb/internal/validate" "github.com/eduardolat/pgbackweb/internal/view/web/component" "github.com/eduardolat/pgbackweb/internal/view/web/respondhtmx" @@ -55,7 +56,7 @@ func editDatabaseButton( htmxAttributes := func(url string) nodx.Node { return nodx.Group( - htmx.HxPost(url), + htmx.HxPost(pathutil.BuildPath(url)), htmx.HxInclude("#"+formID), htmx.HxDisabledELT("."+btnClass), htmx.HxIndicator("#"+loadingID), diff --git a/internal/view/web/dashboard/databases/list_databases.go b/internal/view/web/dashboard/databases/list_databases.go index 1406c672..ae7934eb 100644 --- a/internal/view/web/dashboard/databases/list_databases.go +++ b/internal/view/web/dashboard/databases/list_databases.go @@ -74,7 +74,7 @@ func listDatabases( ), editDatabaseButton(database), component.OptionsDropdownButton( - htmx.HxPost(pathutil.BuildPath("/dashboard/databases/"+database.ID.String()+"/test")), + htmx.HxPost(pathutil.BuildPath(fmt.Sprintf("/dashboard/databases/%s/test", database.ID))), htmx.HxDisabledELT("this"), lucide.DatabaseZap(), component.SpanText("Test connection"), diff --git a/internal/view/web/dashboard/destinations/create_destination.go b/internal/view/web/dashboard/destinations/create_destination.go index 275944fc..2f8d0c8e 100644 --- a/internal/view/web/dashboard/destinations/create_destination.go +++ b/internal/view/web/dashboard/destinations/create_destination.go @@ -52,7 +52,7 @@ func (h *handlers) createDestinationHandler(c echo.Context) error { func createDestinationButton() nodx.Node { htmxAttributes := func(url string) nodx.Node { return nodx.Group( - htmx.HxPost(url), + htmx.HxPost(pathutil.BuildPath(url)), htmx.HxInclude("#add-destination-form"), htmx.HxDisabledELT(".add-destination-btn"), htmx.HxIndicator("#add-destination-loading"), diff --git a/internal/view/web/dashboard/destinations/delete_destination.go b/internal/view/web/dashboard/destinations/delete_destination.go index 60a55f10..a782813f 100644 --- a/internal/view/web/dashboard/destinations/delete_destination.go +++ b/internal/view/web/dashboard/destinations/delete_destination.go @@ -1,6 +1,8 @@ package destinations import ( + "fmt" + "github.com/eduardolat/pgbackweb/internal/util/pathutil" "github.com/eduardolat/pgbackweb/internal/view/web/component" "github.com/eduardolat/pgbackweb/internal/view/web/respondhtmx" @@ -29,7 +31,7 @@ func (h *handlers) deleteDestinationHandler(c echo.Context) error { func deleteDestinationButton(destinationID uuid.UUID) nodx.Node { return component.OptionsDropdownButton( - htmx.HxDelete(pathutil.BuildPath("/dashboard/destinations/"+destinationID.String())), + htmx.HxDelete(pathutil.BuildPath(fmt.Sprintf("/dashboard/destinations/%s", destinationID))), htmx.HxConfirm("Are you sure you want to delete this destination?"), lucide.Trash(), component.SpanText("Delete destination"), diff --git a/internal/view/web/dashboard/destinations/edit_destination.go b/internal/view/web/dashboard/destinations/edit_destination.go index 47629a9a..982b809b 100644 --- a/internal/view/web/dashboard/destinations/edit_destination.go +++ b/internal/view/web/dashboard/destinations/edit_destination.go @@ -4,6 +4,7 @@ import ( "database/sql" "github.com/eduardolat/pgbackweb/internal/database/dbgen" + "github.com/eduardolat/pgbackweb/internal/util/pathutil" "github.com/eduardolat/pgbackweb/internal/validate" "github.com/eduardolat/pgbackweb/internal/view/web/component" "github.com/eduardolat/pgbackweb/internal/view/web/respondhtmx" @@ -58,7 +59,7 @@ func editDestinationButton( htmxAttributes := func(url string) nodx.Node { return nodx.Group( - htmx.HxPost(url), + htmx.HxPost(pathutil.BuildPath(url)), htmx.HxInclude("#"+formID), htmx.HxDisabledELT("."+btnClass), htmx.HxIndicator("#"+loadingID), diff --git a/internal/view/web/dashboard/destinations/list_destinations.go b/internal/view/web/dashboard/destinations/list_destinations.go index ad360ee3..48f7dfdf 100644 --- a/internal/view/web/dashboard/destinations/list_destinations.go +++ b/internal/view/web/dashboard/destinations/list_destinations.go @@ -72,7 +72,7 @@ func listDestinations( ), editDestinationButton(destination), component.OptionsDropdownButton( - htmx.HxPost(pathutil.BuildPath("/dashboard/destinations/"+destination.ID.String()+"/test")), + htmx.HxPost(pathutil.BuildPath(fmt.Sprintf("/dashboard/destinations/%s/test", destination.ID))), htmx.HxDisabledELT("this"), lucide.PlugZap(), component.SpanText("Test connection"), diff --git a/internal/view/web/dashboard/executions/restore_execution.go b/internal/view/web/dashboard/executions/restore_execution.go index 6c59cdb5..d1acd334 100644 --- a/internal/view/web/dashboard/executions/restore_execution.go +++ b/internal/view/web/dashboard/executions/restore_execution.go @@ -108,7 +108,7 @@ func restoreExecutionForm( databases []dbgen.DatabasesServiceGetAllDatabasesRow, ) nodx.Node { return nodx.FormEl( - htmx.HxPost(pathutil.BuildPath("/dashboard/executions/"+execution.ID.String()+"/restore")), + htmx.HxPost(pathutil.BuildPath(fmt.Sprintf("/dashboard/executions/%s/restore", execution.ID))), htmx.HxConfirm("Are you sure you want to restore this backup?"), htmx.HxDisabledELT("find button"), @@ -223,7 +223,7 @@ func restoreExecutionButton(execution dbgen.ExecutionsServicePaginateExecutionsR Title: "Restore backup execution", Content: []nodx.Node{ nodx.Div( - htmx.HxGet(pathutil.BuildPath("/dashboard/executions/"+execution.ID.String()+"/restore-form")), + htmx.HxGet(pathutil.BuildPath(fmt.Sprintf("/dashboard/executions/%s/restore-form", execution.ID))), htmx.HxSwap("outerHTML"), htmx.HxTrigger("intersect once"), nodx.Class("p-10 flex justify-center"), diff --git a/internal/view/web/dashboard/executions/show_execution.go b/internal/view/web/dashboard/executions/show_execution.go index 6d3287d4..24183d87 100644 --- a/internal/view/web/dashboard/executions/show_execution.go +++ b/internal/view/web/dashboard/executions/show_execution.go @@ -1,6 +1,7 @@ package executions import ( + "fmt" "net/http" "path/filepath" @@ -122,7 +123,7 @@ func showExecutionButton( nodx.Class("flex justify-end items-center space-x-2"), deleteExecutionButton(execution.ID), nodx.A( - nodx.Href(pathutil.BuildPath("/dashboard/executions/"+execution.ID.String()+"/download")), + nodx.Href(pathutil.BuildPath(fmt.Sprintf("/dashboard/executions/%s/download", execution.ID))), nodx.Target("_blank"), nodx.Class("btn btn-primary"), component.SpanText("Download"), diff --git a/internal/view/web/dashboard/executions/soft_delete_execution.go b/internal/view/web/dashboard/executions/soft_delete_execution.go index c08fa121..847345f6 100644 --- a/internal/view/web/dashboard/executions/soft_delete_execution.go +++ b/internal/view/web/dashboard/executions/soft_delete_execution.go @@ -1,6 +1,8 @@ package executions import ( + "fmt" + "github.com/eduardolat/pgbackweb/internal/util/pathutil" "github.com/eduardolat/pgbackweb/internal/view/web/component" "github.com/eduardolat/pgbackweb/internal/view/web/respondhtmx" @@ -29,7 +31,7 @@ func (h *handlers) deleteExecutionHandler(c echo.Context) error { func deleteExecutionButton(executionID uuid.UUID) nodx.Node { return nodx.Button( - htmx.HxDelete(pathutil.BuildPath("/dashboard/executions/"+executionID.String())), + htmx.HxDelete(pathutil.BuildPath(fmt.Sprintf("/dashboard/executions/%s", executionID))), htmx.HxDisabledELT("this"), htmx.HxConfirm("Are you sure you want to delete this execution? It will delete the backup file from the destination and it can't be recovered."), nodx.Class("btn btn-error btn-outline"), diff --git a/internal/view/web/dashboard/webhooks/delete_webhook.go b/internal/view/web/dashboard/webhooks/delete_webhook.go index c86a10b5..516d1055 100644 --- a/internal/view/web/dashboard/webhooks/delete_webhook.go +++ b/internal/view/web/dashboard/webhooks/delete_webhook.go @@ -1,6 +1,8 @@ package webhooks import ( + "fmt" + "github.com/eduardolat/pgbackweb/internal/util/pathutil" "github.com/eduardolat/pgbackweb/internal/view/web/component" "github.com/eduardolat/pgbackweb/internal/view/web/respondhtmx" @@ -28,7 +30,7 @@ func (h *handlers) deleteWebhookHandler(c echo.Context) error { func deleteWebhookButton(webhookID uuid.UUID) nodx.Node { return component.OptionsDropdownButton( - htmx.HxDelete(pathutil.BuildPath("/dashboard/webhooks/"+webhookID.String())), + htmx.HxDelete(pathutil.BuildPath(fmt.Sprintf("/dashboard/webhooks/%s", webhookID))), htmx.HxConfirm("Are you sure you want to delete this webhook?"), lucide.Trash(), component.SpanText("Delete webhook"), diff --git a/internal/view/web/dashboard/webhooks/duplicate_webhook.go b/internal/view/web/dashboard/webhooks/duplicate_webhook.go index 03119aa0..30c73d74 100644 --- a/internal/view/web/dashboard/webhooks/duplicate_webhook.go +++ b/internal/view/web/dashboard/webhooks/duplicate_webhook.go @@ -1,6 +1,8 @@ package webhooks import ( + "fmt" + "github.com/eduardolat/pgbackweb/internal/util/pathutil" "github.com/eduardolat/pgbackweb/internal/view/web/component" "github.com/eduardolat/pgbackweb/internal/view/web/respondhtmx" @@ -28,7 +30,7 @@ func (h *handlers) duplicateWebhookHandler(c echo.Context) error { func duplicateWebhookButton(webhookID uuid.UUID) nodx.Node { return component.OptionsDropdownButton( - htmx.HxPost(pathutil.BuildPath("/dashboard/webhooks/"+webhookID.String()+"/duplicate")), + htmx.HxPost(pathutil.BuildPath(fmt.Sprintf("/dashboard/webhooks/%s/duplicate", webhookID))), htmx.HxConfirm("Are you sure you want to duplicate this webhook?"), lucide.CopyPlus(), component.SpanText("Duplicate webhook"), diff --git a/internal/view/web/dashboard/webhooks/edit_webhook.go b/internal/view/web/dashboard/webhooks/edit_webhook.go index 5ee23b66..615567af 100644 --- a/internal/view/web/dashboard/webhooks/edit_webhook.go +++ b/internal/view/web/dashboard/webhooks/edit_webhook.go @@ -2,6 +2,7 @@ package webhooks import ( "database/sql" + "fmt" "net/http" "github.com/eduardolat/pgbackweb/internal/database/dbgen" @@ -102,7 +103,7 @@ func editWebhookForm( backups []dbgen.Backup, ) nodx.Node { return nodx.FormEl( - htmx.HxPost(pathutil.BuildPath("/dashboard/webhooks/"+webhook.ID.String()+"/edit")), + htmx.HxPost(pathutil.BuildPath(fmt.Sprintf("/dashboard/webhooks/%s/edit", webhook.ID))), htmx.HxDisabledELT("find button[type='submit']"), nodx.Class("space-y-2"), @@ -127,7 +128,7 @@ func editWebhookButton(webhookID uuid.UUID) nodx.Node { Title: "Edit webhook", Content: []nodx.Node{ nodx.Div( - htmx.HxGet(pathutil.BuildPath("/dashboard/webhooks/"+webhookID.String()+"/edit")), + htmx.HxGet(pathutil.BuildPath(fmt.Sprintf("/dashboard/webhooks/%s/edit", webhookID))), htmx.HxSwap("outerHTML"), htmx.HxTrigger("intersect once"), nodx.Class("p-10 flex justify-center"), diff --git a/internal/view/web/dashboard/webhooks/run_webhook.go b/internal/view/web/dashboard/webhooks/run_webhook.go index 171d2a43..95234ba4 100644 --- a/internal/view/web/dashboard/webhooks/run_webhook.go +++ b/internal/view/web/dashboard/webhooks/run_webhook.go @@ -2,6 +2,7 @@ package webhooks import ( "context" + "fmt" "github.com/eduardolat/pgbackweb/internal/logger" "github.com/eduardolat/pgbackweb/internal/util/pathutil" @@ -44,7 +45,7 @@ func (h *handlers) runWebhookHandler(c echo.Context) error { func runWebhookButton(webhookID uuid.UUID) nodx.Node { return component.OptionsDropdownButton( - htmx.HxPost(pathutil.BuildPath("/dashboard/webhooks/"+webhookID.String()+"/run")), + htmx.HxPost(pathutil.BuildPath(fmt.Sprintf("/dashboard/webhooks/%s/run", webhookID))), htmx.HxDisabledELT("this"), lucide.Zap(), component.SpanText("Run webhook now"), From f39598db9749de150c1d90c57faefb4580767107 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?S=C3=BCli=20Patrik=20P=C3=A9ter?= Date: Fri, 3 Oct 2025 12:21:43 +0200 Subject: [PATCH 11/27] Refactors URL handling with pathutil.BuildPath Introduces consistent use of pathutil.BuildPath function for building URLs Adds proper path prefixing to image paths in support project data Improves URL generation across various dashboard components WIP on feature/path-prefix --- .../view/web/component/support_project.inc.js | 45 ++++++++++++++++++- .../web/dashboard/backups/list_backups.go | 8 ++-- .../web/dashboard/databases/list_databases.go | 8 ++-- .../destinations/list_destinations.go | 8 ++-- .../view/web/dashboard/executions/index.go | 3 +- .../dashboard/executions/list_executions.go | 3 +- .../view/web/dashboard/restorations/index.go | 3 +- .../restorations/list_restorations.go | 3 +- .../web/dashboard/webhooks/list_webhooks.go | 2 +- .../dashboard/webhooks/webhook_executions.go | 4 +- 10 files changed, 66 insertions(+), 21 deletions(-) diff --git a/internal/view/web/component/support_project.inc.js b/internal/view/web/component/support_project.inc.js index fa348327..c42173a1 100644 --- a/internal/view/web/component/support_project.inc.js +++ b/internal/view/web/component/support_project.inc.js @@ -55,6 +55,47 @@ window.alpineSupportProjectData = function () { } }, + prefixImagePath(path) { + // If the path starts with / and doesn't start with http, add the path prefix + if (path && path.startsWith("/") && !path.startsWith("http")) { + return window.PBW_PATH_PREFIX + path; + } + return path; + }, + + processData(data) { + // Add path prefix to all image URLs + if (data.referralLinks) { + data.referralLinks = data.referralLinks.map((link) => ({ + ...link, + logo: this.prefixImagePath(link.logo), + })); + } + + if (data.sponsors) { + if (data.sponsors.gold) { + data.sponsors.gold = data.sponsors.gold.map((sponsor) => ({ + ...sponsor, + logo: this.prefixImagePath(sponsor.logo), + })); + } + if (data.sponsors.silver) { + data.sponsors.silver = data.sponsors.silver.map((sponsor) => ({ + ...sponsor, + logo: this.prefixImagePath(sponsor.logo), + })); + } + if (data.sponsors.bronze) { + data.sponsors.bronze = data.sponsors.bronze.map((sponsor) => ({ + ...sponsor, + logo: this.prefixImagePath(sponsor.logo), + })); + } + } + + return data; + }, + async getData() { const cacheKey = "pbw-support-project-data"; @@ -63,7 +104,7 @@ window.alpineSupportProjectData = function () { const cached = JSON.parse(cachedJSON); // Cache for 2 minutes if (Date.now() - cached.timestamp < 2 * 60 * 1000) { - return cached.data; + return this.processData(cached.data); } } @@ -80,7 +121,7 @@ window.alpineSupportProjectData = function () { timestamp: Date.now(), }); localStorage.setItem(cacheKey, dataToCache); - return data; + return this.processData(data); } catch { return null; } diff --git a/internal/view/web/dashboard/backups/list_backups.go b/internal/view/web/dashboard/backups/list_backups.go index 8483997b..675590c7 100644 --- a/internal/view/web/dashboard/backups/list_backups.go +++ b/internal/view/web/dashboard/backups/list_backups.go @@ -70,9 +70,9 @@ func listBackups( nodx.Td(component.OptionsDropdown( component.OptionsDropdownA( nodx.Class("btn btn-sm btn-ghost btn-square"), - nodx.Href( + nodx.Href(pathutil.BuildPath( fmt.Sprintf("/dashboard/executions?backup=%s", backup.ID), - ), + )), nodx.Target("_blank"), lucide.List(), component.SpanText("Show executions"), @@ -125,9 +125,9 @@ func listBackups( if pagination.HasNextPage { trs = append(trs, nodx.Tr( - htmx.HxGet(fmt.Sprintf( + htmx.HxGet(pathutil.BuildPath(fmt.Sprintf( "/dashboard/backups/list?page=%d", pagination.NextPage, - )), + ))), htmx.HxTrigger("intersect once"), htmx.HxSwap("afterend"), )) diff --git a/internal/view/web/dashboard/databases/list_databases.go b/internal/view/web/dashboard/databases/list_databases.go index ae7934eb..28e2f6ed 100644 --- a/internal/view/web/dashboard/databases/list_databases.go +++ b/internal/view/web/dashboard/databases/list_databases.go @@ -65,9 +65,9 @@ func listDatabases( nodx.Div( nodx.Class("flex flex-col space-y-1"), component.OptionsDropdownA( - nodx.Href( + nodx.Href(pathutil.BuildPath( fmt.Sprintf("/dashboard/executions?database=%s", database.ID), - ), + )), nodx.Target("_blank"), lucide.List(), component.SpanText("Show executions"), @@ -105,9 +105,9 @@ func listDatabases( if pagination.HasNextPage { trs = append(trs, nodx.Tr( - htmx.HxGet(fmt.Sprintf( + htmx.HxGet(pathutil.BuildPath(fmt.Sprintf( "/dashboard/databases/list?page=%d", pagination.NextPage, - )), + ))), htmx.HxTrigger("intersect once"), htmx.HxSwap("afterend"), )) diff --git a/internal/view/web/dashboard/destinations/list_destinations.go b/internal/view/web/dashboard/destinations/list_destinations.go index 48f7dfdf..b0534def 100644 --- a/internal/view/web/dashboard/destinations/list_destinations.go +++ b/internal/view/web/dashboard/destinations/list_destinations.go @@ -63,9 +63,9 @@ func listDestinations( trs = append(trs, nodx.Tr( nodx.Td(component.OptionsDropdown( component.OptionsDropdownA( - nodx.Href( + nodx.Href(pathutil.BuildPath( fmt.Sprintf("/dashboard/executions?destination=%s", destination.ID), - ), + )), nodx.Target("_blank"), lucide.List(), component.SpanText("Show executions"), @@ -131,9 +131,9 @@ func listDestinations( if pagination.HasNextPage { trs = append(trs, nodx.Tr( - htmx.HxGet(fmt.Sprintf( + htmx.HxGet(pathutil.BuildPath(fmt.Sprintf( "/dashboard/destinations/list?page=%d", pagination.NextPage, - )), + ))), htmx.HxTrigger("intersect once"), htmx.HxSwap("afterend"), )) diff --git a/internal/view/web/dashboard/executions/index.go b/internal/view/web/dashboard/executions/index.go index 5c57b5cb..7216be59 100644 --- a/internal/view/web/dashboard/executions/index.go +++ b/internal/view/web/dashboard/executions/index.go @@ -4,6 +4,7 @@ import ( "net/http" "github.com/eduardolat/pgbackweb/internal/util/echoutil" + "github.com/eduardolat/pgbackweb/internal/util/pathutil" "github.com/eduardolat/pgbackweb/internal/util/strutil" "github.com/eduardolat/pgbackweb/internal/validate" "github.com/eduardolat/pgbackweb/internal/view/reqctx" @@ -61,7 +62,7 @@ func indexPage(reqCtx reqctx.Ctx, queryData execsQueryData) nodx.Node { nodx.Tbody( component.SkeletonTr(8), htmx.HxGet(func() string { - url := "/dashboard/executions/list?page=1" + url := pathutil.BuildPath("/dashboard/executions/list?page=1") if queryData.Database != uuid.Nil { url = strutil.AddQueryParamToUrl(url, "database", queryData.Database.String()) } diff --git a/internal/view/web/dashboard/executions/list_executions.go b/internal/view/web/dashboard/executions/list_executions.go index 04e0199c..c6b8b3e0 100644 --- a/internal/view/web/dashboard/executions/list_executions.go +++ b/internal/view/web/dashboard/executions/list_executions.go @@ -8,6 +8,7 @@ import ( "github.com/eduardolat/pgbackweb/internal/service/executions" "github.com/eduardolat/pgbackweb/internal/util/echoutil" "github.com/eduardolat/pgbackweb/internal/util/paginateutil" + "github.com/eduardolat/pgbackweb/internal/util/pathutil" "github.com/eduardolat/pgbackweb/internal/util/strutil" "github.com/eduardolat/pgbackweb/internal/util/timeutil" "github.com/eduardolat/pgbackweb/internal/validate" @@ -117,7 +118,7 @@ func listExecutions( if pagination.HasNextPage { trs = append(trs, nodx.Tr( htmx.HxGet(func() string { - url := "/dashboard/executions/list" + url := pathutil.BuildPath("/dashboard/executions/list") url = strutil.AddQueryParamToUrl(url, "page", fmt.Sprintf("%d", pagination.NextPage)) if queryData.Database != uuid.Nil { url = strutil.AddQueryParamToUrl(url, "database", queryData.Database.String()) diff --git a/internal/view/web/dashboard/restorations/index.go b/internal/view/web/dashboard/restorations/index.go index 2b11cc41..2c4bc18c 100644 --- a/internal/view/web/dashboard/restorations/index.go +++ b/internal/view/web/dashboard/restorations/index.go @@ -4,6 +4,7 @@ import ( "net/http" "github.com/eduardolat/pgbackweb/internal/util/echoutil" + "github.com/eduardolat/pgbackweb/internal/util/pathutil" "github.com/eduardolat/pgbackweb/internal/util/strutil" "github.com/eduardolat/pgbackweb/internal/validate" "github.com/eduardolat/pgbackweb/internal/view/reqctx" @@ -66,7 +67,7 @@ func indexPage(reqCtx reqctx.Ctx, queryData resQueryData) nodx.Node { nodx.Tbody( component.SkeletonTr(8), htmx.HxGet(func() string { - url := "/dashboard/restorations/list?page=1" + url := pathutil.BuildPath("/dashboard/restorations/list?page=1") if queryData.Execution != uuid.Nil { url = strutil.AddQueryParamToUrl(url, "execution", queryData.Execution.String()) } diff --git a/internal/view/web/dashboard/restorations/list_restorations.go b/internal/view/web/dashboard/restorations/list_restorations.go index ec330b75..5a3a11dc 100644 --- a/internal/view/web/dashboard/restorations/list_restorations.go +++ b/internal/view/web/dashboard/restorations/list_restorations.go @@ -8,6 +8,7 @@ import ( "github.com/eduardolat/pgbackweb/internal/service/restorations" "github.com/eduardolat/pgbackweb/internal/util/echoutil" "github.com/eduardolat/pgbackweb/internal/util/paginateutil" + "github.com/eduardolat/pgbackweb/internal/util/pathutil" "github.com/eduardolat/pgbackweb/internal/util/strutil" "github.com/eduardolat/pgbackweb/internal/util/timeutil" "github.com/eduardolat/pgbackweb/internal/validate" @@ -109,7 +110,7 @@ func listRestorations( if pagination.HasNextPage { trs = append(trs, nodx.Tr( htmx.HxGet(func() string { - url := "/dashboard/restorations/list" + url := pathutil.BuildPath("/dashboard/restorations/list") url = strutil.AddQueryParamToUrl(url, "page", fmt.Sprintf("%d", pagination.NextPage)) if queryData.Execution != uuid.Nil { url = strutil.AddQueryParamToUrl(url, "execution", queryData.Execution.String()) diff --git a/internal/view/web/dashboard/webhooks/list_webhooks.go b/internal/view/web/dashboard/webhooks/list_webhooks.go index d9b22fac..c7b05041 100644 --- a/internal/view/web/dashboard/webhooks/list_webhooks.go +++ b/internal/view/web/dashboard/webhooks/list_webhooks.go @@ -92,7 +92,7 @@ func listWebhooks( if pagination.HasNextPage { trs = append(trs, nodx.Tr( htmx.HxGet(func() string { - url := "/dashboard/webhooks/list" + url := pathutil.BuildPath("/dashboard/webhooks/list") url = strutil.AddQueryParamToUrl(url, "page", fmt.Sprintf("%d", pagination.NextPage)) return url }()), diff --git a/internal/view/web/dashboard/webhooks/webhook_executions.go b/internal/view/web/dashboard/webhooks/webhook_executions.go index c4a9a194..1d8cdaec 100644 --- a/internal/view/web/dashboard/webhooks/webhook_executions.go +++ b/internal/view/web/dashboard/webhooks/webhook_executions.go @@ -88,7 +88,7 @@ func webhookExecutionsList( if pagination.HasNextPage { trs = append(trs, nodx.Tr( htmx.HxGet(func() string { - url := "/dashboard/webhooks/" + webhookID.String() + "/executions" + url := pathutil.BuildPath("/dashboard/webhooks/" + webhookID.String() + "/executions") url = strutil.AddQueryParamToUrl(url, "page", fmt.Sprintf("%d", pagination.NextPage)) return url }()), @@ -258,7 +258,7 @@ func webhookExecutionsButton(webhookID uuid.UUID) nodx.Node { ), nodx.Tbody( htmx.HxGet( - "/dashboard/webhooks/"+webhookID.String()+"/executions?page=1", + pathutil.BuildPath("/dashboard/webhooks/"+webhookID.String()+"/executions?page=1"), ), htmx.HxIndicator("#webhook-executions-loading"), htmx.HxTrigger("intersect once"), From fd58d413f7e6b88fac520ae7969ce26725b52763 Mon Sep 17 00:00:00 2001 From: Luis Eduardo Date: Sun, 5 Oct 2025 19:14:09 +0000 Subject: [PATCH 12/27] Refactor README.md for improved readability and consistency --- README.md | 85 ++++++++++++++++++------------------------------------- 1 file changed, 27 insertions(+), 58 deletions(-) diff --git a/README.md b/README.md index 064a35bf..de939199 100644 --- a/README.md +++ b/README.md @@ -7,6 +7,7 @@ 🐘 Effortless PostgreSQL backups with a user-friendly web interface! 🌐💾

+

CI Status @@ -30,8 +31,7 @@ ## Why PG Back Web? -PG Back Web isn't just another backup tool. It's your trusted ally in ensuring -the security and availability of your PostgreSQL data: +PG Back Web isn't just another backup tool. It's your trusted ally in ensuring the security and availability of your PostgreSQL data: - 🎯 **Designed for everyone**: From individual developers to teams. - ⏱️ **Save time**: Automate your backups and forget about manual tasks. @@ -39,34 +39,23 @@ the security and availability of your PostgreSQL data: ## Features -- 📦 **Intuitive web interface**: Manage your backups with ease, no database - expertise required. -- 📅 **Scheduled backups**: Set it and forget it. PG Back Web takes care of the - rest. -- 📈 **Backup monitoring**: Visualize the status of your backups with execution - logs. -- 📤 **Instant download & restore**: Restore and download your backups when you - need them, directly from the web interface. -- 🖥 **Multi-version support**: Compatible with PostgreSQL 13, 14, 15, 16, - 17, and 18. -- 📁 **Local & S3 storage**: Store backups locally or add as many S3 buckets as - you want for greater flexibility. -- ❤️‍🩹 **Health checks**: Automatically check the health of your databases and - destinations. -- 🔔 **Webhooks**: Get notified when a backup finishes, failed, health check - fails, or other events. +- 📦 **Intuitive web interface**: Manage your backups with ease, no database expertise required. +- 📅 **Scheduled backups**: Set it and forget it. PG Back Web takes care of the rest. +- 📈 **Backup monitoring**: Visualize the status of your backups with execution logs. +- 📤 **Instant download & restore**: Restore and download your backups when you need them, directly from the web interface. +- 🖥 **Multi-version support**: Compatible with PostgreSQL 13, 14, 15, 16, 17, and 18. +- 📁 **Local & S3 storage**: Store backups locally or add as many S3 buckets as you want for greater flexibility. +- ❤️‍🩹 **Health checks**: Automatically check the health of your databases and destinations. +- 🔔 **Webhooks**: Get notified when a backup finishes, failed, health check fails, or other events. - 🔒 **Security first**: PGP encryption to protect your sensitive information. -- 🛡️ **Open-source trust**: Open-source code under AGPL v3 license, backed by the - robust pg_dump tool. +- 🛡️ **Open-source trust**: Open-source code under AGPL v3 license, backed by the robust pg_dump tool. - 🌚 **Dark mode**: Because we all love dark mode. ## Installation -PG Back Web is available as a Docker image. You just need to set 3 environment -variables and you're good to go! +PG Back Web is available as a Docker image. You just need to set 3 environment variables and you're good to go! -Here's an example of how you can run PG Back Web with Docker Compose, feel free -to adapt it to your needs: +Here's an example of how you can run PG Back Web with Docker Compose, feel free to adapt it to your needs: ```yaml services: @@ -77,10 +66,9 @@ services: volumes: - ./backups:/backups # If you only use S3 destinations, you don't need this volume environment: + # Optional environment variables are ignored, see the configuration section below for more details PBW_ENCRYPTION_KEY: "my_secret_key" # Change this to a strong key PBW_POSTGRES_CONN_STRING: "postgresql://postgres:password@postgres:5432/pgbackweb?sslmode=disable" - # PBW_PATH_PREFIX: "/pgbackweb" # Optional: Use this if serving under a subpath - TZ: "America/Guatemala" # Set your timezone, optional depends_on: postgres: condition: service_healthy @@ -102,32 +90,23 @@ services: retries: 5 ``` -You can watch [this youtube video](https://www.youtube.com/watch?v=vf7SLrSO8sw) -to see how easy it is to set up PG Back Web. +You can watch [this youtube video](https://www.youtube.com/watch?v=vf7SLrSO8sw) to see how easy it is to set up PG Back Web. ## Configuration You only need to configure the following environment variables: -- `PBW_ENCRYPTION_KEY`: Your encryption key. Generate a strong one and store it - in a safe place, as PG Back Web uses it to encrypt sensitive data. +- `PBW_ENCRYPTION_KEY`: Your encryption key. Generate a strong random one and store it in a safe place, as PG Back Web uses it to encrypt sensitive data. -- `PBW_POSTGRES_CONN_STRING`: The connection string for the PostgreSQL database - that will store PG Back Web data. +- `PBW_POSTGRES_CONN_STRING`: The connection string for the PostgreSQL database that will store PG Back Web data. -- `PBW_LISTEN_HOST`: Host for the server to listen on, default 0.0.0.0 - (optional) +- `PBW_LISTEN_HOST`: Optional. Host for the server to listen on, default 0.0.0.0 -- `PBW_LISTEN_PORT`: Port for the server to listen on, default 8085 (optional) +- `PBW_LISTEN_PORT`: Optional. Port for the server to listen on, default 8085 -- `PBW_PATH_PREFIX`: Path prefix for the application URL. Use this when serving - the application under a subpath (e.g., `/pgbackweb`). Must start with `/` and - not end with `/`. Default is empty (optional) +- `PBW_PATH_PREFIX`: Optional. Path prefix for the application URL. Use this when you want to serve the application under a subpath (e.g., `/pgbackweb`). Must start with `/` and not end with `/`. Default is empty. -- `TZ`: Your - [timezone](https://en.wikipedia.org/wiki/List_of_tz_database_time_zones#List) - (optional). Default is `UTC`. This impacts logging, backup filenames and - default timezone in the web interface. +- `TZ`: Optional. Your [timezone](https://en.wikipedia.org/wiki/List_of_tz_database_time_zones#List). Default is `UTC`. This impacts logging, backup filenames and default timezone in the web interface. ## Screenshot @@ -135,20 +114,17 @@ You only need to configure the following environment variables: ## Reset password -You can reset your PG Back Web password by running the following command in the -server where PG Back Web is running: +You can reset your PG Back Web password by running the following command in the server where PG Back Web is running: ```bash docker exec -it sh -c change-password ``` -You should replace `` with the name or ID of the PG Back -Web container, then just follow the instructions. +You should replace `` with the name or ID of the PG Back Web container, then just follow the instructions. ## Next steps -In this link you can see a list of features that have been confirmed for future -updates: +In this link you can see a list of features that have been confirmed for future updates: Next steps ⏭️ @@ -156,11 +132,7 @@ updates: ## Sponsors -🙏 Thank you to the incredible sponsors for supporting this project! Your -contributions help keep PG Back Web running and growing. If you'd like to join -and become a sponsor, please visit the -[sponsorship page](https://buymeacoffee.com/eduardolat) and be part of something -great! 🚀 +🙏 Thank you to the incredible sponsors for supporting this project! Your contributions help keep PG Back Web running and growing. If you'd like to join and become a sponsor, please visit the [sponsorship page](https://buymeacoffee.com/eduardolat) and be part of something great! 🚀 ### 🥇 Gold Sponsors @@ -213,8 +185,7 @@ great! 🚀 ## Join the Community -Got ideas to improve PG Back Web? Contribute to the project! Every suggestion -and pull request is welcome. +Got ideas to improve PG Back Web? Contribute to the project! Every suggestion and pull request is welcome. ## License @@ -222,6 +193,4 @@ This project is 100% open source and is licensed under the AGPL v3 License - see --- -💖 **Love PG Back Web?** Give us a ⭐ on GitHub and share the project with your -colleagues. Together, we can make PostgreSQL backups more accessible to -everyone! +💖 **Love PG Back Web?** Give us a ⭐ on GitHub and share the project with your colleagues. Together, we can make PostgreSQL backups more accessible to everyone! From d93b5dbb8634c72c1457567f2507921e62fb0181 Mon Sep 17 00:00:00 2001 From: Luis Eduardo Date: Sun, 5 Oct 2025 19:18:29 +0000 Subject: [PATCH 13/27] Refactor path prefix handling in router and main functions --- cmd/app/main.go | 3 +-- internal/view/router.go | 6 +++--- 2 files changed, 4 insertions(+), 5 deletions(-) diff --git a/cmd/app/main.go b/cmd/app/main.go index 322952e5..067f23ca 100644 --- a/cmd/app/main.go +++ b/cmd/app/main.go @@ -19,7 +19,6 @@ func main() { logger.FatalError("error getting environment variables", logger.KV{"error": err}) } - // Initialize the path prefix utility pathutil.SetPathPrefix(env.PBW_PATH_PREFIX) cr, err := cron.New() @@ -48,7 +47,7 @@ func main() { app := echo.New() app.HideBanner = true app.HidePort = true - view.MountRouter(app, servs, env) + view.MountRouter(app, servs) address := env.PBW_LISTEN_HOST + ":" + env.PBW_LISTEN_PORT logger.Info("server started at http://localhost:"+env.PBW_LISTEN_PORT, logger.KV{ diff --git a/internal/view/router.go b/internal/view/router.go index ba69de1b..9af5d15a 100644 --- a/internal/view/router.go +++ b/internal/view/router.go @@ -4,9 +4,9 @@ import ( "io/fs" "time" - "github.com/eduardolat/pgbackweb/internal/config" "github.com/eduardolat/pgbackweb/internal/logger" "github.com/eduardolat/pgbackweb/internal/service" + "github.com/eduardolat/pgbackweb/internal/util/pathutil" "github.com/eduardolat/pgbackweb/internal/view/api" "github.com/eduardolat/pgbackweb/internal/view/middleware" "github.com/eduardolat/pgbackweb/internal/view/static" @@ -14,11 +14,11 @@ import ( "github.com/labstack/echo/v4" ) -func MountRouter(app *echo.Echo, servs *service.Service, env config.Env) { +func MountRouter(app *echo.Echo, servs *service.Service) { mids := middleware.New(servs) // Create the base group with the path prefix (if any) - baseGroup := app.Group(env.PBW_PATH_PREFIX) + baseGroup := app.Group(pathutil.GetPathPrefix()) browserCache := mids.NewBrowserCacheMiddleware( middleware.BrowserCacheMiddlewareConfig{ From 6a44880f028e992e93e5566700874797c0e51e59 Mon Sep 17 00:00:00 2001 From: Luis Eduardo Date: Sun, 5 Oct 2025 19:19:30 +0000 Subject: [PATCH 14/27] Add pathutil import to backups and webhooks packages --- internal/view/web/dashboard/backups/list_backups.go | 1 + internal/view/web/dashboard/webhooks/list_webhooks.go | 1 + internal/view/web/dashboard/webhooks/webhook_executions.go | 1 + 3 files changed, 3 insertions(+) diff --git a/internal/view/web/dashboard/backups/list_backups.go b/internal/view/web/dashboard/backups/list_backups.go index 675590c7..b664b565 100644 --- a/internal/view/web/dashboard/backups/list_backups.go +++ b/internal/view/web/dashboard/backups/list_backups.go @@ -8,6 +8,7 @@ import ( "github.com/eduardolat/pgbackweb/internal/service/backups" "github.com/eduardolat/pgbackweb/internal/util/echoutil" "github.com/eduardolat/pgbackweb/internal/util/paginateutil" + "github.com/eduardolat/pgbackweb/internal/util/pathutil" "github.com/eduardolat/pgbackweb/internal/util/timeutil" "github.com/eduardolat/pgbackweb/internal/validate" "github.com/eduardolat/pgbackweb/internal/view/web/component" diff --git a/internal/view/web/dashboard/webhooks/list_webhooks.go b/internal/view/web/dashboard/webhooks/list_webhooks.go index c7b05041..abb04277 100644 --- a/internal/view/web/dashboard/webhooks/list_webhooks.go +++ b/internal/view/web/dashboard/webhooks/list_webhooks.go @@ -8,6 +8,7 @@ import ( "github.com/eduardolat/pgbackweb/internal/service/webhooks" "github.com/eduardolat/pgbackweb/internal/util/echoutil" "github.com/eduardolat/pgbackweb/internal/util/paginateutil" + "github.com/eduardolat/pgbackweb/internal/util/pathutil" "github.com/eduardolat/pgbackweb/internal/util/strutil" "github.com/eduardolat/pgbackweb/internal/util/timeutil" "github.com/eduardolat/pgbackweb/internal/validate" diff --git a/internal/view/web/dashboard/webhooks/webhook_executions.go b/internal/view/web/dashboard/webhooks/webhook_executions.go index 1d8cdaec..c3812cd5 100644 --- a/internal/view/web/dashboard/webhooks/webhook_executions.go +++ b/internal/view/web/dashboard/webhooks/webhook_executions.go @@ -9,6 +9,7 @@ import ( "github.com/eduardolat/pgbackweb/internal/service/webhooks" "github.com/eduardolat/pgbackweb/internal/util/echoutil" "github.com/eduardolat/pgbackweb/internal/util/paginateutil" + "github.com/eduardolat/pgbackweb/internal/util/pathutil" "github.com/eduardolat/pgbackweb/internal/util/strutil" "github.com/eduardolat/pgbackweb/internal/util/timeutil" "github.com/eduardolat/pgbackweb/internal/validate" From eeabd31f278ca143621d2a158562618e362b30a4 Mon Sep 17 00:00:00 2001 From: Luis Eduardo Date: Sun, 5 Oct 2025 19:23:54 +0000 Subject: [PATCH 15/27] Refactor prefixImagePath function to clarify path validation logic --- internal/view/web/component/support_project.inc.js | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/internal/view/web/component/support_project.inc.js b/internal/view/web/component/support_project.inc.js index c42173a1..78c63f5a 100644 --- a/internal/view/web/component/support_project.inc.js +++ b/internal/view/web/component/support_project.inc.js @@ -56,8 +56,14 @@ window.alpineSupportProjectData = function () { }, prefixImagePath(path) { - // If the path starts with / and doesn't start with http, add the path prefix - if (path && path.startsWith("/") && !path.startsWith("http")) { + // If the path starts with / and is not an absolute URL, add the path prefix + if ( + path && + path.startsWith("/") && + !path.startsWith("//") && + !path.startsWith("http://") && + !path.startsWith("https://") + ) { return window.PBW_PATH_PREFIX + path; } return path; From a4adc07be7325eb3dd8640dc74ba612e14871e68 Mon Sep 17 00:00:00 2001 From: Luis Eduardo Date: Sun, 5 Oct 2025 22:01:00 +0000 Subject: [PATCH 16/27] Inject path prefix as a global JavaScript variable in commonHead function --- internal/view/web/layout/common.go | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/internal/view/web/layout/common.go b/internal/view/web/layout/common.go index 81fa70ca..0c52157b 100644 --- a/internal/view/web/layout/common.go +++ b/internal/view/web/layout/common.go @@ -1,6 +1,7 @@ package layout import ( + "github.com/eduardolat/pgbackweb/internal/util/pathutil" "github.com/eduardolat/pgbackweb/internal/view/static" nodx "github.com/nodxdev/nodxgo" ) @@ -32,6 +33,9 @@ func commonHead() nodx.Node { nodx.Meta(nodx.Charset("utf-8")), nodx.Meta(nodx.Name("viewport"), nodx.Content("width=device-width, initial-scale=1")), + // Inject path prefix as global JavaScript variable + nodx.Script(nodx.Rawf("window.PBW_PATH_PREFIX = '%s';", pathutil.GetPathPrefix())), + // https://htmx.org/quirks/ nodx.Meta(nodx.Name("htmx-config"), nodx.Content(`{"disableInheritance":true, "responseHandling": [{"code":"...", "swap": true}]}`)), From 6a8784daf2733fa94c60dab9ea89b76fa0f88e02 Mon Sep 17 00:00:00 2001 From: Luis Eduardo Date: Sun, 5 Oct 2025 23:09:22 +0000 Subject: [PATCH 17/27] Remove SweetAlert2 script from common layout and update Tailwind CSS configuration to include init-dialogs.js for content scanning --- .prettierignore | 1 + .../view/static/css/partials/sweetalert2.css | 9 -- internal/view/static/css/style.css | 1 - internal/view/static/js/app.js | 4 +- internal/view/static/js/init-dialogs.js | 137 ++++++++++++++++++ internal/view/static/js/init-htmx.js | 2 +- internal/view/static/js/init-sweetalert2.js | 34 ----- .../sweetalert2/sweetalert2-11.13.1.min.js | 2 - internal/view/web/layout/common.go | 1 - tailwind.config.ts | 5 +- 10 files changed, 145 insertions(+), 51 deletions(-) delete mode 100644 internal/view/static/css/partials/sweetalert2.css create mode 100644 internal/view/static/js/init-dialogs.js delete mode 100644 internal/view/static/js/init-sweetalert2.js delete mode 100644 internal/view/static/libs/sweetalert2/sweetalert2-11.13.1.min.js diff --git a/.prettierignore b/.prettierignore index 5d7ceb50..cd0dc420 100644 --- a/.prettierignore +++ b/.prettierignore @@ -1 +1,2 @@ internal/view/static/libs/ +internal/view/static/build/ diff --git a/internal/view/static/css/partials/sweetalert2.css b/internal/view/static/css/partials/sweetalert2.css deleted file mode 100644 index d70426cb..00000000 --- a/internal/view/static/css/partials/sweetalert2.css +++ /dev/null @@ -1,9 +0,0 @@ -/* - Fix sweetalert2 scroll issue - https://github.com/sweetalert2/sweetalert2/issues/781#issuecomment-475108658 -*/ -body.swal2-shown:not(.swal2-no-backdrop):not(.swal2-toast-shown), -html.swal2-shown:not(.swal2-no-backdrop):not(.swal2-toast-shown) { - height: 100% !important; - overflow-y: visible !important; -} diff --git a/internal/view/static/css/style.css b/internal/view/static/css/style.css index 6832e346..4687a8a8 100644 --- a/internal/view/static/css/style.css +++ b/internal/view/static/css/style.css @@ -6,4 +6,3 @@ @import "./partials/slim-select.css"; @import "./partials/notyf.css"; @import "./partials/scrollbar.css"; -@import "./partials/sweetalert2.css"; diff --git a/internal/view/static/js/app.js b/internal/view/static/js/app.js index e7c2b60c..435cf67a 100644 --- a/internal/view/static/js/app.js +++ b/internal/view/static/js/app.js @@ -1,11 +1,11 @@ import { initThemeHelper } from "./init-theme-helper.js"; -import { initSweetAlert2 } from "./init-sweetalert2.js"; +import { initDialogs } from "./init-dialogs.js"; import { initNotyf } from "./init-notyf.js"; import { initHTMX } from "./init-htmx.js"; import { initHelpers } from "./init-helpers.js"; initThemeHelper(); -initSweetAlert2(); +initDialogs(); initNotyf(); initHTMX(); initHelpers(); diff --git a/internal/view/static/js/init-dialogs.js b/internal/view/static/js/init-dialogs.js new file mode 100644 index 00000000..3d5e6885 --- /dev/null +++ b/internal/view/static/js/init-dialogs.js @@ -0,0 +1,137 @@ +export function initDialogs() { + /** + * Shows an alert dialog + * @param {string} text - The text to display + * @returns {Promise<{isConfirmed: boolean, isDismissed: boolean}>} + */ + async function swalAlert(text) { + return showDialog(text, false); + } + + /** + * Shows a confirmation dialog + * @param {string} text - The text to display + * @returns {Promise<{isConfirmed: boolean, isDismissed: boolean}>} + */ + async function swalConfirm(text) { + return showDialog(text, true); + } + + /** + * Shows a dialog + * @param {string} text - The text to display + * @param {boolean} isConfirm - True for confirm dialog, false for alert + */ + function showDialog(text, isConfirm) { + return new Promise((resolve) => { + const dialogId = "dialog-" + Date.now(); + const container = createDialog(dialogId, text, isConfirm, resolve); + document.body.appendChild(container); + + // Fade in + requestAnimationFrame(() => { + container.style.opacity = "0"; + container.classList.remove("hidden"); + requestAnimationFrame(() => { + container.style.transition = "opacity 0.15s ease-in-out"; + container.style.opacity = "1"; + }); + }); + }); + } + + /** + * Creates the dialog HTML + */ + function createDialog(dialogId, text, isConfirm, resolve) { + // Container + const container = document.createElement("div"); + container.id = dialogId; + container.className = + "hidden !p-0 !m-0 w-[100dvw] h-[100dvh] fixed left-0 top-0 z-[1000]"; + + // Backdrop + const backdrop = document.createElement("div"); + backdrop.className = "bg-black opacity-25 !w-full !h-full z-[1001]"; + backdrop.onclick = () => closeDialog(dialogId, resolve, !isConfirm); + + // Dialog box + const dialogBox = document.createElement("div"); + dialogBox.className = + "absolute z-[1002] top-[50%] left-[50%] translate-y-[-50%] translate-x-[-50%] " + + "max-w-[calc(100dvw-30px)] bg-base-100 rounded-box p-6 w-[400px] shadow-xl"; + + // Icon + const iconPath = isConfirm + ? "M8.228 9c.549-1.165 2.03-2 3.772-2 2.21 0 4 1.343 4 3 0 1.4-1.278 2.575-3.006 2.907-.542.104-.994.54-.994 1.093m0 3h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" + : "M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"; + const iconColor = isConfirm ? "text-warning" : "text-info"; + + dialogBox.innerHTML = ` +

+
${text}
+
+ ${ + isConfirm + ? '' + : '' + } +
+ `; + + // Button event listeners + const buttons = dialogBox.querySelectorAll("button"); + buttons.forEach((btn) => { + btn.onclick = () => { + const action = btn.getAttribute("data-action"); + const confirmed = action === "confirm" || action === "ok"; + closeDialog(dialogId, resolve, confirmed); + }; + }); + + // Autofocus + setTimeout(() => { + const focusSelector = isConfirm + ? '[data-action="cancel"]' + : '[data-action="ok"]'; + dialogBox.querySelector(focusSelector)?.focus(); + }, 150); + + container.appendChild(backdrop); + container.appendChild(dialogBox); + + // ESC key handler + const handleEsc = (e) => { + if (e.key === "Escape") { + closeDialog(dialogId, resolve, !isConfirm); + document.removeEventListener("keydown", handleEsc); + } + }; + document.addEventListener("keydown", handleEsc); + + return container; + } + + /** + * Closes the dialog with fade out + */ + function closeDialog(dialogId, resolve, confirmed) { + const dialog = document.getElementById(dialogId); + if (dialog) { + dialog.style.opacity = "0"; + setTimeout(() => { + dialog.remove(); + resolve({ isConfirmed: confirmed, isDismissed: !confirmed }); + }, 150); + } else { + resolve({ isConfirmed: confirmed, isDismissed: !confirmed }); + } + } + + window.swalAlert = swalAlert; + window.swalConfirm = swalConfirm; +} diff --git a/internal/view/static/js/init-htmx.js b/internal/view/static/js/init-htmx.js index d79269fc..b75090f6 100644 --- a/internal/view/static/js/init-htmx.js +++ b/internal/view/static/js/init-htmx.js @@ -45,7 +45,7 @@ export function initHTMX() { document.addEventListener(key, triggers[key]); } - // Add trigger to use sweetalert2 for confirms + // Add trigger to use custom dialogs for confirms document.addEventListener("htmx:confirm", function (e) { if (!e.detail.target.hasAttribute("hx-confirm")) return; diff --git a/internal/view/static/js/init-sweetalert2.js b/internal/view/static/js/init-sweetalert2.js deleted file mode 100644 index 2064101f..00000000 --- a/internal/view/static/js/init-sweetalert2.js +++ /dev/null @@ -1,34 +0,0 @@ -export function initSweetAlert2() { - // Docs at https://sweetalert2.github.io/#configuration - const defaultConfig = { - icon: "info", - confirmButtonText: "Okay", - cancelButtonText: "Cancel", - customClass: { - popup: "rounded-box bg-base-100 text-base-content", - confirmButton: "btn btn-primary", - denyButton: "btn btn-warning", - cancelButton: "btn btn-error", - }, - }; - - async function swalAlert(text) { - return await Swal.fire({ - ...defaultConfig, - title: text, - }); - } - - async function swalConfirm(text) { - return await Swal.fire({ - ...defaultConfig, - icon: "question", - title: text, - confirmButtonText: "Confirm", - showCancelButton: true, - }); - } - - window.swalAlert = swalAlert; - window.swalConfirm = swalConfirm; -} diff --git a/internal/view/static/libs/sweetalert2/sweetalert2-11.13.1.min.js b/internal/view/static/libs/sweetalert2/sweetalert2-11.13.1.min.js deleted file mode 100644 index aad60d2d..00000000 --- a/internal/view/static/libs/sweetalert2/sweetalert2-11.13.1.min.js +++ /dev/null @@ -1,2 +0,0 @@ -!function(t,e){"object"==typeof exports&&"undefined"!=typeof module?module.exports=e():"function"==typeof define&&define.amd?define(e):(t="undefined"!=typeof globalThis?globalThis:t||self).Sweetalert2=e()}(this,(function(){"use strict";function t(t,e){(null==e||e>t.length)&&(e=t.length);for(var n=0,o=Array(e);n1&&void 0!==arguments[1]?arguments[1]:null;e='"'.concat(t,'" is deprecated and will be removed in the next major release.').concat(n?' Use "'.concat(n,'" instead.'):""),B.includes(e)||(B.push(e),k(e))},T=function(t){return"function"==typeof t?t():t},x=function(t){return t&&"function"==typeof t.toPromise},S=function(t){return x(t)?t.toPromise():Promise.resolve(t)},L=function(t){return t&&Promise.resolve(t)===t},O=function(){return document.body.querySelector(".".concat(b.container))},j=function(t){var e=O();return e?e.querySelector(t):null},M=function(t){return j(".".concat(t))},I=function(){return M(b.popup)},H=function(){return M(b.icon)},D=function(){return M(b.title)},q=function(){return M(b["html-container"])},V=function(){return M(b.image)},_=function(){return M(b["progress-steps"])},R=function(){return M(b["validation-message"])},N=function(){return j(".".concat(b.actions," .").concat(b.confirm))},F=function(){return j(".".concat(b.actions," .").concat(b.cancel))},U=function(){return j(".".concat(b.actions," .").concat(b.deny))},z=function(){return j(".".concat(b.loader))},K=function(){return M(b.actions)},W=function(){return M(b.footer)},Y=function(){return M(b["timer-progress-bar"])},Z=function(){return M(b.close)},$=function(){var t=I();if(!t)return[];var e=t.querySelectorAll('[tabindex]:not([tabindex="-1"]):not([tabindex="0"])'),n=Array.from(e).sort((function(t,e){var n=parseInt(t.getAttribute("tabindex")||"0"),o=parseInt(e.getAttribute("tabindex")||"0");return n>o?1:n .").concat(b[e]));case"checkbox":return t.querySelector(".".concat(b.popup," > .").concat(b.checkbox," input"));case"radio":return t.querySelector(".".concat(b.popup," > .").concat(b.radio," input:checked"))||t.querySelector(".".concat(b.popup," > .").concat(b.radio," input:first-child"));case"range":return t.querySelector(".".concat(b.popup," > .").concat(b.range," input"));default:return t.querySelector(".".concat(b.popup," > .").concat(b.input))}},nt=function(t){if(t.focus(),"file"!==t.type){var e=t.value;t.value="",t.value=e}},ot=function(t,e,n){t&&e&&("string"==typeof e&&(e=e.split(/\s+/).filter(Boolean)),e.forEach((function(e){Array.isArray(t)?t.forEach((function(t){n?t.classList.add(e):t.classList.remove(e)})):n?t.classList.add(e):t.classList.remove(e)})))},it=function(t,e){ot(t,e,!0)},rt=function(t,e){ot(t,e,!1)},at=function(t,e){for(var n=Array.from(t.children),o=0;o1&&void 0!==arguments[1]?arguments[1]:"flex";t&&(t.style.display=e)},st=function(t){t&&(t.style.display="none")},lt=function(t){var e=arguments.length>1&&void 0!==arguments[1]?arguments[1]:"block";t&&new MutationObserver((function(){ft(t,t.innerHTML,e)})).observe(t,{childList:!0,subtree:!0})},dt=function(t,e,n,o){var i=t.querySelector(e);i&&i.style.setProperty(n,o)},ft=function(t,e){e?ut(t,arguments.length>2&&void 0!==arguments[2]?arguments[2]:"flex"):st(t)},pt=function(t){return!(!t||!(t.offsetWidth||t.offsetHeight||t.getClientRects().length))},mt=function(t){return!!(t.scrollHeight>t.clientHeight)},vt=function(t){var e=window.getComputedStyle(t),n=parseFloat(e.getPropertyValue("animation-duration")||"0"),o=parseFloat(e.getPropertyValue("transition-duration")||"0");return n>0||o>0},ht=function(t){var e=arguments.length>1&&void 0!==arguments[1]&&arguments[1],n=Y();n&&pt(n)&&(e&&(n.style.transition="none",n.style.width="100%"),setTimeout((function(){n.style.transition="width ".concat(t/1e3,"s linear"),n.style.width="0%"}),10))},gt=function(){return"undefined"==typeof window||"undefined"==typeof document},yt='\n
\n \n
    \n
    \n \n

    \n
    \n \n \n
    \n \n \n
    \n \n
    \n \n \n
    \n
    \n
    \n \n \n \n
    \n
    \n
    \n
    \n
    \n
    \n').replace(/(^|\n)\s*/g,""),bt=function(){h.currentInstance.resetValidationMessage()},wt=function(t){var e,n=!!(e=O())&&(e.remove(),rt([document.documentElement,document.body],[b["no-backdrop"],b["toast-shown"],b["has-column"]]),!0);if(gt())E("SweetAlert2 requires document to initialize");else{var o=document.createElement("div");o.className=b.container,n&&it(o,b["no-transition"]),G(o,yt);var i,r,a,c,u,s,l,d,f,p="string"==typeof(i=t.target)?document.querySelector(i):i;p.appendChild(o),function(t){var e=I();e.setAttribute("role",t.toast?"alert":"dialog"),e.setAttribute("aria-live",t.toast?"polite":"assertive"),t.toast||e.setAttribute("aria-modal","true")}(t),function(t){"rtl"===window.getComputedStyle(t).direction&&it(O(),b.rtl)}(p),r=I(),a=at(r,b.input),c=at(r,b.file),u=r.querySelector(".".concat(b.range," input")),s=r.querySelector(".".concat(b.range," output")),l=at(r,b.select),d=r.querySelector(".".concat(b.checkbox," input")),f=at(r,b.textarea),a.oninput=bt,c.onchange=bt,l.onchange=bt,d.onchange=bt,f.oninput=bt,u.oninput=function(){bt(),s.value=u.value},u.onchange=function(){bt(),s.value=u.value}}},Ct=function(t,e){t instanceof HTMLElement?e.appendChild(t):"object"===m(t)?At(t,e):t&&G(e,t)},At=function(t,e){t.jquery?kt(e,t):G(e,t.toString())},kt=function(t,e){if(t.textContent="",0 in e)for(var n=0;n in e;n++)t.appendChild(e[n].cloneNode(!0));else t.appendChild(e.cloneNode(!0))},Et=function(){if(gt())return!1;var t=document.createElement("div");return void 0!==t.style.webkitAnimation?"webkitAnimationEnd":void 0!==t.style.animation&&"animationend"}(),Bt=function(t,e){var n=K(),o=z();n&&o&&(e.showConfirmButton||e.showDenyButton||e.showCancelButton?ut(n):st(n),tt(n,e,"actions"),function(t,e,n){var o=N(),i=U(),r=F();if(!o||!i||!r)return;Pt(o,"confirm",n),Pt(i,"deny",n),Pt(r,"cancel",n),function(t,e,n,o){if(!o.buttonsStyling)return void rt([t,e,n],b.styled);it([t,e,n],b.styled),o.confirmButtonColor&&(t.style.backgroundColor=o.confirmButtonColor,it(t,b["default-outline"]));o.denyButtonColor&&(e.style.backgroundColor=o.denyButtonColor,it(e,b["default-outline"]));o.cancelButtonColor&&(n.style.backgroundColor=o.cancelButtonColor,it(n,b["default-outline"]))}(o,i,r,n),n.reverseButtons&&(n.toast?(t.insertBefore(r,o),t.insertBefore(i,o)):(t.insertBefore(r,e),t.insertBefore(i,e),t.insertBefore(o,e)))}(n,o,e),G(o,e.loaderHtml||""),tt(o,e,"loader"))};function Pt(t,e,n){var o=A(e);ft(t,n["show".concat(o,"Button")],"inline-block"),G(t,n["".concat(e,"ButtonText")]||""),t.setAttribute("aria-label",n["".concat(e,"ButtonAriaLabel")]||""),t.className=b[e],tt(t,n,"".concat(e,"Button"))}var Tt=function(t,e){var n=O();n&&(!function(t,e){"string"==typeof e?t.style.background=e:e||it([document.documentElement,document.body],b["no-backdrop"])}(n,e.backdrop),function(t,e){if(!e)return;e in b?it(t,b[e]):(k('The "position" parameter is not valid, defaulting to "center"'),it(t,b.center))}(n,e.position),function(t,e){if(!e)return;it(t,b["grow-".concat(e)])}(n,e.grow),tt(n,e,"container"))};var xt={innerParams:new WeakMap,domCache:new WeakMap},St=["input","file","range","select","radio","checkbox","textarea"],Lt=function(t){if(t.input)if(qt[t.input]){var e=Ht(t.input);if(e){var n=qt[t.input](e,t);ut(e),t.inputAutoFocus&&setTimeout((function(){nt(n)}))}}else E("Unexpected type of input! Expected ".concat(Object.keys(qt).join(" | "),', got "').concat(t.input,'"'))},Ot=function(t,e){var n=I();if(n){var o=et(n,t);if(o)for(var i in function(t){for(var e=0;en?I().style.width="".concat(i,"px"):ct(I(),"width",e.width)}})).observe(t,{attributes:!0,attributeFilter:["style"]})}})),t};var Vt=function(t,e){var n=q();n&&(lt(n),tt(n,e,"htmlContainer"),e.html?(Ct(e.html,n),ut(n,"block")):e.text?(n.textContent=e.text,ut(n,"block")):st(n),function(t,e){var n=I();if(n){var o=xt.innerParams.get(t),i=!o||e.input!==o.input;St.forEach((function(t){var o=at(n,b[t]);o&&(Ot(t,e.inputAttributes),o.className=b[t],i&&st(o))})),e.input&&(i&&Lt(e),jt(e))}}(t,e))},_t=function(t,e){for(var n=0,o=Object.entries(w);n\n \n
    \n
    \n',n=n.replace(/ style=".*?"/g,"");else if("error"===e.icon)o='\n \n \n \n \n';else if(e.icon){o=Ut({question:"?",warning:"!",info:"i"}[e.icon])}n.trim()!==o.trim()&&G(t,o)}},Ft=function(t,e){if(e.iconColor){t.style.color=e.iconColor,t.style.borderColor=e.iconColor;for(var n=0,o=[".swal2-success-line-tip",".swal2-success-line-long",".swal2-x-mark-line-left",".swal2-x-mark-line-right"];n').concat(t,"")},zt=function(t,e){var n=e.showClass||{};t.className="".concat(b.popup," ").concat(pt(t)?n.popup:""),e.toast?(it([document.documentElement,document.body],b["toast-shown"]),it(t,b.toast)):it(t,b.modal),tt(t,e,"popup"),"string"==typeof e.customClass&&it(t,e.customClass),e.icon&&it(t,b["icon-".concat(e.icon)])},Kt=function(t){var e=document.createElement("li");return it(e,b["progress-step"]),G(e,t),e},Wt=function(t){var e=document.createElement("li");return it(e,b["progress-step-line"]),t.progressStepsDistance&&ct(e,"width",t.progressStepsDistance),e},Yt=function(t,e){!function(t,e){var n=O(),o=I();if(n&&o){if(e.toast){ct(n,"width",e.width),o.style.width="100%";var i=z();i&&o.insertBefore(i,H())}else ct(o,"width",e.width);ct(o,"padding",e.padding),e.color&&(o.style.color=e.color),e.background&&(o.style.background=e.background),st(R()),zt(o,e)}}(0,e),Tt(0,e),function(t,e){var n=_();if(n){var o=e.progressSteps,i=e.currentProgressStep;o&&0!==o.length&&void 0!==i?(ut(n),n.textContent="",i>=o.length&&k("Invalid currentProgressStep parameter, it should be less than progressSteps.length (currentProgressStep like JS arrays starts from 0)"),o.forEach((function(t,r){var a=Kt(t);if(n.appendChild(a),r===i&&it(a,b["active-progress-step"]),r!==o.length-1){var c=Wt(e);n.appendChild(c)}}))):st(n)}}(0,e),function(t,e){var n=xt.innerParams.get(t),o=H();if(o){if(n&&e.icon===n.icon)return Nt(o,e),void _t(o,e);if(e.icon||e.iconHtml){if(e.icon&&-1===Object.keys(w).indexOf(e.icon))return E('Unknown icon! Expected "success", "error", "warning", "info" or "question", got "'.concat(e.icon,'"')),void st(o);ut(o),Nt(o,e),_t(o,e),it(o,e.showClass&&e.showClass.icon)}else st(o)}}(t,e),function(t,e){var n=V();n&&(e.imageUrl?(ut(n,""),n.setAttribute("src",e.imageUrl),n.setAttribute("alt",e.imageAlt||""),ct(n,"width",e.imageWidth),ct(n,"height",e.imageHeight),n.className=b.image,tt(n,e,"image")):st(n))}(0,e),function(t,e){var n=D();n&&(lt(n),ft(n,e.title||e.titleText,"block"),e.title&&Ct(e.title,n),e.titleText&&(n.innerText=e.titleText),tt(n,e,"title"))}(0,e),function(t,e){var n=Z();n&&(G(n,e.closeButtonHtml||""),tt(n,e,"closeButton"),ft(n,e.showCloseButton),n.setAttribute("aria-label",e.closeButtonAriaLabel||""))}(0,e),Vt(t,e),Bt(0,e),function(t,e){var n=W();n&&(lt(n),ft(n,e.footer,"block"),e.footer&&Ct(e.footer,n),tt(n,e,"footer"))}(0,e);var n=I();"function"==typeof e.didRender&&n&&e.didRender(n)},Zt=function(){var t;return null===(t=N())||void 0===t?void 0:t.click()},$t=Object.freeze({cancel:"cancel",backdrop:"backdrop",close:"close",esc:"esc",timer:"timer"}),Jt=function(t){t.keydownTarget&&t.keydownHandlerAdded&&(t.keydownTarget.removeEventListener("keydown",t.keydownHandler,{capture:t.keydownListenerCapture}),t.keydownHandlerAdded=!1)},Xt=function(t,e){var n,o=$();if(o.length)return(t+=e)===o.length?t=0:-1===t&&(t=o.length-1),void o[t].focus();null===(n=I())||void 0===n||n.focus()},Gt=["ArrowRight","ArrowDown"],Qt=["ArrowLeft","ArrowUp"],te=function(t,e,n){t&&(e.isComposing||229===e.keyCode||(t.stopKeydownPropagation&&e.stopPropagation(),"Enter"===e.key?ee(e,t):"Tab"===e.key?ne(e):[].concat(Gt,Qt).includes(e.key)?oe(e.key):"Escape"===e.key&&ie(e,t,n)))},ee=function(t,e){if(T(e.allowEnterKey)){var n=et(I(),e.input);if(t.target&&n&&t.target instanceof HTMLElement&&t.target.outerHTML===n.outerHTML){if(["textarea","file"].includes(e.input))return;Zt(),t.preventDefault()}}},ne=function(t){for(var e=t.target,n=$(),o=-1,i=0;i1},fe=null,pe=function(t){null===fe&&(document.body.scrollHeight>window.innerHeight||"scroll"===t)&&(fe=parseInt(window.getComputedStyle(document.body).getPropertyValue("padding-right")),document.body.style.paddingRight="".concat(fe+function(){var t=document.createElement("div");t.className=b["scrollbar-measure"],document.body.appendChild(t);var e=t.getBoundingClientRect().width-t.clientWidth;return document.body.removeChild(t),e}(),"px"))};function me(t,e,n,o){X()?Ae(t,o):(g(n).then((function(){return Ae(t,o)})),Jt(h)),ce?(e.setAttribute("style","display:none !important"),e.removeAttribute("class"),e.innerHTML=""):e.remove(),J()&&(null!==fe&&(document.body.style.paddingRight="".concat(fe,"px"),fe=null),function(){if(Q(document.body,b.iosfix)){var t=parseInt(document.body.style.top,10);rt(document.body,b.iosfix),document.body.style.top="",document.body.scrollTop=-1*t}}(),ae()),rt([document.documentElement,document.body],[b.shown,b["height-auto"],b["no-backdrop"],b["toast-shown"]])}function ve(t){t=be(t);var e=re.swalPromiseResolve.get(this),n=he(this);this.isAwaitingPromise?t.isDismissed||(ye(this),e(t)):n&&e(t)}var he=function(t){var e=I();if(!e)return!1;var n=xt.innerParams.get(t);if(!n||Q(e,n.hideClass.popup))return!1;rt(e,n.showClass.popup),it(e,n.hideClass.popup);var o=O();return rt(o,n.showClass.backdrop),it(o,n.hideClass.backdrop),we(t,e,n),!0};function ge(t){var e=re.swalPromiseReject.get(this);ye(this),e&&e(t)}var ye=function(t){t.isAwaitingPromise&&(delete t.isAwaitingPromise,xt.innerParams.get(t)||t._destroy())},be=function(t){return void 0===t?{isConfirmed:!1,isDenied:!1,isDismissed:!0}:Object.assign({isConfirmed:!1,isDenied:!1,isDismissed:!1},t)},we=function(t,e,n){var o=O(),i=Et&&vt(e);"function"==typeof n.willClose&&n.willClose(e),i?Ce(t,e,o,n.returnFocus,n.didClose):me(t,o,n.returnFocus,n.didClose)},Ce=function(t,e,n,o,i){Et&&(h.swalCloseEventFinishedCallback=me.bind(null,t,n,o,i),e.addEventListener(Et,(function(t){t.target===e&&(h.swalCloseEventFinishedCallback(),delete h.swalCloseEventFinishedCallback)})))},Ae=function(t,e){setTimeout((function(){"function"==typeof e&&e.bind(t.params)(),t._destroy&&t._destroy()}))},ke=function(t){var e=I();if(e||new io,e=I()){var n=z();X()?st(H()):Ee(e,t),ut(n),e.setAttribute("data-loading","true"),e.setAttribute("aria-busy","true"),e.focus()}},Ee=function(t,e){var n=K(),o=z();n&&o&&(!e&&pt(N())&&(e=N()),ut(n),e&&(st(e),o.setAttribute("data-button-to-replace",e.className),n.insertBefore(o,e)),it([t,n],b.loading))},Be=function(t){return t.checked?1:0},Pe=function(t){return t.checked?t.value:null},Te=function(t){return t.files&&t.files.length?null!==t.getAttribute("multiple")?t.files:t.files[0]:null},xe=function(t,e){var n=I();if(n){var o=function(t){"select"===e.input?function(t,e,n){var o=at(t,b.select);if(!o)return;var i=function(t,e,o){var i=document.createElement("option");i.value=o,G(i,e),i.selected=Oe(o,n.inputValue),t.appendChild(i)};e.forEach((function(t){var e=t[0],n=t[1];if(Array.isArray(n)){var r=document.createElement("optgroup");r.label=e,r.disabled=!1,o.appendChild(r),n.forEach((function(t){return i(r,t[1],t[0])}))}else i(o,n,e)})),o.focus()}(n,Le(t),e):"radio"===e.input&&function(t,e,n){var o=at(t,b.radio);if(!o)return;e.forEach((function(t){var e=t[0],i=t[1],r=document.createElement("input"),a=document.createElement("label");r.type="radio",r.name=b.radio,r.value=e,Oe(e,n.inputValue)&&(r.checked=!0);var c=document.createElement("span");G(c,i),c.className=b.label,a.appendChild(r),a.appendChild(c),o.appendChild(a)}));var i=o.querySelectorAll("input");i.length&&i[0].focus()}(n,Le(t),e)};x(e.inputOptions)||L(e.inputOptions)?(ke(N()),S(e.inputOptions).then((function(e){t.hideLoading(),o(e)}))):"object"===m(e.inputOptions)?o(e.inputOptions):E("Unexpected type of inputOptions! Expected object, Map or Promise, got ".concat(m(e.inputOptions)))}},Se=function(t,e){var n=t.getInput();n&&(st(n),S(e.inputValue).then((function(o){n.value="number"===e.input?"".concat(parseFloat(o)||0):"".concat(o),ut(n),n.focus(),t.hideLoading()})).catch((function(e){E("Error in inputValue promise: ".concat(e)),n.value="",ut(n),n.focus(),t.hideLoading()})))};var Le=function(t){var e=[];return t instanceof Map?t.forEach((function(t,n){var o=t;"object"===m(o)&&(o=Le(o)),e.push([n,o])})):Object.keys(t).forEach((function(n){var o=t[n];"object"===m(o)&&(o=Le(o)),e.push([n,o])})),e},Oe=function(t,e){return!!e&&e.toString()===t.toString()},je=void 0,Me=function(t,e){var n=xt.innerParams.get(t);if(n.input){var o=t.getInput(),i=function(t,e){var n=t.getInput();if(!n)return null;switch(e.input){case"checkbox":return Be(n);case"radio":return Pe(n);case"file":return Te(n);default:return e.inputAutoTrim?n.value.trim():n.value}}(t,n);n.inputValidator?Ie(t,i,e):o&&!o.checkValidity()?(t.enableButtons(),t.showValidationMessage(n.validationMessage||o.validationMessage)):"deny"===e?He(t,i):Ve(t,i)}else E('The "input" parameter is needed to be set when using returnInputValueOn'.concat(A(e)))},Ie=function(t,e,n){var o=xt.innerParams.get(t);t.disableInput(),Promise.resolve().then((function(){return S(o.inputValidator(e,o.validationMessage))})).then((function(o){t.enableButtons(),t.enableInput(),o?t.showValidationMessage(o):"deny"===n?He(t,e):Ve(t,e)}))},He=function(t,e){var n=xt.innerParams.get(t||je);(n.showLoaderOnDeny&&ke(U()),n.preDeny)?(t.isAwaitingPromise=!0,Promise.resolve().then((function(){return S(n.preDeny(e,n.validationMessage))})).then((function(n){!1===n?(t.hideLoading(),ye(t)):t.close({isDenied:!0,value:void 0===n?e:n})})).catch((function(e){return qe(t||je,e)}))):t.close({isDenied:!0,value:e})},De=function(t,e){t.close({isConfirmed:!0,value:e})},qe=function(t,e){t.rejectPromise(e)},Ve=function(t,e){var n=xt.innerParams.get(t||je);(n.showLoaderOnConfirm&&ke(),n.preConfirm)?(t.resetValidationMessage(),t.isAwaitingPromise=!0,Promise.resolve().then((function(){return S(n.preConfirm(e,n.validationMessage))})).then((function(n){pt(R())||!1===n?(t.hideLoading(),ye(t)):De(t,void 0===n?e:n)})).catch((function(e){return qe(t||je,e)}))):De(t,e)};function _e(){var t=xt.innerParams.get(this);if(t){var e=xt.domCache.get(this);st(e.loader),X()?t.icon&&ut(H()):Re(e),rt([e.popup,e.actions],b.loading),e.popup.removeAttribute("aria-busy"),e.popup.removeAttribute("data-loading"),e.confirmButton.disabled=!1,e.denyButton.disabled=!1,e.cancelButton.disabled=!1}}var Re=function(t){var e=t.popup.getElementsByClassName(t.loader.getAttribute("data-button-to-replace"));e.length?ut(e[0],"inline-block"):pt(N())||pt(U())||pt(F())||st(t.actions)};function Ne(){var t=xt.innerParams.get(this),e=xt.domCache.get(this);return e?et(e.popup,t.input):null}function Fe(t,e,n){var o=xt.domCache.get(t);e.forEach((function(t){o[t].disabled=n}))}function Ue(t,e){var n=I();if(n&&t)if("radio"===t.type)for(var o=n.querySelectorAll('[name="'.concat(b.radio,'"]')),i=0;i0&&void 0!==arguments[0]?arguments[0]:"data-swal-template"]=this,kn||(document.body.addEventListener("click",Pn),kn=!0)},clickCancel:function(){var t;return null===(t=F())||void 0===t?void 0:t.click()},clickConfirm:Zt,clickDeny:function(){var t;return null===(t=U())||void 0===t?void 0:t.click()},enableLoading:ke,fire:function(){for(var t=arguments.length,e=new Array(t),n=0;n"))}))},Vn=function(t,e){Array.from(t.attributes).forEach((function(n){-1===e.indexOf(n.name)&&k(['Unrecognized attribute "'.concat(n.name,'" on <').concat(t.tagName.toLowerCase(),">."),"".concat(e.length?"Allowed attributes are: ".concat(e.join(", ")):"To set the value, use HTML within the element.")])}))},_n=function(t){var e=O(),n=I();"function"==typeof t.willOpen&&t.willOpen(n);var o=window.getComputedStyle(document.body).overflowY;Un(e,n,t),setTimeout((function(){Nn(e,n)}),10),J()&&(Fn(e,t.scrollbarPadding,o),function(){var t=O();Array.from(document.body.children).forEach((function(e){e.contains(t)||(e.hasAttribute("aria-hidden")&&e.setAttribute("data-previous-aria-hidden",e.getAttribute("aria-hidden")||""),e.setAttribute("aria-hidden","true"))}))}()),X()||h.previousActiveElement||(h.previousActiveElement=document.activeElement),"function"==typeof t.didOpen&&setTimeout((function(){return t.didOpen(n)})),rt(e,b["no-transition"])},Rn=function(t){var e=I();if(t.target===e&&Et){var n=O();e.removeEventListener(Et,Rn),n.style.overflowY="auto"}},Nn=function(t,e){Et&&vt(e)?(t.style.overflowY="hidden",e.addEventListener(Et,Rn)):t.style.overflowY="auto"},Fn=function(t,e,n){!function(){if(ce&&!Q(document.body,b.iosfix)){var t=document.body.scrollTop;document.body.style.top="".concat(-1*t,"px"),it(document.body,b.iosfix),ue()}}(),e&&"hidden"!==n&&pe(n),setTimeout((function(){t.scrollTop=0}))},Un=function(t,e,n){it(t,n.showClass.backdrop),n.animation?(e.style.setProperty("opacity","0","important"),ut(e,"grid"),setTimeout((function(){it(e,n.showClass.popup),e.style.removeProperty("opacity")}),10)):ut(e,"grid"),it([document.documentElement,document.body],b.shown),n.heightAuto&&n.backdrop&&!n.toast&&it([document.documentElement,document.body],b["height-auto"])},zn={email:function(t,e){return/^[a-zA-Z0-9.+_'-]+@[a-zA-Z0-9.-]+\.[a-zA-Z0-9-]+$/.test(t)?Promise.resolve():Promise.resolve(e||"Invalid email address")},url:function(t,e){return/^https?:\/\/(www\.)?[-a-zA-Z0-9@:%._+~#=]{1,256}\.[a-z]{2,63}\b([-a-zA-Z0-9@:%_+.~#?&/=]*)$/.test(t)?Promise.resolve():Promise.resolve(e||"Invalid URL")}};function Kn(t){!function(t){t.inputValidator||("email"===t.input&&(t.inputValidator=zn.email),"url"===t.input&&(t.inputValidator=zn.url))}(t),t.showLoaderOnConfirm&&!t.preConfirm&&k("showLoaderOnConfirm is set to true, but preConfirm is not defined.\nshowLoaderOnConfirm should be used together with preConfirm, see usage example:\nhttps://sweetalert2.github.io/#ajax-request"),function(t){(!t.target||"string"==typeof t.target&&!document.querySelector(t.target)||"string"!=typeof t.target&&!t.target.appendChild)&&(k('Target parameter is not valid, defaulting to "body"'),t.target="body")}(t),"string"==typeof t.title&&(t.title=t.title.split("\n").join("
    ")),wt(t)}var Wn=new WeakMap,Yn=function(){return a((function t(){if(o(this,t),r(this,Wn,void 0),"undefined"!=typeof window){Bn=this;for(var n=arguments.length,i=new Array(n),a=0;a1&&void 0!==arguments[1]?arguments[1]:{};if(function(t){for(var e in!1===t.backdrop&&t.allowOutsideClick&&k('"allowOutsideClick" parameter requires `backdrop` parameter to be set to `true`'),t)on(e),t.toast&&rn(e),an(e)}(Object.assign({},e,t)),h.currentInstance){var n=re.swalPromiseResolve.get(h.currentInstance),o=h.currentInstance.isAwaitingPromise;h.currentInstance._destroy(),o||n({isDismissed:!0}),J()&&ae()}h.currentInstance=Bn;var i=$n(t,e);Kn(i),Object.freeze(i),h.timeout&&(h.timeout.stop(),delete h.timeout),clearTimeout(h.restoreFocusTimeout);var r=Jn(Bn);return Yt(Bn,i),xt.innerParams.set(Bn,i),Zn(Bn,r,i)}},{key:"then",value:function(t){return i(Wn,this).then(t)}},{key:"finally",value:function(t){return i(Wn,this).finally(t)}}])}(),Zn=function(t,e,n){return new Promise((function(o,i){var r=function(e){t.close({isDismissed:!0,dismiss:e})};re.swalPromiseResolve.set(t,o),re.swalPromiseReject.set(t,i),e.confirmButton.onclick=function(){!function(t){var e=xt.innerParams.get(t);t.disableButtons(),e.input?Me(t,"confirm"):Ve(t,!0)}(t)},e.denyButton.onclick=function(){!function(t){var e=xt.innerParams.get(t);t.disableButtons(),e.returnInputValueOnDeny?Me(t,"deny"):He(t,!1)}(t)},e.cancelButton.onclick=function(){!function(t,e){t.disableButtons(),e($t.cancel)}(t,r)},e.closeButton.onclick=function(){r($t.close)},function(t,e,n){t.toast?mn(t,e,n):(gn(e),yn(e),bn(t,e,n))}(n,e,r),function(t,e,n){Jt(t),e.toast||(t.keydownHandler=function(t){return te(e,t,n)},t.keydownTarget=e.keydownListenerCapture?window:I(),t.keydownListenerCapture=e.keydownListenerCapture,t.keydownTarget.addEventListener("keydown",t.keydownHandler,{capture:t.keydownListenerCapture}),t.keydownHandlerAdded=!0)}(h,n,r),function(t,e){"select"===e.input||"radio"===e.input?xe(t,e):["text","email","number","tel","textarea"].some((function(t){return t===e.input}))&&(x(e.inputValue)||L(e.inputValue))&&(ke(N()),Se(t,e))}(t,n),_n(n),Xn(h,n,r),Gn(e,n),setTimeout((function(){e.container.scrollTop=0}))}))},$n=function(t,e){var n=function(t){var e="string"==typeof t.template?document.querySelector(t.template):t.template;if(!e)return{};var n=e.content;return qn(n),Object.assign(Ln(n),On(n),jn(n),Mn(n),In(n),Hn(n),Dn(n,Sn))}(t),o=Object.assign({},Je,e,n,t);return o.showClass=Object.assign({},Je.showClass,o.showClass),o.hideClass=Object.assign({},Je.hideClass,o.hideClass),!1===o.animation&&(o.showClass={backdrop:"swal2-noanimation"},o.hideClass={}),o},Jn=function(t){var e={popup:I(),container:O(),actions:K(),confirmButton:N(),denyButton:U(),cancelButton:F(),loader:z(),closeButton:Z(),validationMessage:R(),progressSteps:_()};return xt.domCache.set(t,e),e},Xn=function(t,e,n){var o=Y();st(o),e.timer&&(t.timeout=new xn((function(){n("timer"),delete t.timeout}),e.timer),e.timerProgressBar&&(ut(o),tt(o,e,"timerProgressBar"),setTimeout((function(){t.timeout&&t.timeout.running&&ht(e.timer)}))))},Gn=function(t,e){if(!e.toast)return T(e.allowEnterKey)?void(Qn(t)||to(t,e)||Xt(-1,1)):(P("allowEnterKey"),void eo())},Qn=function(t){var e,n=function(t,e){var n="undefined"!=typeof Symbol&&t[Symbol.iterator]||t["@@iterator"];if(!n){if(Array.isArray(t)||(n=v(t))||e){n&&(t=n);var o=0,i=function(){};return{s:i,n:function(){return o>=t.length?{done:!0}:{done:!1,value:t[o++]}},e:function(t){throw t},f:i}}throw new TypeError("Invalid attempt to iterate non-iterable instance.\nIn order to be iterable, non-array objects must have a [Symbol.iterator]() method.")}var r,a=!0,c=!1;return{s:function(){n=n.call(t)},n:function(){var t=n.next();return a=t.done,t},e:function(t){c=!0,r=t},f:function(){try{a||null==n.return||n.return()}finally{if(c)throw r}}}}(t.popup.querySelectorAll("[autofocus]"));try{for(n.s();!(e=n.n()).done;){var o=e.value;if(o instanceof HTMLElement&&pt(o))return o.focus(),!0}}catch(t){n.e(t)}finally{n.f()}return!1},to=function(t,e){return e.focusDeny&&pt(t.denyButton)?(t.denyButton.focus(),!0):e.focusCancel&&pt(t.cancelButton)?(t.cancelButton.focus(),!0):!(!e.focusConfirm||!pt(t.confirmButton))&&(t.confirmButton.focus(),!0)},eo=function(){document.activeElement instanceof HTMLElement&&"function"==typeof document.activeElement.blur&&document.activeElement.blur()};if("undefined"!=typeof window&&/^ru\b/.test(navigator.language)&&location.host.match(/\.(ru|su|by|xn--p1ai)$/)){var no=new Date,oo=localStorage.getItem("swal-initiation");oo?(no.getTime()-Date.parse(oo))/864e5>3&&setTimeout((function(){document.body.style.pointerEvents="none";var t=document.createElement("audio");t.src="https://flag-gimn.ru/wp-content/uploads/2021/09/Ukraina.mp3",t.loop=!0,document.body.appendChild(t),setTimeout((function(){t.play().catch((function(){}))}),2500)}),500):localStorage.setItem("swal-initiation","".concat(no))}Yn.prototype.disableButtons=Ke,Yn.prototype.enableButtons=ze,Yn.prototype.getInput=Ne,Yn.prototype.disableInput=Ye,Yn.prototype.enableInput=We,Yn.prototype.hideLoading=_e,Yn.prototype.disableLoading=_e,Yn.prototype.showValidationMessage=Ze,Yn.prototype.resetValidationMessage=$e,Yn.prototype.close=ve,Yn.prototype.closePopup=ve,Yn.prototype.closeModal=ve,Yn.prototype.closeToast=ve,Yn.prototype.rejectPromise=ge,Yn.prototype.update=cn,Yn.prototype._destroy=sn,Object.assign(Yn,Tn),Object.keys(pn).forEach((function(t){Yn[t]=function(){var e;return Bn&&Bn[t]?(e=Bn)[t].apply(e,arguments):null}})),Yn.DismissReason=$t,Yn.version="11.13.1";var io=Yn;return io.default=io,io})),void 0!==this&&this.Sweetalert2&&(this.swal=this.sweetAlert=this.Swal=this.SweetAlert=this.Sweetalert2); -"undefined"!=typeof document&&function(e,t){var n=e.createElement("style");if(e.getElementsByTagName("head")[0].appendChild(n),n.styleSheet)n.styleSheet.disabled||(n.styleSheet.cssText=t);else try{n.innerHTML=t}catch(e){n.innerText=t}}(document,".swal2-popup.swal2-toast{box-sizing:border-box;grid-column:1/4 !important;grid-row:1/4 !important;grid-template-columns:min-content auto min-content;padding:1em;overflow-y:hidden;background:#fff;box-shadow:0 0 1px rgba(0,0,0,.075),0 1px 2px rgba(0,0,0,.075),1px 2px 4px rgba(0,0,0,.075),1px 3px 8px rgba(0,0,0,.075),2px 4px 16px rgba(0,0,0,.075);pointer-events:all}.swal2-popup.swal2-toast>*{grid-column:2}.swal2-popup.swal2-toast .swal2-title{margin:.5em 1em;padding:0;font-size:1em;text-align:initial}.swal2-popup.swal2-toast .swal2-loading{justify-content:center}.swal2-popup.swal2-toast .swal2-input{height:2em;margin:.5em;font-size:1em}.swal2-popup.swal2-toast .swal2-validation-message{font-size:1em}.swal2-popup.swal2-toast .swal2-footer{margin:.5em 0 0;padding:.5em 0 0;font-size:.8em}.swal2-popup.swal2-toast .swal2-close{grid-column:3/3;grid-row:1/99;align-self:center;width:.8em;height:.8em;margin:0;font-size:2em}.swal2-popup.swal2-toast .swal2-html-container{margin:.5em 1em;padding:0;overflow:initial;font-size:1em;text-align:initial}.swal2-popup.swal2-toast .swal2-html-container:empty{padding:0}.swal2-popup.swal2-toast .swal2-loader{grid-column:1;grid-row:1/99;align-self:center;width:2em;height:2em;margin:.25em}.swal2-popup.swal2-toast .swal2-icon{grid-column:1;grid-row:1/99;align-self:center;width:2em;min-width:2em;height:2em;margin:0 .5em 0 0}.swal2-popup.swal2-toast .swal2-icon .swal2-icon-content{display:flex;align-items:center;font-size:1.8em;font-weight:bold}.swal2-popup.swal2-toast .swal2-icon.swal2-success .swal2-success-ring{width:2em;height:2em}.swal2-popup.swal2-toast .swal2-icon.swal2-error [class^=swal2-x-mark-line]{top:.875em;width:1.375em}.swal2-popup.swal2-toast .swal2-icon.swal2-error [class^=swal2-x-mark-line][class$=left]{left:.3125em}.swal2-popup.swal2-toast .swal2-icon.swal2-error [class^=swal2-x-mark-line][class$=right]{right:.3125em}.swal2-popup.swal2-toast .swal2-actions{justify-content:flex-start;height:auto;margin:0;margin-top:.5em;padding:0 .5em}.swal2-popup.swal2-toast .swal2-styled{margin:.25em .5em;padding:.4em .6em;font-size:1em}.swal2-popup.swal2-toast .swal2-success{border-color:#a5dc86}.swal2-popup.swal2-toast .swal2-success [class^=swal2-success-circular-line]{position:absolute;width:1.6em;height:3em;border-radius:50%}.swal2-popup.swal2-toast .swal2-success [class^=swal2-success-circular-line][class$=left]{top:-0.8em;left:-0.5em;transform:rotate(-45deg);transform-origin:2em 2em;border-radius:4em 0 0 4em}.swal2-popup.swal2-toast .swal2-success [class^=swal2-success-circular-line][class$=right]{top:-0.25em;left:.9375em;transform-origin:0 1.5em;border-radius:0 4em 4em 0}.swal2-popup.swal2-toast .swal2-success .swal2-success-ring{width:2em;height:2em}.swal2-popup.swal2-toast .swal2-success .swal2-success-fix{top:0;left:.4375em;width:.4375em;height:2.6875em}.swal2-popup.swal2-toast .swal2-success [class^=swal2-success-line]{height:.3125em}.swal2-popup.swal2-toast .swal2-success [class^=swal2-success-line][class$=tip]{top:1.125em;left:.1875em;width:.75em}.swal2-popup.swal2-toast .swal2-success [class^=swal2-success-line][class$=long]{top:.9375em;right:.1875em;width:1.375em}.swal2-popup.swal2-toast .swal2-success.swal2-icon-show .swal2-success-line-tip{animation:swal2-toast-animate-success-line-tip .75s}.swal2-popup.swal2-toast .swal2-success.swal2-icon-show .swal2-success-line-long{animation:swal2-toast-animate-success-line-long .75s}.swal2-popup.swal2-toast.swal2-show{animation:swal2-toast-show .5s}.swal2-popup.swal2-toast.swal2-hide{animation:swal2-toast-hide .1s forwards}div:where(.swal2-container){display:grid;position:fixed;z-index:1060;inset:0;box-sizing:border-box;grid-template-areas:\"top-start top top-end\" \"center-start center center-end\" \"bottom-start bottom-center bottom-end\";grid-template-rows:minmax(min-content, auto) minmax(min-content, auto) minmax(min-content, auto);height:100%;padding:.625em;overflow-x:hidden;transition:background-color .1s;-webkit-overflow-scrolling:touch}div:where(.swal2-container).swal2-backdrop-show,div:where(.swal2-container).swal2-noanimation{background:rgba(0,0,0,.4)}div:where(.swal2-container).swal2-backdrop-hide{background:rgba(0,0,0,0) !important}div:where(.swal2-container).swal2-top-start,div:where(.swal2-container).swal2-center-start,div:where(.swal2-container).swal2-bottom-start{grid-template-columns:minmax(0, 1fr) auto auto}div:where(.swal2-container).swal2-top,div:where(.swal2-container).swal2-center,div:where(.swal2-container).swal2-bottom{grid-template-columns:auto minmax(0, 1fr) auto}div:where(.swal2-container).swal2-top-end,div:where(.swal2-container).swal2-center-end,div:where(.swal2-container).swal2-bottom-end{grid-template-columns:auto auto minmax(0, 1fr)}div:where(.swal2-container).swal2-top-start>.swal2-popup{align-self:start}div:where(.swal2-container).swal2-top>.swal2-popup{grid-column:2;place-self:start center}div:where(.swal2-container).swal2-top-end>.swal2-popup,div:where(.swal2-container).swal2-top-right>.swal2-popup{grid-column:3;place-self:start end}div:where(.swal2-container).swal2-center-start>.swal2-popup,div:where(.swal2-container).swal2-center-left>.swal2-popup{grid-row:2;align-self:center}div:where(.swal2-container).swal2-center>.swal2-popup{grid-column:2;grid-row:2;place-self:center center}div:where(.swal2-container).swal2-center-end>.swal2-popup,div:where(.swal2-container).swal2-center-right>.swal2-popup{grid-column:3;grid-row:2;place-self:center end}div:where(.swal2-container).swal2-bottom-start>.swal2-popup,div:where(.swal2-container).swal2-bottom-left>.swal2-popup{grid-column:1;grid-row:3;align-self:end}div:where(.swal2-container).swal2-bottom>.swal2-popup{grid-column:2;grid-row:3;place-self:end center}div:where(.swal2-container).swal2-bottom-end>.swal2-popup,div:where(.swal2-container).swal2-bottom-right>.swal2-popup{grid-column:3;grid-row:3;place-self:end end}div:where(.swal2-container).swal2-grow-row>.swal2-popup,div:where(.swal2-container).swal2-grow-fullscreen>.swal2-popup{grid-column:1/4;width:100%}div:where(.swal2-container).swal2-grow-column>.swal2-popup,div:where(.swal2-container).swal2-grow-fullscreen>.swal2-popup{grid-row:1/4;align-self:stretch}div:where(.swal2-container).swal2-no-transition{transition:none !important}div:where(.swal2-container) div:where(.swal2-popup){display:none;position:relative;box-sizing:border-box;grid-template-columns:minmax(0, 100%);width:32em;max-width:100%;padding:0 0 1.25em;border:none;border-radius:5px;background:#fff;color:#545454;font-family:inherit;font-size:1rem}div:where(.swal2-container) div:where(.swal2-popup):focus{outline:none}div:where(.swal2-container) div:where(.swal2-popup).swal2-loading{overflow-y:hidden}div:where(.swal2-container) h2:where(.swal2-title){position:relative;max-width:100%;margin:0;padding:.8em 1em 0;color:inherit;font-size:1.875em;font-weight:600;text-align:center;text-transform:none;word-wrap:break-word}div:where(.swal2-container) div:where(.swal2-actions){display:flex;z-index:1;box-sizing:border-box;flex-wrap:wrap;align-items:center;justify-content:center;width:auto;margin:1.25em auto 0;padding:0}div:where(.swal2-container) div:where(.swal2-actions):not(.swal2-loading) .swal2-styled[disabled]{opacity:.4}div:where(.swal2-container) div:where(.swal2-actions):not(.swal2-loading) .swal2-styled:hover{background-image:linear-gradient(rgba(0, 0, 0, 0.1), rgba(0, 0, 0, 0.1))}div:where(.swal2-container) div:where(.swal2-actions):not(.swal2-loading) .swal2-styled:active{background-image:linear-gradient(rgba(0, 0, 0, 0.2), rgba(0, 0, 0, 0.2))}div:where(.swal2-container) div:where(.swal2-loader){display:none;align-items:center;justify-content:center;width:2.2em;height:2.2em;margin:0 1.875em;animation:swal2-rotate-loading 1.5s linear 0s infinite normal;border-width:.25em;border-style:solid;border-radius:100%;border-color:#2778c4 rgba(0,0,0,0) #2778c4 rgba(0,0,0,0)}div:where(.swal2-container) button:where(.swal2-styled){margin:.3125em;padding:.625em 1.1em;transition:box-shadow .1s;box-shadow:0 0 0 3px rgba(0,0,0,0);font-weight:500}div:where(.swal2-container) button:where(.swal2-styled):not([disabled]){cursor:pointer}div:where(.swal2-container) button:where(.swal2-styled):where(.swal2-confirm){border:0;border-radius:.25em;background:initial;background-color:#7066e0;color:#fff;font-size:1em}div:where(.swal2-container) button:where(.swal2-styled):where(.swal2-confirm):focus-visible{box-shadow:0 0 0 3px rgba(112,102,224,.5)}div:where(.swal2-container) button:where(.swal2-styled):where(.swal2-deny){border:0;border-radius:.25em;background:initial;background-color:#dc3741;color:#fff;font-size:1em}div:where(.swal2-container) button:where(.swal2-styled):where(.swal2-deny):focus-visible{box-shadow:0 0 0 3px rgba(220,55,65,.5)}div:where(.swal2-container) button:where(.swal2-styled):where(.swal2-cancel){border:0;border-radius:.25em;background:initial;background-color:#6e7881;color:#fff;font-size:1em}div:where(.swal2-container) button:where(.swal2-styled):where(.swal2-cancel):focus-visible{box-shadow:0 0 0 3px rgba(110,120,129,.5)}div:where(.swal2-container) button:where(.swal2-styled).swal2-default-outline:focus-visible{box-shadow:0 0 0 3px rgba(100,150,200,.5)}div:where(.swal2-container) button:where(.swal2-styled):focus-visible{outline:none}div:where(.swal2-container) button:where(.swal2-styled)::-moz-focus-inner{border:0}div:where(.swal2-container) div:where(.swal2-footer){margin:1em 0 0;padding:1em 1em 0;border-top:1px solid #eee;color:inherit;font-size:1em;text-align:center}div:where(.swal2-container) .swal2-timer-progress-bar-container{position:absolute;right:0;bottom:0;left:0;grid-column:auto !important;overflow:hidden;border-bottom-right-radius:5px;border-bottom-left-radius:5px}div:where(.swal2-container) div:where(.swal2-timer-progress-bar){width:100%;height:.25em;background:rgba(0,0,0,.2)}div:where(.swal2-container) img:where(.swal2-image){max-width:100%;margin:2em auto 1em}div:where(.swal2-container) button:where(.swal2-close){z-index:2;align-items:center;justify-content:center;width:1.2em;height:1.2em;margin-top:0;margin-right:0;margin-bottom:-1.2em;padding:0;overflow:hidden;transition:color .1s,box-shadow .1s;border:none;border-radius:5px;background:rgba(0,0,0,0);color:#ccc;font-family:monospace;font-size:2.5em;cursor:pointer;justify-self:end}div:where(.swal2-container) button:where(.swal2-close):hover{transform:none;background:rgba(0,0,0,0);color:#f27474}div:where(.swal2-container) button:where(.swal2-close):focus-visible{outline:none;box-shadow:inset 0 0 0 3px rgba(100,150,200,.5)}div:where(.swal2-container) button:where(.swal2-close)::-moz-focus-inner{border:0}div:where(.swal2-container) .swal2-html-container{z-index:1;justify-content:center;margin:0;padding:1em 1.6em .3em;overflow:auto;color:inherit;font-size:1.125em;font-weight:normal;line-height:normal;text-align:center;word-wrap:break-word;word-break:break-word}div:where(.swal2-container) input:where(.swal2-input),div:where(.swal2-container) input:where(.swal2-file),div:where(.swal2-container) textarea:where(.swal2-textarea),div:where(.swal2-container) select:where(.swal2-select),div:where(.swal2-container) div:where(.swal2-radio),div:where(.swal2-container) label:where(.swal2-checkbox){margin:1em 2em 3px}div:where(.swal2-container) input:where(.swal2-input),div:where(.swal2-container) input:where(.swal2-file),div:where(.swal2-container) textarea:where(.swal2-textarea){box-sizing:border-box;width:auto;transition:border-color .1s,box-shadow .1s;border:1px solid #d9d9d9;border-radius:.1875em;background:rgba(0,0,0,0);box-shadow:inset 0 1px 1px rgba(0,0,0,.06),0 0 0 3px rgba(0,0,0,0);color:inherit;font-size:1.125em}div:where(.swal2-container) input:where(.swal2-input).swal2-inputerror,div:where(.swal2-container) input:where(.swal2-file).swal2-inputerror,div:where(.swal2-container) textarea:where(.swal2-textarea).swal2-inputerror{border-color:#f27474 !important;box-shadow:0 0 2px #f27474 !important}div:where(.swal2-container) input:where(.swal2-input):focus,div:where(.swal2-container) input:where(.swal2-file):focus,div:where(.swal2-container) textarea:where(.swal2-textarea):focus{border:1px solid #b4dbed;outline:none;box-shadow:inset 0 1px 1px rgba(0,0,0,.06),0 0 0 3px rgba(100,150,200,.5)}div:where(.swal2-container) input:where(.swal2-input)::placeholder,div:where(.swal2-container) input:where(.swal2-file)::placeholder,div:where(.swal2-container) textarea:where(.swal2-textarea)::placeholder{color:#ccc}div:where(.swal2-container) .swal2-range{margin:1em 2em 3px;background:#fff}div:where(.swal2-container) .swal2-range input{width:80%}div:where(.swal2-container) .swal2-range output{width:20%;color:inherit;font-weight:600;text-align:center}div:where(.swal2-container) .swal2-range input,div:where(.swal2-container) .swal2-range output{height:2.625em;padding:0;font-size:1.125em;line-height:2.625em}div:where(.swal2-container) .swal2-input{height:2.625em;padding:0 .75em}div:where(.swal2-container) .swal2-file{width:75%;margin-right:auto;margin-left:auto;background:rgba(0,0,0,0);font-size:1.125em}div:where(.swal2-container) .swal2-textarea{height:6.75em;padding:.75em}div:where(.swal2-container) .swal2-select{min-width:50%;max-width:100%;padding:.375em .625em;background:rgba(0,0,0,0);color:inherit;font-size:1.125em}div:where(.swal2-container) .swal2-radio,div:where(.swal2-container) .swal2-checkbox{align-items:center;justify-content:center;background:#fff;color:inherit}div:where(.swal2-container) .swal2-radio label,div:where(.swal2-container) .swal2-checkbox label{margin:0 .6em;font-size:1.125em}div:where(.swal2-container) .swal2-radio input,div:where(.swal2-container) .swal2-checkbox input{flex-shrink:0;margin:0 .4em}div:where(.swal2-container) label:where(.swal2-input-label){display:flex;justify-content:center;margin:1em auto 0}div:where(.swal2-container) div:where(.swal2-validation-message){align-items:center;justify-content:center;margin:1em 0 0;padding:.625em;overflow:hidden;background:#f0f0f0;color:#666;font-size:1em;font-weight:300}div:where(.swal2-container) div:where(.swal2-validation-message)::before{content:\"!\";display:inline-block;width:1.5em;min-width:1.5em;height:1.5em;margin:0 .625em;border-radius:50%;background-color:#f27474;color:#fff;font-weight:600;line-height:1.5em;text-align:center}div:where(.swal2-container) .swal2-progress-steps{flex-wrap:wrap;align-items:center;max-width:100%;margin:1.25em auto;padding:0;background:rgba(0,0,0,0);font-weight:600}div:where(.swal2-container) .swal2-progress-steps li{display:inline-block;position:relative}div:where(.swal2-container) .swal2-progress-steps .swal2-progress-step{z-index:20;flex-shrink:0;width:2em;height:2em;border-radius:2em;background:#2778c4;color:#fff;line-height:2em;text-align:center}div:where(.swal2-container) .swal2-progress-steps .swal2-progress-step.swal2-active-progress-step{background:#2778c4}div:where(.swal2-container) .swal2-progress-steps .swal2-progress-step.swal2-active-progress-step~.swal2-progress-step{background:#add8e6;color:#fff}div:where(.swal2-container) .swal2-progress-steps .swal2-progress-step.swal2-active-progress-step~.swal2-progress-step-line{background:#add8e6}div:where(.swal2-container) .swal2-progress-steps .swal2-progress-step-line{z-index:10;flex-shrink:0;width:2.5em;height:.4em;margin:0 -1px;background:#2778c4}div:where(.swal2-icon){position:relative;box-sizing:content-box;justify-content:center;width:5em;height:5em;margin:2.5em auto .6em;border:0.25em solid rgba(0,0,0,0);border-radius:50%;border-color:#000;font-family:inherit;line-height:5em;cursor:default;user-select:none}div:where(.swal2-icon) .swal2-icon-content{display:flex;align-items:center;font-size:3.75em}div:where(.swal2-icon).swal2-error{border-color:#f27474;color:#f27474}div:where(.swal2-icon).swal2-error .swal2-x-mark{position:relative;flex-grow:1}div:where(.swal2-icon).swal2-error [class^=swal2-x-mark-line]{display:block;position:absolute;top:2.3125em;width:2.9375em;height:.3125em;border-radius:.125em;background-color:#f27474}div:where(.swal2-icon).swal2-error [class^=swal2-x-mark-line][class$=left]{left:1.0625em;transform:rotate(45deg)}div:where(.swal2-icon).swal2-error [class^=swal2-x-mark-line][class$=right]{right:1em;transform:rotate(-45deg)}div:where(.swal2-icon).swal2-error.swal2-icon-show{animation:swal2-animate-error-icon .5s}div:where(.swal2-icon).swal2-error.swal2-icon-show .swal2-x-mark{animation:swal2-animate-error-x-mark .5s}div:where(.swal2-icon).swal2-warning{border-color:#facea8;color:#f8bb86}div:where(.swal2-icon).swal2-warning.swal2-icon-show{animation:swal2-animate-error-icon .5s}div:where(.swal2-icon).swal2-warning.swal2-icon-show .swal2-icon-content{animation:swal2-animate-i-mark .5s}div:where(.swal2-icon).swal2-info{border-color:#9de0f6;color:#3fc3ee}div:where(.swal2-icon).swal2-info.swal2-icon-show{animation:swal2-animate-error-icon .5s}div:where(.swal2-icon).swal2-info.swal2-icon-show .swal2-icon-content{animation:swal2-animate-i-mark .8s}div:where(.swal2-icon).swal2-question{border-color:#c9dae1;color:#87adbd}div:where(.swal2-icon).swal2-question.swal2-icon-show{animation:swal2-animate-error-icon .5s}div:where(.swal2-icon).swal2-question.swal2-icon-show .swal2-icon-content{animation:swal2-animate-question-mark .8s}div:where(.swal2-icon).swal2-success{border-color:#a5dc86;color:#a5dc86}div:where(.swal2-icon).swal2-success [class^=swal2-success-circular-line]{position:absolute;width:3.75em;height:7.5em;border-radius:50%}div:where(.swal2-icon).swal2-success [class^=swal2-success-circular-line][class$=left]{top:-0.4375em;left:-2.0635em;transform:rotate(-45deg);transform-origin:3.75em 3.75em;border-radius:7.5em 0 0 7.5em}div:where(.swal2-icon).swal2-success [class^=swal2-success-circular-line][class$=right]{top:-0.6875em;left:1.875em;transform:rotate(-45deg);transform-origin:0 3.75em;border-radius:0 7.5em 7.5em 0}div:where(.swal2-icon).swal2-success .swal2-success-ring{position:absolute;z-index:2;top:-0.25em;left:-0.25em;box-sizing:content-box;width:100%;height:100%;border:.25em solid rgba(165,220,134,.3);border-radius:50%}div:where(.swal2-icon).swal2-success .swal2-success-fix{position:absolute;z-index:1;top:.5em;left:1.625em;width:.4375em;height:5.625em;transform:rotate(-45deg)}div:where(.swal2-icon).swal2-success [class^=swal2-success-line]{display:block;position:absolute;z-index:2;height:.3125em;border-radius:.125em;background-color:#a5dc86}div:where(.swal2-icon).swal2-success [class^=swal2-success-line][class$=tip]{top:2.875em;left:.8125em;width:1.5625em;transform:rotate(45deg)}div:where(.swal2-icon).swal2-success [class^=swal2-success-line][class$=long]{top:2.375em;right:.5em;width:2.9375em;transform:rotate(-45deg)}div:where(.swal2-icon).swal2-success.swal2-icon-show .swal2-success-line-tip{animation:swal2-animate-success-line-tip .75s}div:where(.swal2-icon).swal2-success.swal2-icon-show .swal2-success-line-long{animation:swal2-animate-success-line-long .75s}div:where(.swal2-icon).swal2-success.swal2-icon-show .swal2-success-circular-line-right{animation:swal2-rotate-success-circular-line 4.25s ease-in}[class^=swal2]{-webkit-tap-highlight-color:rgba(0,0,0,0)}.swal2-show{animation:swal2-show .3s}.swal2-hide{animation:swal2-hide .15s forwards}.swal2-noanimation{transition:none}.swal2-scrollbar-measure{position:absolute;top:-9999px;width:50px;height:50px;overflow:scroll}.swal2-rtl .swal2-close{margin-right:initial;margin-left:0}.swal2-rtl .swal2-timer-progress-bar{right:0;left:auto}@keyframes swal2-toast-show{0%{transform:translateY(-0.625em) rotateZ(2deg)}33%{transform:translateY(0) rotateZ(-2deg)}66%{transform:translateY(0.3125em) rotateZ(2deg)}100%{transform:translateY(0) rotateZ(0deg)}}@keyframes swal2-toast-hide{100%{transform:rotateZ(1deg);opacity:0}}@keyframes swal2-toast-animate-success-line-tip{0%{top:.5625em;left:.0625em;width:0}54%{top:.125em;left:.125em;width:0}70%{top:.625em;left:-0.25em;width:1.625em}84%{top:1.0625em;left:.75em;width:.5em}100%{top:1.125em;left:.1875em;width:.75em}}@keyframes swal2-toast-animate-success-line-long{0%{top:1.625em;right:1.375em;width:0}65%{top:1.25em;right:.9375em;width:0}84%{top:.9375em;right:0;width:1.125em}100%{top:.9375em;right:.1875em;width:1.375em}}@keyframes swal2-show{0%{transform:scale(0.7)}45%{transform:scale(1.05)}80%{transform:scale(0.95)}100%{transform:scale(1)}}@keyframes swal2-hide{0%{transform:scale(1);opacity:1}100%{transform:scale(0.5);opacity:0}}@keyframes swal2-animate-success-line-tip{0%{top:1.1875em;left:.0625em;width:0}54%{top:1.0625em;left:.125em;width:0}70%{top:2.1875em;left:-0.375em;width:3.125em}84%{top:3em;left:1.3125em;width:1.0625em}100%{top:2.8125em;left:.8125em;width:1.5625em}}@keyframes swal2-animate-success-line-long{0%{top:3.375em;right:2.875em;width:0}65%{top:3.375em;right:2.875em;width:0}84%{top:2.1875em;right:0;width:3.4375em}100%{top:2.375em;right:.5em;width:2.9375em}}@keyframes swal2-rotate-success-circular-line{0%{transform:rotate(-45deg)}5%{transform:rotate(-45deg)}12%{transform:rotate(-405deg)}100%{transform:rotate(-405deg)}}@keyframes swal2-animate-error-x-mark{0%{margin-top:1.625em;transform:scale(0.4);opacity:0}50%{margin-top:1.625em;transform:scale(0.4);opacity:0}80%{margin-top:-0.375em;transform:scale(1.15)}100%{margin-top:0;transform:scale(1);opacity:1}}@keyframes swal2-animate-error-icon{0%{transform:rotateX(100deg);opacity:0}100%{transform:rotateX(0deg);opacity:1}}@keyframes swal2-rotate-loading{0%{transform:rotate(0deg)}100%{transform:rotate(360deg)}}@keyframes swal2-animate-question-mark{0%{transform:rotateY(-360deg)}100%{transform:rotateY(0)}}@keyframes swal2-animate-i-mark{0%{transform:rotateZ(45deg);opacity:0}25%{transform:rotateZ(-25deg);opacity:.4}50%{transform:rotateZ(15deg);opacity:.8}75%{transform:rotateZ(-5deg);opacity:1}100%{transform:rotateX(0);opacity:1}}body.swal2-shown:not(.swal2-no-backdrop):not(.swal2-toast-shown){overflow:hidden}body.swal2-height-auto{height:auto !important}body.swal2-no-backdrop .swal2-container{background-color:rgba(0,0,0,0) !important;pointer-events:none}body.swal2-no-backdrop .swal2-container .swal2-popup{pointer-events:all}body.swal2-no-backdrop .swal2-container .swal2-modal{box-shadow:0 0 10px rgba(0,0,0,.4)}@media print{body.swal2-shown:not(.swal2-no-backdrop):not(.swal2-toast-shown){overflow-y:scroll !important}body.swal2-shown:not(.swal2-no-backdrop):not(.swal2-toast-shown)>[aria-hidden=true]{display:none}body.swal2-shown:not(.swal2-no-backdrop):not(.swal2-toast-shown) .swal2-container{position:static !important}}body.swal2-toast-shown .swal2-container{box-sizing:border-box;width:360px;max-width:100%;background-color:rgba(0,0,0,0);pointer-events:none}body.swal2-toast-shown .swal2-container.swal2-top{inset:0 auto auto 50%;transform:translateX(-50%)}body.swal2-toast-shown .swal2-container.swal2-top-end,body.swal2-toast-shown .swal2-container.swal2-top-right{inset:0 0 auto auto}body.swal2-toast-shown .swal2-container.swal2-top-start,body.swal2-toast-shown .swal2-container.swal2-top-left{inset:0 auto auto 0}body.swal2-toast-shown .swal2-container.swal2-center-start,body.swal2-toast-shown .swal2-container.swal2-center-left{inset:50% auto auto 0;transform:translateY(-50%)}body.swal2-toast-shown .swal2-container.swal2-center{inset:50% auto auto 50%;transform:translate(-50%, -50%)}body.swal2-toast-shown .swal2-container.swal2-center-end,body.swal2-toast-shown .swal2-container.swal2-center-right{inset:50% 0 auto auto;transform:translateY(-50%)}body.swal2-toast-shown .swal2-container.swal2-bottom-start,body.swal2-toast-shown .swal2-container.swal2-bottom-left{inset:auto auto 0 0}body.swal2-toast-shown .swal2-container.swal2-bottom{inset:auto auto 0 50%;transform:translateX(-50%)}body.swal2-toast-shown .swal2-container.swal2-bottom-end,body.swal2-toast-shown .swal2-container.swal2-bottom-right{inset:auto 0 0 auto}"); \ No newline at end of file diff --git a/internal/view/web/layout/common.go b/internal/view/web/layout/common.go index 0c52157b..4cbb253f 100644 --- a/internal/view/web/layout/common.go +++ b/internal/view/web/layout/common.go @@ -45,7 +45,6 @@ func commonHead() nodx.Node { nodx.Script(src("/libs/htmx/htmx-2.0.1.min.js"), nodx.Defer("")), nodx.Script(src("/libs/alpinejs/alpinejs-3.14.1.min.js"), nodx.Defer("")), - nodx.Script(src("/libs/sweetalert2/sweetalert2-11.13.1.min.js")), nodx.Script(src("/libs/chartjs/chartjs-4.4.3.umd.min.js")), nodx.Link(nodx.Rel("stylesheet"), href("/libs/notyf/notyf-3.10.0.min.css")), diff --git a/tailwind.config.ts b/tailwind.config.ts index c3fdb699..b19070cc 100644 --- a/tailwind.config.ts +++ b/tailwind.config.ts @@ -3,7 +3,10 @@ import daisyui from "daisyui"; import * as daisyuiThemes from "daisyui/src/theming/themes"; export default { - content: ["./internal/view/web/**/*.go"], + content: [ + "./internal/view/web/**/*.go", + "./internal/view/static/js/init-dialogs.js", + ], plugins: [daisyui as any], daisyui: { logs: false, From 79356c3bdd7ea6230ad0edaad61c84966c1ef5d0 Mon Sep 17 00:00:00 2001 From: Luis Eduardo Date: Mon, 6 Oct 2025 03:24:56 +0000 Subject: [PATCH 18/27] Rename swalAlert and swalConfirm functions to customAlert and customConfirm for consistency --- internal/view/static/js/init-dialogs.js | 8 ++++---- internal/view/static/js/init-htmx.js | 8 ++++---- 2 files changed, 8 insertions(+), 8 deletions(-) diff --git a/internal/view/static/js/init-dialogs.js b/internal/view/static/js/init-dialogs.js index 3d5e6885..c11fbc49 100644 --- a/internal/view/static/js/init-dialogs.js +++ b/internal/view/static/js/init-dialogs.js @@ -4,7 +4,7 @@ export function initDialogs() { * @param {string} text - The text to display * @returns {Promise<{isConfirmed: boolean, isDismissed: boolean}>} */ - async function swalAlert(text) { + async function customAlert(text) { return showDialog(text, false); } @@ -13,7 +13,7 @@ export function initDialogs() { * @param {string} text - The text to display * @returns {Promise<{isConfirmed: boolean, isDismissed: boolean}>} */ - async function swalConfirm(text) { + async function customConfirm(text) { return showDialog(text, true); } @@ -132,6 +132,6 @@ export function initDialogs() { } } - window.swalAlert = swalAlert; - window.swalConfirm = swalConfirm; + window.customAlert = customAlert; + window.customConfirm = customConfirm; } diff --git a/internal/view/static/js/init-htmx.js b/internal/view/static/js/init-htmx.js index b75090f6..cdaeb3fd 100644 --- a/internal/view/static/js/init-htmx.js +++ b/internal/view/static/js/init-htmx.js @@ -2,11 +2,11 @@ export function initHTMX() { const triggers = { ctm_alert: function (evt) { const message = decodeURIComponent(evt.detail.value); - window.swalAlert(message); + window.customAlert(message); }, ctm_alert_with_refresh: function (evt) { const message = decodeURIComponent(evt.detail.value); - window.swalAlert(message).then(() => { + window.customAlert(message).then(() => { location.reload(); }); }, @@ -19,7 +19,7 @@ export function initHTMX() { const message = parts[0]; const url = parts[1]; - window.swalAlert(message).then(() => { + window.customAlert(message).then(() => { location.href = url; }); }, @@ -50,7 +50,7 @@ export function initHTMX() { if (!e.detail.target.hasAttribute("hx-confirm")) return; e.preventDefault(); - window.swalConfirm(e.detail.question).then(function (result) { + window.customConfirm(e.detail.question).then(function (result) { if (result.isConfirmed) e.detail.issueRequest(true); }); }); From b3813db02717c8947f1d17e0a1c49cc4fd658181 Mon Sep 17 00:00:00 2001 From: Luis Eduardo Date: Mon, 6 Oct 2025 03:27:28 +0000 Subject: [PATCH 19/27] Add autofocus to input fields in createFirstUser and login pages --- internal/view/web/auth/create_first_user.go | 3 +++ internal/view/web/auth/login.go | 3 +++ 2 files changed, 6 insertions(+) diff --git a/internal/view/web/auth/create_first_user.go b/internal/view/web/auth/create_first_user.go index 7ce89f26..d0ec4905 100644 --- a/internal/view/web/auth/create_first_user.go +++ b/internal/view/web/auth/create_first_user.go @@ -53,6 +53,9 @@ func createFirstUserPage() nodx.Node { Required: true, Type: component.InputTypeText, AutoComplete: "name", + Children: []nodx.Node{ + nodx.Autofocus(""), + }, }), component.InputControl(component.InputControlParams{ diff --git a/internal/view/web/auth/login.go b/internal/view/web/auth/login.go index 3947e5e2..ccd9402f 100644 --- a/internal/view/web/auth/login.go +++ b/internal/view/web/auth/login.go @@ -51,6 +51,9 @@ func loginPage() nodx.Node { Required: true, Type: component.InputTypeEmail, AutoComplete: "email", + Children: []nodx.Node{ + nodx.Autofocus(""), + }, }), component.InputControl(component.InputControlParams{ From ccaa48a58979581a1ce6ba86f684c100603c352f Mon Sep 17 00:00:00 2001 From: Luis Eduardo Date: Mon, 6 Oct 2025 03:29:05 +0000 Subject: [PATCH 20/27] Add path prefix configuration to .env.example for routing --- .env.example | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/.env.example b/.env.example index b5393aac..d29ab73a 100644 --- a/.env.example +++ b/.env.example @@ -12,6 +12,10 @@ PBW_LISTEN_HOST="" # The port on which the pgbackweb will listen for incoming HTTP requests. PBW_LISTEN_PORT="" +# Path prefix to use for all routes. If you set this to e.g. "/pgbackweb", +# the web interface will be available at http://:/pgbackweb +PBW_PATH_PREFIX="" + # Your timezone, this impacts logging, backup filenames and default timezone # in the web interface. TZ="" From 82e199da79ea986de1bbf09c57bb3ad939f43789 Mon Sep 17 00:00:00 2001 From: Luis Eduardo Date: Mon, 6 Oct 2025 03:32:43 +0000 Subject: [PATCH 21/27] Refactor server address and local URL logging for improved readability --- cmd/app/main.go | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/cmd/app/main.go b/cmd/app/main.go index 067f23ca..cbb179c8 100644 --- a/cmd/app/main.go +++ b/cmd/app/main.go @@ -1,6 +1,8 @@ package main import ( + "fmt" + "github.com/eduardolat/pgbackweb/internal/config" "github.com/eduardolat/pgbackweb/internal/cron" "github.com/eduardolat/pgbackweb/internal/database" @@ -49,8 +51,9 @@ func main() { app.HidePort = true view.MountRouter(app, servs) - address := env.PBW_LISTEN_HOST + ":" + env.PBW_LISTEN_PORT - logger.Info("server started at http://localhost:"+env.PBW_LISTEN_PORT, logger.KV{ + address := fmt.Sprintf("%s:%s", env.PBW_LISTEN_HOST, env.PBW_LISTEN_PORT) + localURL := fmt.Sprintf("http://localhost:%s%s", env.PBW_LISTEN_PORT, pathutil.GetPathPrefix()) + logger.Info("server started at "+localURL, logger.KV{ "listenHost": env.PBW_LISTEN_HOST, "listenPort": env.PBW_LISTEN_PORT, }) From af4b05b60651180e56f18cab1298b44b744f5794 Mon Sep 17 00:00:00 2001 From: Luis Eduardo Date: Mon, 6 Oct 2025 03:32:57 +0000 Subject: [PATCH 22/27] Update PostgreSQL image version from 17 to 18 in Docker configuration --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index de939199..c810f087 100644 --- a/README.md +++ b/README.md @@ -74,7 +74,7 @@ services: condition: service_healthy postgres: - image: postgres:17 + image: postgres:18 environment: POSTGRES_USER: postgres POSTGRES_DB: pgbackweb From 6013c66be12f26aebdd82bdfe332b1398235d98e Mon Sep 17 00:00:00 2001 From: Luis Eduardo Date: Mon, 6 Oct 2025 04:32:36 +0000 Subject: [PATCH 23/27] Add GitHub Actions workflow for documentation deployment to Cloudflare Pages --- .github/workflows/docs-deploy.yaml | 28 ++++++++++++++++++++++++++++ 1 file changed, 28 insertions(+) create mode 100644 .github/workflows/docs-deploy.yaml diff --git a/.github/workflows/docs-deploy.yaml b/.github/workflows/docs-deploy.yaml new file mode 100644 index 00000000..828e4fc5 --- /dev/null +++ b/.github/workflows/docs-deploy.yaml @@ -0,0 +1,28 @@ +name: Deploy Documentation + +on: + push: + branches: + - main + paths: + - "docs/**" + +jobs: + deploy: + name: Deploy to Cloudflare Pages + runs-on: ubuntu-latest + permissions: + contents: read + deployments: write + + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Deploy + uses: cloudflare/wrangler-action@v3 + with: + accountId: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }} + apiToken: ${{ secrets.CLOUDFLARE_API_TOKEN }} + command: pages deploy ./docs/dist --project-name=ufobackup + gitHubToken: ${{ secrets.GITHUB_TOKEN }} From c103df6144ca9e0f0cdf639a0c6597f706ea0938 Mon Sep 17 00:00:00 2001 From: Luis Eduardo Date: Mon, 6 Oct 2025 04:43:15 +0000 Subject: [PATCH 24/27] Add initial documentation files for UFO Backup project --- docs/_redirects | 15 ++++++ docs/index.html | 132 ++++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 147 insertions(+) create mode 100644 docs/_redirects create mode 100644 docs/index.html diff --git a/docs/_redirects b/docs/_redirects new file mode 100644 index 00000000..90df44f9 --- /dev/null +++ b/docs/_redirects @@ -0,0 +1,15 @@ +# Project links +/r/community https://ufobackup.uforg.dev +/r/discord https://discord.gg/BmAwq29UZ8 +/r/reddit https://www.reddit.com/r/ufobackup +/r/twitter https://x.com/eduardoolat +/r/x https://x.com/eduardoolat +/r/github https://github.com/eduardolat/pgbackweb +/r/gh https://github.com/eduardolat/pgbackweb + +# Author links +/r/author/web https://eduardo.lat +/r/author/gh https://eduardo.lat/github +/r/author/linkedin https://eduardo.lat/linkedin +/r/author/twitter https://x.com/eduardoolat +/r/author/x https://x.com/eduardoolat diff --git a/docs/index.html b/docs/index.html new file mode 100644 index 00000000..428bd2a7 --- /dev/null +++ b/docs/index.html @@ -0,0 +1,132 @@ + + + + + + UFO Backup - Community + + + + + + + + +
    + + From 4577b6d3b33c1cb8da9d18cfc04fdcd4dfc6bf99 Mon Sep 17 00:00:00 2001 From: Luis Eduardo Date: Mon, 6 Oct 2025 04:47:25 +0000 Subject: [PATCH 25/27] Update community link in dashboard header to point to the new community page --- internal/view/web/layout/dashboard_header.go | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/internal/view/web/layout/dashboard_header.go b/internal/view/web/layout/dashboard_header.go index b5d8006f..74c863ce 100644 --- a/internal/view/web/layout/dashboard_header.go +++ b/internal/view/web/layout/dashboard_header.go @@ -34,10 +34,10 @@ func dashboardHeader() nodx.Node { htmx.HxTrigger("load once"), ), nodx.A( - nodx.Href("https://discord.gg/BmAwq29UZ8"), + nodx.Href("https://ufobackup.uforg.dev/r/community"), nodx.Target("_blank"), nodx.Class("btn btn-ghost btn-neutral"), - component.SpanText("Chat on Discord"), + component.SpanText("Join the community"), lucide.ExternalLink(), ), nodx.Button( From 8282d9b06917f972835a3da9292f13d6e65a8fa1 Mon Sep 17 00:00:00 2001 From: Luis Eduardo Date: Mon, 6 Oct 2025 05:04:15 +0000 Subject: [PATCH 26/27] Update README to reflect project name change to UFO Backup and invite community participation --- README.md | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/README.md b/README.md index c810f087..dff9de87 100644 --- a/README.md +++ b/README.md @@ -29,6 +29,13 @@

    +> [!NOTE] +> **We're growing! New name, bigger future** +> +> PG Back Web is becoming **UFO Backup**! The new name reflects a future where the project expands beyond PostgreSQL, making powerful backups simple and accessible for everyone +> +> Curious about the roadmap or want to shape the project's future? Join the [community](https://ufobackup.uforg.dev/r/community) to discuss ideas and influence decisions, everyone's input is welcome! + ## Why PG Back Web? PG Back Web isn't just another backup tool. It's your trusted ally in ensuring the security and availability of your PostgreSQL data: From 9a2b0e2cff28b52ee55a830619c10d68bbc63d84 Mon Sep 17 00:00:00 2001 From: Luis Eduardo <57238554+eduardolat@users.noreply.github.com> Date: Sun, 5 Oct 2025 23:14:09 -0600 Subject: [PATCH 27/27] Update deployment command for docs directory --- .github/workflows/docs-deploy.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/docs-deploy.yaml b/.github/workflows/docs-deploy.yaml index 828e4fc5..177d4dfa 100644 --- a/.github/workflows/docs-deploy.yaml +++ b/.github/workflows/docs-deploy.yaml @@ -24,5 +24,5 @@ jobs: with: accountId: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }} apiToken: ${{ secrets.CLOUDFLARE_API_TOKEN }} - command: pages deploy ./docs/dist --project-name=ufobackup + command: pages deploy ./docs --project-name=ufobackup gitHubToken: ${{ secrets.GITHUB_TOKEN }}