From 33235d8f1ee6599e322a02d0c85f1019e496ba11 Mon Sep 17 00:00:00 2001 From: Shane C Date: Sun, 3 Nov 2024 15:33:08 -0500 Subject: [PATCH] Initial Commit --- .air.toml | 24 +++ .gitignore | 31 +++ .gitlab-ci.yml | 13 ++ bun.lockb | Bin 0 -> 114943 bytes cmd/assets.go | 118 ++++++++++ cmd/generate.go | 21 ++ cmd/handler.go | 355 +++++++++++++++++++++++++++++++ cmd/root.go | 66 ++++++ cmd/templates/handler.go.tmpl | 81 +++++++ cmd/templates/imports.go.tmpl | 11 + cmd/templates/view.go.tmpl | 9 + config.example.toml | 33 +++ eslint.config.ts | 96 +++++++++ go.mod | 75 +++++++ go.sum | 171 +++++++++++++++ main.go | 12 ++ models/models.go | 80 +++++++ package.json | 44 ++++ postcss.config.ts | 8 + shared/hash.go | 79 +++++++ shared/i18n.go | 36 ++++ shared/json.go | 44 ++++ shared/json_test.go | 23 ++ shared/postgres.go | 24 +++ tailwind.config.ts | 20 ++ tsconfig.json | 26 +++ web/assets/css/styles.css | 31 +++ web/middleware/auth.go | 50 +++++ web/server.go | 264 +++++++++++++++++++++++ web/utils/handlers.go | 33 +++ web/utils/render.go | 15 ++ web/utils/session.go | 1 + web/views/components/utils.templ | 29 +++ web/views/layouts/base.templ | 21 ++ web/views/layouts/error.templ | 16 ++ 35 files changed, 1960 insertions(+) create mode 100644 .air.toml create mode 100644 .gitignore create mode 100644 .gitlab-ci.yml create mode 100755 bun.lockb create mode 100644 cmd/assets.go create mode 100644 cmd/generate.go create mode 100644 cmd/handler.go create mode 100644 cmd/root.go create mode 100644 cmd/templates/handler.go.tmpl create mode 100644 cmd/templates/imports.go.tmpl create mode 100644 cmd/templates/view.go.tmpl create mode 100644 config.example.toml create mode 100644 eslint.config.ts create mode 100644 go.mod create mode 100644 go.sum create mode 100644 main.go create mode 100644 models/models.go create mode 100644 package.json create mode 100644 postcss.config.ts create mode 100644 shared/hash.go create mode 100644 shared/i18n.go create mode 100644 shared/json.go create mode 100644 shared/json_test.go create mode 100644 shared/postgres.go create mode 100644 tailwind.config.ts create mode 100644 tsconfig.json create mode 100644 web/assets/css/styles.css create mode 100644 web/middleware/auth.go create mode 100644 web/server.go create mode 100644 web/utils/handlers.go create mode 100644 web/utils/render.go create mode 100644 web/utils/session.go create mode 100644 web/views/components/utils.templ create mode 100644 web/views/layouts/base.templ create mode 100644 web/views/layouts/error.templ diff --git a/.air.toml b/.air.toml new file mode 100644 index 0000000..3fdcee9 --- /dev/null +++ b/.air.toml @@ -0,0 +1,24 @@ +root = "." +tmp_dir = "tmp" + +[build] +exclude_dir = ["node_modules","build","test-results","tests","playwright-report","tmp"] +exclude_regex = ["_templ\\.go","_test\\.go"] + +pre_cmd = ["rm -rf web/assets/dist/js", "templ generate", "go run -tags dev *.go generate assets", "bun run tailwindcss -i ./web/assets/css/styles.css -o ./web/assets/dist/css/styles.css"] +cmd = "go build -tags dev -o tmp/omnibill_dev *.go" +bin = "tmp/omnibill_dev" +args_bin = ["run"] + +log = "air.log" +delay = 1000 # ms +stop_on_error = true +send_interrupt = false +kill_delay = 500 # ms + + +[misc] +clean_on_exit = true + +[screen] +keep_scroll = true diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..cdcc360 --- /dev/null +++ b/.gitignore @@ -0,0 +1,31 @@ +# Configs +config.toml + +# IDE +.vscode +.idea +.fleet + +# Build +build + +# Packages +node_modules + +# Dist +dist + +# Temp +tmp + +# Compiled templates +*_templ.go + +# Logs +logs + +# Playwright +/test-results/ +/playwright-report/ +/blob-report/ +/playwright/.cache/ diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml new file mode 100644 index 0000000..b733856 --- /dev/null +++ b/.gitlab-ci.yml @@ -0,0 +1,13 @@ +# You can override the included template(s) by including variable overrides +# SAST customization: https://docs.gitlab.com/ee/user/application_security/sast/#customizing-the-sast-settings +# Secret Detection customization: https://docs.gitlab.com/ee/user/application_security/secret_detection/pipeline/#customization +# Dependency Scanning customization: https://docs.gitlab.com/ee/user/application_security/dependency_scanning/#customizing-the-dependency-scanning-settings +# Container Scanning customization: https://docs.gitlab.com/ee/user/application_security/container_scanning/#customizing-the-container-scanning-settings +# Note that environment variables can be set in several places +# See https://docs.gitlab.com/ee/ci/variables/#cicd-variable-precedence +stages: + - test +sast: + stage: test +include: + - template: Security/SAST.gitlab-ci.yml diff --git a/bun.lockb b/bun.lockb new file mode 100755 index 0000000000000000000000000000000000000000..d644c3623715316cdfd3699fce529d7e26ad2979 GIT binary patch literal 114943 zcmeFac{o*F{|9``WR@Xhrp%d2%1q{Y9wPHRPnjcQR4PhI3PqWU%n2b%q>?Cv%9yz# z4SLtg+4r-b=X!p1)ceo-UeCJjeLiOmpU?NZzH8WP@1yJH;o21(7_iI1-Dj7xPY9<& zu+J87iFo-sdb+rKIg7aX26)>CiG+x4!N*`Q%!Lz$<2p0l5qo#k`C8y@?a{cs7*+H| zhq!6f?3As*Hs5AE&Ua&m86-jKSuKc3>M|e7$|#9o)UV%0U4&1`}iN<>&6> z>=CE{%)xvbKyrYEpneNL8$t|*2A~E&27p}z7z`~y6QYfJ3Q!L7y8to+G$Y1f=mE+D z+y;;hl+yt`2Y3*#kZkCG0(mBo9|j29H2{Qmya8?nC_;w8Py!SMd8p?Y;OrFydUG9{ zw*t;Vy+(l0eg!}ZfcgNT|G|!;-tLY8zJ(wI^WILu0bYQA31mQ%7-yd#_aJxYK>Gk! zKl^|{=NO>N2+A!0!hR}a^V& zN(=+G{u|)IIK2VL0o-55x=uT{ylc0Uwr&0cT-810duj0;B?X6x73h*#P7N z=pHEI>gDTzAqLLEc1EBa#?#%^$2Y(kgHgxkUEF<~M1uV=av(UcUIL4LaBABJ0VllN zz1@Q_65BWGr2!B6-wN_j*Tu^|D9G6fg1O0=Y z1Hv#K>>E7iK)(QI;0FU}2j|T=D2M&Cck~T_fguCsus;->NIV>ZT|v2bAou`j9ZW9h z?_oE0M>mjn4|EO%a+vp=8+IGHHs+z9eUO_7aL&=q-pdozALZVNmz#Z{2=LwYFvvq4 zEuM|`7XZS3*?au#4u+d|W4x&V!ucE=;4b0;eDDkQ33K#ya-Ic+aGs3-g!BFpK-jJn ze1I;+T)~!K+wt%9CqX&nfmr^u2jFL1mv?T&!y`~6%--7zGXe524)6Fkupc0_6Ckh= z7dXiFAihU|9&G0W5d0V8Ex55BW`Pkn9}Peu^aqLe3zDP_;Cy%oJU)+?E{^?L!1LJ7$x8@CnzTZ2&P=jKHxX> z{{cW~?+nlcRWZ*bH}ZbIfkBReffzHXjri#RgyZaE@8j$4gu!@#T?J4uNIabb9fIAx zoG``G8{>2yAk>Qo2;&=oEw{zyH2{L88?zfAY{!GGr^eQ=?b)aw2MGC(0Wtxs#g<4T**oX&+g>#@I22%yfVH_qvIh8=JhXcOZp6*We0WN{gA>dvW zSf#U}tDw8l{{UxuCnxs+KYO1ra9(h93k-G)un%;`T+rKylb?G4*mVOj-r>+11{12k z(cd69XCLT-9X9U?5XLtd@ZkOD=VlMuOBim912;g}|91dkT$!+#2m((A^4{(~?tb=x zfmEOz<~5Bs+RFljy1Pv_%1Hsj{`DaU>fyM%m~O=18X(mFU3&uajW{6fk$v|F@E_WD z0|>`6&SK*}?Ew((TMCvN<)$Fd0P@H>b@B@N86R*C_=(7TPzU1y=bJP@*ne>Q`?(HE ztT*E1B66hxQzyngfLCZoN5xB(Hulb~p?LAwwH+gFcv`nC);~4Na0oHB-=3%Au;FFK zSSa{zeH`I^0qk+s<@^j{BjF>hmYzc_DxIc@*W>%!p*k{0#&IuULYwv7%}Yvt$oRuZ*l z=VrX=**uQExzLyC_estCOn_eb!SdH-8sFAhD(%AP>xLfne`SakN$C%=!{ceS!WVtc zReAGOK&~5=tUvEAs_o=%pWUU3p5)9_{U-Z$QB>&uf?WA-_v@LhcYhs{(rW%a)2wj6qLPKO_z<5fQBeDy}a)#gg) zhiZmt2DIu0hDG@dS-N|AjFUCCL=G`~ZEX<7OPbfM7E>2Hk$ztD`I9#W#YbdpINvGU zm7z!_GR+uYWfvu>^ON9zkk-g(}CNU8=X|8`ZeK{sX$Dtk08KQM7 zfL^OPwC%sm&RVjlf0GOMNR6!ar*Ymj8Xf;0p|jboWwgS*S}L0P))c~ zu~Zobsz+|kb+8>PG&6jpXHQev(a$4Gk`R`Z_{qlL`1in0!Ci&&%@YX@Q4(IaKXmrE zxpoK~ia9*yLjCm0$n&ylj-vG`?vJZM3>Ulw*d2;89Q2tVoF*GixK$h~ILaE?+~)H_ z^3xRMZGLA@xr}80-l2{HrUM>~4apLdWnrhy-}tYd&roc8UTd-KZTWdv~S_MuYR8S)5$oD)EG-=`_;;}zN-E-4TET}hN1G?C0*VpSM&9k)vgPhzcQwq z=~rYJIlr%oG-}JVIh(?j<6oWyHm&y=$&&TFjTO^jJUDQm)zPtg=ZApwA-8%;i@{F4 zT`hUcovri^{=9OEfjXY1b}B~`7>O*|^w{DaI!8|*4zL_6)GvK{c&?v$_sN&3)NA=A z_^+#rsU3~3U77vtFnnk$sZ=qSMc$W$AH44=IGM?h@=1s`)`}`i@IPZFHs@vK56O7V z#Z0$FjtXCUD3(W#WKDA{c`&G4uah*e!L+yxpLz+2_YJn;2YnR>R`7=sBhMd`^f^7K ziC6K|F6l9YhSJrK9#fb3VuXbfoE`pi-ia$*<%^7?WqI%2cF|(e@R6zA?2j*}>{puU z2Z-xiCpqLkG?#bM>~T0T zJj46ehy6oS-vF=7>6+G7cW&`y61|ASeggB#ZI)uITaH(!>94g`H4r*vg>H9$GL~$+ ze9-=J=d2ECtWaNmz*EnPgMr61)+?{^Mjq1V;N@<4k$Noqg*l-aMKcml8Xh^7!z3SS z_ULp~=botyC#97as>RFbdax415qZ@#{oeTL>sy)BSJU^;@_3xcN|vCFG8?Nma!4jD zaXUo2FLd%;&~mQA${h#BZ)DUQ^ebL=c|Ts9u2T}EZ zhgGb;ciPcv+m|omMxA?{jpUyQZD*q`d_ZE5sW#=^^7?htrDBP@GLbBF$$2mIy9bN+ z1q+?4Y(KuIwD0(ZxckN$FP90UcVA$=ab%=yvI?Wua@*uZgTuwoUFmai9T}}FBDZ1; zCWa4RJ9dRfP%lqDN=3*6Nfi~Sfe z>)M@6_n!OoE~^tes^nzId}Z5b472X!KRO?F+2ffzwHl%DkiogkPlFh@IjZ3(tH$;$m=GvTM!>X{W zzV_5nriA(A*Q(UF3WH8ONpU(}ZRS3dGtbSD$;`%Wl=?Y%E#CC~nX2mhe&iQ*83JYu z9U}}<`J<1_zYmkuV!Zs!=y}4DMX(Cds?)ofx5vbmKa@;gKXmWJwUtmOW2$yaqhl}1 z*e$fSgb#AibUBKIxtO1rXR*{fP(<-!{fA1Q6f+&2mkAH`^F|~0R{ZteQwcN&y}aV_ zvKQN;{IdJIdIO11MLC||7uw9iM||gy-RSkObzjLVtV%gWXWlb1JWDX`o#hSprLkn= z9^+{ydB>btCo9uo*=E(>wYO~FV>F|+HjA`J z_T3j$GCp(++L{*`YGzmx8a&r%ojup3x-Wz+RSgU(G(_8JI+AlAGkrC--d)BoMR^`s zr%gwlZWm{1n^WB9?l;Udq&+eCA_MpYfJX%`h`1z#uLr&;VfnC6M7WSa8-$;Q zvi~RiUci^a_8**sF;D}SgxL260n-G0$m793{OUIsc!2{Z0zulYxL~ zWBK6xj&nlzwtz2-?LX|pX8WH7_~23Pr~h#7YQ2T^Ha>VEK>$HE>A?e;x3_D}SYo;MLhb{%@9F0r-2d_Hm6J zw1L>40eocr!TE!$2EykDFLU7dBl*qxZw2^p|NhhXp96f@e>m?qqk;JQ7|TcUo9#a_ zn6%*4*3bEi>)L}cLhM-qK6s_}lMmyMl*2Z^N=UmBlzp(3Y<>vrC+`l0gYz3RHyTMOAF);REDdC(D|BJy(b+~^+B2tEn2-0>E z#fLuNx`!fs1ICU1Bl*pqKh6Wb7}kGe4Q$5$2Y`?CA6MU@KH@JSc<6zAxNpL;&F-Iy zfDhL%!pHRb(m5_D{+rV$a zSo_ciaGb#@A^aS`N5&5lp$0Aq;lBcWRjhr;L&|@rfwW@>KT;_IK8!n@yGS{%d|SW| zK-u4{|4#rPjz2Q)o8A9uzz<|Fe$a1dd$aa+03V(|VSgbP@dM_6m5?~406u*G0q6an zoSz!8_K|%9(fyTeSckOx2KX?3xUM@y7vXDwzn6ji$F<-8$xp=c5&x0;-yveZ4e;Ul z=}-Ot2Kcc5$ol=AU8EkdFSq^Q#~-Qx9U}Y)z}Eow;r(wj3E_7GKAgWu{5Ly)$-p0v zApPG=3=sPYfDi9K&^{ak#E;+g1>uJSKAbbOMuKWvtFA4Z?{{PAOtqbrKv3y)@L*J0L44nUZe)yC4=>xt5un*%0`QUXP zP6@Fe%L)Eg2k>DY&LLcVK=`GAFAw;L-Ocjpx&HnA2cipH`DH@dsr?84mn2jr!Vd*} zxPFm5s>v_EBJCdmzB1tB+HXV@;Zt&N+&>^64|^ePeuW7C0N^9z5BW&>ueh)dX?F_n zRk7oTl>Ls2=068~c>lw7-$k?$TNFJ1x_@sbh6vvr@RfmmBoAYNOG4V`13ujUkl5qe z4}?Dg_`3if?!EB&71uo&;mh)3FbA>z5*K3spu{sCU+;FJ*mvjBfT;3IZ78~;AQR|0%!6!st2gxF`?fx(!f z>?3~sP6y$80zSNd!S>))&Zg&21&R+|Wo$bBvw)BE|4+tGWaq~I0sV)4Liz>sze-3u zf53<5hd+IPQULghfWH~vp-@jlvu6;oG5r7Zp@1MrM3h<=?e=}zmaJ{27Ec-v2^Ayf(`}3i$B+ z4aX1iah-#R|4#tl7Vt>{7y6HsgRj3#NIPlajrY%g%1;G+wZE|c6z~-Re=}nTJBIkr zCi1W6Ct^^(+4xxkK3qR!P!uGh4fyRRVm}@5VgHf*X2<^});{zbUWm@`96;L1iT?ZZ zKO9F~62d#9o#Qqno|8Vbswl`~E6&(Ilfqi7$Hp@Q^ z_^|(**}Guh5dR+mKI}iV`zP!F2jIi!H|RgqKy<;^UnZoT9C-Nxe}6}~n;ri&z=zM@ zkPm(e`su?j4Pz@2`z?SE@4t`_6N~{a3E_VR`~!dw^H2jB$KPond`)o-MiFZtDf=B4 z)+24t13p~8Nc=WCe_sPWj6b5cS^ovW!y}wOaQ^+t{$&gJW`O^v`*$Va8~g>I5KJBz zzdyCF1^96N!T94kchKV(5BPe34`T-tG7mR$|Mw>{UV~Ws$liyP|KH4w8l)Wq7(94> zg8hg7gXN4R<5jfb($vL$iNkUmfr@{(_$d z_(Wx(P2Z^l0g@WB=sLkI#$0WK;SNE{M!|L6mJ6YTmU#es)3LHH8j@Q9p$ zkTU?%=2wXDLjWJSfB&lef7T)VBEW~|2iSj@HhX^f0Qky)kLYcdzgzKN{^NS~hJGUU z!T=xI-vY{DALzk_OG5ZmN*m{2$VJLF!?y)|Wc|XvNc%)UHpKc5pWVRY5Kal<_XEBv;KQ{KUM2n_v7rNqTJil2wIkBlAnCizQ%5BD#)?*3%|lm-v~ zN`Mbz&jPN^K0k*7KHUFc{2_O<{VxH0XrBPfh5HCD35ow4;A;UsY#FgI< z_{v!Sq3zA`nYI7D|G%?~)FXCG0Uw#aa1X&XA^Zfuhx2bUTxbK~KLmVue)v;92{^nX z;}10v-QV>CX{Q4CaQ}yST*n@2Bm5}9hv#qT|DUY?RxBUtK@D8vfY|>E_%Qx|>c4^R zzkYwj)i!YRpZ;e7{@?Wf8Hx|%kE;(z|HZ-P4d0(Z9uIU3$As`p0Uw^9APw5z?EQBg z@ZtG|5DI{V>l{GrOX>gX@8@vcyPzF}9|!nw{XpMwwTpzY1tj{|(Lh5qb6)I`SNcYYxDhXEh_1oF@G7d!*uk`TTIm^{ey z1LSSi|7^eqx8Q%`kBe>C2C=^Y_{j4YuC~#95u<2ct6VeXfcw_&DcK^h_CEzOq`_LHdKduR}e-UdRIrkvtztch5 z9RiaF-hVc;_MtYyzX15~{)epn&5qv)mXFN)&GK1IF&IT)AI2W~wAt~G0(^M>$CU?6 z{7gvydjTKLeL&3twh?I0zQmC{QDDFhLrzK18J9z<-`8NGF)ST z@Sg%c96vb!Aa}ETa*KaG|H3(ltOM}%mkF_-1NdMIi-C4=(3SHt=b%QhRoCBTRGPsn2c7qSoGYMKv4{pJI) z?+y6y{R?akV}Se}7|j1FA^bVON6t^1^??HzfEP><;rMZ4kqaP95b3}L{!HQL*zN%c z+eu>)-1~nfh;Zz|S>k6u6am7d`15lEVgC+*3;M2(Er$qspbtNF!Q+tN)I$z6o4V?;f@tL|EQ}#a1k~ z0fY%6tiKN~$m;+XOb}uD1DFBnzagxD__KBcVcfc~d5EyS8=Hp+^?Set%b#HJDL|MY zLjH4b!TLUM!2}WJU%(7VXoPwL;DYrp!37gUn16-EL4Ytpg!5?xWh(-8y3;2LYjztQX0SS$;h!T82 zJ~g%-4YnObSWbs6--<2&ZwTAL|K9){CuV@qFLrEuh%nEC%|nE7LH-93xZpO>ULMQ; z|1!e(D1&~$c&TFhgGN}chAl@U2`Jaamj4Q&z9DD_<72l2|0{&;+OX{)LciLvd0Y^-e}HX|Mp)j7EeBtJo}D=07my&rDo!kN0fY(S|IShW zJ4eCu6`UXcJ4b;afCSG|a6SF+90jAXai03$IqKKvs{fs%{&$Z0-#P04Z|A6W>UDTy z{C_h6Jv_{YE?Sx}Hs)4-9oM+X1L__6BAH(jcq=7BjF-rcF&6CdxuN2`V=?%G#Wb;5 zTMqY+Yg}BC$HpR;dCK+^dmle|CABCZ^b$%J-n)=Q=+{I>sMvflf!$21dz|ckW*euN zvsjzuCzg-9?gV*9;yo`RcWwy`XCDa7wP_fy#NYKBm{@a&2t z!WaFAL>(P1d`iCwQJ)=%j!rGGvyu+TKFW@

Gf}n=icc-3c~IC;W6$&j;OKM59Ke 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

_qmV@8jC2&ByPL+IjSw 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 +*/ +package cmd + +import ( + "errors" + "github.com/evanw/esbuild/pkg/api" + "github.com/kr/pretty" + "io" + "io/fs" + "log" + "os" + "path/filepath" + "strings" + + "github.com/spf13/cobra" +) + +var ignoredAssetExtensions = []string{ + ".css", + ".js", + ".ts", +} + +// assetsCmd represents the assets command +var assetsCmd = &cobra.Command{ + Use: "assets", + Short: "Generates assets needed for webserver", + Run: func(cmd *cobra.Command, args []string) { + err := filepath.Walk("./web/assets", func(path string, info os.FileInfo, err error) error { + + if info.IsDir() { + return nil + } + + relPath, _ := strings.CutPrefix(path, "web/assets/") + + if strings.HasPrefix(relPath, "dist") { + return nil + } + + for _, ext := range ignoredAssetExtensions { + if strings.HasSuffix(info.Name(), ext) { + return nil + } + } + + distFolder, _ := strings.CutSuffix(relPath, info.Name()) + + if err := os.MkdirAll("web/assets/dist/"+distFolder, 0600); err != nil && !errors.Is(err, fs.ErrExist) { + log.Fatalln(err) + } + + copyFile(path, filepath.Join("web/assets/dist/"+distFolder, info.Name())) + + return nil + + }) + if err != nil { + log.Fatalln(err) + } + + result := api.Build(api.BuildOptions{ + Format: api.FormatESModule, + EntryPoints: []string{"./web/assets/js/*.ts", "./web/assets/js/**/*.ts"}, + Outdir: "./web/assets/dist/js", + Sourcemap: api.SourceMapLinked, + MinifyWhitespace: true, + MinifyIdentifiers: true, + MinifySyntax: true, + Splitting: true, + Write: true, + TreeShaking: api.TreeShakingTrue, + Bundle: true, + }) + if len(result.Errors) != 0 { + pretty.Println(result.Errors) + os.Exit(1) + } + }, +} + +func init() { + generateCmd.AddCommand(assetsCmd) +} + +func copyFile(sourcePath, destinationPath string) { + sourcePath = filepath.Clean(sourcePath) + sourceFile, err := os.Open(sourcePath) + if err != nil { + log.Fatalln(err) + } + defer sourceFile.Close() + + destinationPath = filepath.Clean(destinationPath) + destinationFile, err := os.Create(destinationPath) + if err != nil { + log.Fatalln(err) + } + defer destinationFile.Close() + + _, err = io.Copy(destinationFile, sourceFile) + if err != nil { + log.Fatalln(err) + } + + if err := sourceFile.Close(); err != nil { + log.Fatalln(err) + } + if err := destinationFile.Close(); err != nil { + log.Fatalln(err) + } + +} diff --git a/cmd/generate.go b/cmd/generate.go new file mode 100644 index 0000000..975789d --- /dev/null +++ b/cmd/generate.go @@ -0,0 +1,21 @@ +//go:build dev + +/* +Package cmd +Copyright © 2024 Shane C. +*/ +package cmd + +import ( + "github.com/spf13/cobra" +) + +// generateCmd represents the generate command +var generateCmd = &cobra.Command{ + Use: "generate", + Short: "Generates things for the panel", +} + +func init() { + rootCmd.AddCommand(generateCmd) +} diff --git a/cmd/handler.go b/cmd/handler.go new file mode 100644 index 0000000..0e139d6 --- /dev/null +++ b/cmd/handler.go @@ -0,0 +1,355 @@ +//go:build dev + +/* +Package cmd +Copyright © 2024 Shane C. +*/ +package cmd + +import ( + "bytes" + "errors" + "fmt" + "github.com/spf13/cobra" + "gitlab.com/omnibill/linux" + "gitlab.com/omnibill/tui/confirmation" + "gitlab.com/omnibill/tui/textinput" + "golang.org/x/text/cases" + "golang.org/x/text/language" + "io/fs" + "log" + "os" + "path/filepath" + "strings" + "text/template" +) + +type templateData struct { + PackagePath string + UpperName string + Name string + Path string + RequireAuth bool + GetView bool + GetNoView bool + Post bool + Put bool + Delete bool + Options bool + Head bool + Patch bool +} + +// handlerCmd represents the handler command +var handlerCmd = &cobra.Command{ + Use: "handler", + Short: "A brief description of your command", + Run: func(cmd *cobra.Command, args []string) { + tmplFile, err := templates.ReadFile("templates/handler.go.tmpl") + if err != nil { + log.Fatalln(err) + } + + viewFile, err := templates.ReadFile("templates/view.go.tmpl") + if err != nil { + log.Fatalln(err) + } + + importsFile, err := templates.ReadFile("templates/imports.go.tmpl") + if err != nil { + log.Fatalln(err) + } + + handlerTempl, err := template.New("handler").Parse(string(tmplFile)) + if err != nil { + log.Fatalln(err) + } + + viewTempl, err := template.New("view").Parse(string(viewFile)) + if err != nil { + log.Fatalln(err) + } + + importsTempl, err := template.New("imports").Parse(string(importsFile)) + if err != nil { + log.Fatalln(err) + } + + tmplData := templateData{} + + inputHandlerPath, err := textinput.New(textinput.InputData{ + Question: "Path of handler?", + }) + if err != nil { + log.Fatalln(err) + } + + pathSplit := strings.Split(*inputHandlerPath, "/") + + tmplData.UpperName = cases.Title(language.AmericanEnglish).String(pathSplit[len(pathSplit)-1]) + tmplData.Name = pathSplit[len(pathSplit)-1] + tmplData.Path = *inputHandlerPath + + if len(pathSplit) > 1 { + tmplData.PackagePath = pathSplit[len(pathSplit)-2] + } else { + tmplData.PackagePath = strings.Join(pathSplit, "") + } + + hasView, err := confirmation.New(confirmation.InputData{ + Question: "Does this handler need a view?", + }) + if err != nil { + log.Fatalln(err) + } + + if *hasView { + tmplData.GetView = true + } else { + hasGetView, err := confirmation.New(confirmation.InputData{ + Question: "Does this handler need a GET handler?", + }) + if err != nil { + log.Fatalln(err) + } + tmplData.GetNoView = *hasGetView + } + + hasAuth, err := confirmation.New(confirmation.InputData{ + Question: "Does this handler need a AUTH handler?", + }) + if err != nil { + log.Fatalln(err) + } + tmplData.RequireAuth = *hasAuth + + hasPost, err := confirmation.New(confirmation.InputData{ + Question: "Does this handler need a POST handler?", + }) + if err != nil { + log.Fatalln(err) + } + tmplData.Post = *hasPost + + hasPut, err := confirmation.New(confirmation.InputData{ + Question: "Does this handler need a PUT handler?", + }) + if err != nil { + log.Fatalln(err) + } + tmplData.Put = *hasPut + + hasDelete, err := confirmation.New(confirmation.InputData{ + Question: "Does this handler need a DELETE handler?", + }) + if err != nil { + log.Fatalln(err) + } + tmplData.Delete = *hasDelete + + hasOptions, err := confirmation.New(confirmation.InputData{ + Question: "Does this handler need a OPTIONS handler?", + }) + if err != nil { + log.Fatalln(err) + } + tmplData.Options = *hasOptions + + hasHead, err := confirmation.New(confirmation.InputData{ + Question: "Does this handler need a HEAD handler?", + }) + if err != nil { + log.Fatalln(err) + } + tmplData.Head = *hasHead + + hasPatch, err := confirmation.New(confirmation.InputData{ + Question: "Does this handler need a PATCH handler?", + }) + if err != nil { + log.Fatalln(err) + } + tmplData.Patch = *hasPatch + + cwd, err := os.Getwd() + if err != nil { + log.Fatalln(err) + } + + handlerDir := filepath.Join(cwd, "web/handlers") + viewDir := filepath.Join(cwd, "web/views") + + if *hasView { + for i, _ := range pathSplit { + isLast := i == len(pathSplit)-1 + path := filepath.Join(append([]string{viewDir}, pathSplit[0:i+1]...)...) + + if _, err := os.Lstat(path); err != nil && !errors.Is(err, fs.ErrNotExist) { + log.Fatalln(err) + } else if err != nil && errors.Is(err, fs.ErrNotExist) { + if err := os.MkdirAll(path, 0740); err != nil { + log.Fatalln(err) + } + } + + if isLast { + viewFileOut, err := os.Create(path + "/show.templ") + if err != nil { + log.Fatalln(err) + } + defer viewFileOut.Close() + + var buffer bytes.Buffer + if err := viewTempl.Execute(&buffer, tmplData); err != nil { + log.Fatalln(err) + } + if _, err := viewFileOut.Write(buffer.Bytes()); err != nil { + log.Fatalln(err) + } + viewFileOut.Close() + } + } + } + + templCommand, err := linux.NewCommand(linux.CommandOptions{ + Env: map[string]string{ + "PATH": os.Getenv("PATH"), + }, + Command: "templ", + Args: []string{"generate"}, + Shell: "/bin/bash", + }) + if err != nil { + log.Fatalln(err) + } + + if err := templCommand.Run(); err != nil { + log.Fatalln(err) + } + + for i, _ := range pathSplit { + isLast := i == len(pathSplit)-1 + path := filepath.Join(append([]string{handlerDir}, pathSplit[0:i+1]...)...) + + if _, err := os.Lstat(path + ".go"); err != nil && !errors.Is(err, fs.ErrNotExist) { + log.Fatalln(err) + } else if err == nil { + if err := os.MkdirAll(path, 0740); err != nil { + log.Fatalln(err) + } + if err := os.Rename(path+".go", path+"/index.go"); err != nil { + log.Fatalln(err) + } + } + + if _, err := os.Lstat(path); err != nil && !errors.Is(err, fs.ErrNotExist) { + log.Fatalln(err) + } else if err != nil && errors.Is(err, fs.ErrNotExist) && !isLast { + if err := os.MkdirAll(path, 0740); err != nil { + log.Fatalln(err) + } + } else if err != nil && errors.Is(err, fs.ErrNotExist) && isLast { + handlerFileOut, err := os.Create(path + ".go") + if err != nil { + log.Fatalln(err) + } + defer handlerFileOut.Close() + + var buffer bytes.Buffer + if err := handlerTempl.Execute(&buffer, tmplData); err != nil { + log.Fatalln(err) + } + if _, err := handlerFileOut.Write(buffer.Bytes()); err != nil { + log.Fatalln(err) + } + handlerFileOut.Close() + } else if err == nil && isLast { + handlerFileOut, err := os.Create(path + "/index.go") + if err != nil { + log.Fatalln(err) + } + defer handlerFileOut.Close() + + var buffer bytes.Buffer + if err := handlerTempl.Execute(&buffer, tmplData); err != nil { + log.Fatalln(err) + } + if _, err := handlerFileOut.Write(buffer.Bytes()); err != nil { + log.Fatalln(err) + } + handlerFileOut.Close() + } + + } + + fmt.Println("Generating Imports") + + var imports []string + + if err := filepath.WalkDir(handlerDir, func(path string, d fs.DirEntry, err error) error { + if strings.HasSuffix(d.Name(), ".go") { + folder := filepath.Dir(path) + isFound := false + + if folder == handlerDir { + return nil + } + + output, _ := strings.CutPrefix(folder, cwd) + + for _, handlerImport := range imports { + if handlerImport == "omnibill.net/omnibill"+output { + isFound = true + } + } + + if !isFound { + imports = append(imports, "omnibill.net/omnibill"+output) + } + } + + return nil + }); err != nil { + log.Fatalln(err) + } + + if len(imports) != 0 { + importFileOut, err := os.Create(handlerDir + "/imports.go") + if err != nil { + log.Fatalln(err) + } + defer importFileOut.Close() + + var buffer bytes.Buffer + if err := importsTempl.Execute(&buffer, map[string]interface{}{ + "Imports": imports, + }); err != nil { + log.Fatalln(err) + } + if _, err := importFileOut.Write(buffer.Bytes()); err != nil { + log.Fatalln(err) + } + importFileOut.Close() + } + + fmt.Println("Go formatting") + + fmtCommand, err := linux.NewCommand(linux.CommandOptions{ + Command: "go", + Args: []string{"fmt", "./..."}, + Shell: "/bin/bash", + }) + if err != nil { + log.Fatalln(err) + } + + if err := fmtCommand.Run(); err != nil { + log.Fatalln(err) + } + + }, +} + +func init() { + generateCmd.AddCommand(handlerCmd) +} diff --git a/cmd/root.go b/cmd/root.go new file mode 100644 index 0000000..f2ef5d9 --- /dev/null +++ b/cmd/root.go @@ -0,0 +1,66 @@ +/* +Package cmd +Copyright © 2024 Shane C. +*/ +package cmd + +import ( + "embed" + "fmt" + "github.com/spf13/cobra" + "github.com/spf13/viper" + "os" +) + +var cfgFile string + +//go:embed templates/* +var templates embed.FS + +// rootCmd represents the base command when called without any subcommands +var rootCmd = &cobra.Command{ + Use: "omnibill", + Short: "A brief description of your application", + Long: `A longer description that spans multiple lines and likely contains +examples and usage of using your application. For example: + +Cobra is a CLI library for Go that empowers applications. +This application is a tool to generate the needed files +to quickly create a Cobra application.`, + // Uncomment the following line if your bare application + // has an action associated with it: + Run: func(cmd *cobra.Command, args []string) { + }, +} + +// Execute adds all child commands to the root command and sets flags appropriately. +// This is called by main.main(). It only needs to happen once to the rootCmd. +func Execute() { + err := rootCmd.Execute() + if err != nil { + os.Exit(1) + } +} + +func init() { + cobra.OnInitialize(initConfig) + rootCmd.PersistentFlags().StringVar(&cfgFile, "config", "", "config file (default is $HOME/.omnibill.yaml)") +} + +// initConfig reads in config file and ENV variables if set. +func initConfig() { + viper.SetConfigType("toml") + if cfgFile != "" { + viper.SetConfigFile(cfgFile) + } else { + viper.AddConfigPath("/etc/omnibill") + viper.SetConfigName("config") + } + + viper.AutomaticEnv() // read in environment variables that match + + // If a config file is found, read it in. + if err := viper.ReadInConfig(); err == nil { + fmt.Fprintln(os.Stderr, "Using config file:", viper.ConfigFileUsed()) + } +} diff --git a/cmd/templates/handler.go.tmpl b/cmd/templates/handler.go.tmpl new file mode 100644 index 0000000..fdc7f81 --- /dev/null +++ b/cmd/templates/handler.go.tmpl @@ -0,0 +1,81 @@ +package {{.PackagePath}} + +import ( + "github.com/go-webauthn/webauthn/webauthn" + "github.com/gofiber/fiber/v2" + "github.com/gofiber/fiber/v2/middleware/session" + "github.com/uptrace/bun" + "go.uber.org/zap" + "omnibill.net/omnibill/web/utils" + {{- if .GetView }} + PAGE_VIEW "omnibill.net/omnibill/web/views/{{.Path}}" + {{ end }} +) + +func init() { + utils.Handlers = append(utils.Handlers, &{{.UpperName}}Handler{ + Path: "{{.Path}}", + }) +} + +type {{.UpperName}}Handler struct { + Path string {{ if .RequireAuth }}`omnibill:"requireAuth"`{{ end }} + Db *bun.DB + Logger *zap.Logger + + AuthSessionStore *session.Store + SessionStore *session.Store + + Session *session.Session + AuthSession *session.Session + + WebAuthn *webauthn.WebAuthn +} + +{{- if .GetView }} +func (h {{.UpperName}}Handler) Get(c *fiber.Ctx) error { + return utils.Render(c, PAGE_VIEW.Show()) +} +{{ end -}} + +{{- if .GetNoView }} +func (h {{.UpperName}}Handler) Get(c *fiber.Ctx) error { + return nil +} +{{ end -}} + +{{- if .Post }} +func (h {{.UpperName}}Handler) Post(c *fiber.Ctx) error { + return nil +} +{{ end -}} + +{{- if .Put }} +func (h {{.UpperName}}Handler) Put(c *fiber.Ctx) error { + return nil +} +{{ end -}} + +{{- if .Delete }} +func (h {{.UpperName}}Handler) Delete(c *fiber.Ctx) error { + return nil +} +{{ end -}} + +{{- if .Options }} +func (h {{.UpperName}}Handler) Options(c *fiber.Ctx) error { + return nil +} +{{ end -}} + +{{- if .Head }} +func (h {{.UpperName}}Handler) Head(c *fiber.Ctx) error { + return nil +} +{{ end -}} + +{{- if .Patch }} +func (h {{.UpperName}}Handler) Patch(c *fiber.Ctx) error { + return nil +} +{{ end -}} diff --git a/cmd/templates/imports.go.tmpl b/cmd/templates/imports.go.tmpl new file mode 100644 index 0000000..6f91345 --- /dev/null +++ b/cmd/templates/imports.go.tmpl @@ -0,0 +1,11 @@ +// Code generated by Omnibill - DO NOT EDIT + +package handlers + +{{ if ne (len .Imports) 0 }} +import ( + {{- range .Imports }} + _ "{{.}}" + {{ end -}} +) +{{ end }} \ No newline at end of file diff --git a/cmd/templates/view.go.tmpl b/cmd/templates/view.go.tmpl new file mode 100644 index 0000000..2e7314b --- /dev/null +++ b/cmd/templates/view.go.tmpl @@ -0,0 +1,9 @@ +package {{.PackagePath}} + +import "omnibill.net/omnibill/web/views/layouts" + +templ Show() { + @layouts.Base(nil) { +

{{.Name}}

+ } +} \ No newline at end of file diff --git a/config.example.toml b/config.example.toml new file mode 100644 index 0000000..004ac20 --- /dev/null +++ b/config.example.toml @@ -0,0 +1,33 @@ +[omnibill] +debug = false +domain = "" +display_name = "Omnibill" + +# Webserver Settings +[omnibill.webserver] +use_https = false +proxy = "" +port = 9000 + +# Database Settings +[omnibill.database] +host = "127.0.0.1" +port = 5432 +database = "omnibill" + +username = "omnibill" +password = "" + +# Mailer Settings +[omnibill.mailer] +enabled = false + +host = "127.0.0.1" +encryption = "tls" # Can be "none", "tls", or "starttls" +port = 587 + +username = "" +password = "" + +from_name = "Omnibill Panel" +from_addr = "omnibill@example.com" diff --git a/eslint.config.ts b/eslint.config.ts new file mode 100644 index 0000000..5790540 --- /dev/null +++ b/eslint.config.ts @@ -0,0 +1,96 @@ +import eslint from '@eslint/js'; +import tseslint from 'typescript-eslint'; +import tsParser from '@typescript-eslint/parser'; +import globals from 'globals'; + +export default tseslint.config({ + extends: [ + eslint.configs.recommended, + ...tseslint.configs.recommended, + ], + files: ['**/*.ts'], + languageOptions: { + parserOptions: { + ecmaVersion: 2022, + sourceType: 'module', + parser: tsParser, + }, + globals: { + ...globals.browser, + ...globals.node, + }, + }, + rules: { + 'arrow-spacing': ['warn', { + before: true, + after: true, + }], + + 'brace-style': ['error', 'stroustrup', { + allowSingleLine: true, + }], + + 'comma-dangle': ['error', 'always-multiline'], + 'comma-spacing': 'error', + 'comma-style': 'error', + curly: ['error', 'multi-line', 'consistent'], + 'dot-location': ['error', 'property'], + 'handle-callback-err': 'off', + indent: ['error', 'tab'], + 'keyword-spacing': 'error', + + 'max-nested-callbacks': ['error', { + max: 4, + }], + + 'max-statements-per-line': ['error', { + max: 2, + }], + + 'no-console': 'off', + 'no-empty-function': 'error', + 'no-floating-decimal': 'error', + 'no-inline-comments': 'error', + 'no-lonely-if': 'error', + 'no-multi-spaces': 'error', + + 'no-multiple-empty-lines': ['error', { + max: 2, + maxEOF: 1, + maxBOF: 0, + }], + + 'no-shadow': ['error', { + allow: ['err', 'resolve', 'reject'], + }], + + 'no-trailing-spaces': ['error'], + 'no-var': 'error', + + '@typescript-eslint/no-unused-vars': ['error', { + argsIgnorePattern: '^_', + destructuredArrayIgnorePattern: '^_', + varsIgnorePattern: '^_', + }], + + 'no-unused-vars': 'off', + 'object-curly-spacing': ['error', 'always'], + 'prefer-const': 'error', + quotes: ['error', 'single'], + semi: ['error', 'always'], + 'space-before-blocks': 'error', + + 'space-before-function-paren': ['error', { + anonymous: 'never', + named: 'never', + asyncArrow: 'always', + }], + + 'space-in-parens': 'error', + 'space-infix-ops': 'error', + 'space-unary-ops': 'error', + 'spaced-comment': 'error', + yoda: 'error', + }, + }, +); \ No newline at end of file diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..6b91ef0 --- /dev/null +++ b/go.mod @@ -0,0 +1,75 @@ +module omnibill.net/omnibill + +go 1.23.2 + +require ( + github.com/go-webauthn/webauthn v0.11.2 + github.com/gofiber/fiber/v2 v2.52.5 + github.com/kr/pretty v0.3.1 + github.com/spf13/cobra v1.8.1 + github.com/spf13/viper v1.19.0 + github.com/uptrace/bun v1.2.5 + go.uber.org/zap v1.27.0 +) + +require ( + github.com/a-h/templ v0.2.793 // indirect + github.com/andybalholm/brotli v1.1.1 // indirect + github.com/evanw/esbuild v0.24.0 // indirect + github.com/fsnotify/fsnotify v1.7.0 // indirect + github.com/fxamacker/cbor/v2 v2.7.0 // indirect + github.com/go-git/go-billy/v5 v5.6.0 // indirect + github.com/go-webauthn/x v0.1.14 // indirect + github.com/goccy/go-json v0.10.3 // indirect + github.com/gofiber/storage/postgres/v3 v3.0.0 // indirect + github.com/golang-jwt/jwt/v5 v5.2.1 // indirect + github.com/google/go-tpm v0.9.1 // indirect + github.com/google/uuid v1.6.0 // indirect + github.com/hashicorp/hcl v1.0.0 // indirect + github.com/inconshreveable/mousetrap v1.1.0 // indirect + github.com/jackc/pgpassfile v1.0.0 // indirect + github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect + github.com/jackc/pgx/v5 v5.7.1 // indirect + github.com/jackc/puddle/v2 v2.2.2 // indirect + github.com/jinzhu/inflection v1.0.0 // indirect + github.com/klauspost/compress v1.17.11 // indirect + github.com/kr/text v0.2.0 // indirect + github.com/magiconair/properties v1.8.7 // indirect + github.com/mattn/go-colorable v0.1.13 // indirect + github.com/mattn/go-isatty v0.0.20 // indirect + github.com/mattn/go-runewidth v0.0.16 // indirect + github.com/mitchellh/mapstructure v1.5.0 // indirect + github.com/nicksnyder/go-i18n/v2 v2.4.1 // indirect + github.com/pelletier/go-toml/v2 v2.2.2 // indirect + github.com/puzpuzpuz/xsync/v3 v3.4.0 // indirect + github.com/rivo/uniseg v0.4.7 // indirect + github.com/rogpeppe/go-internal v1.13.1 // indirect + github.com/sagikazarmark/locafero v0.4.0 // indirect + github.com/sagikazarmark/slog-shim v0.1.0 // indirect + github.com/sourcegraph/conc v0.3.0 // indirect + github.com/spf13/afero v1.11.0 // indirect + github.com/spf13/cast v1.6.0 // indirect + github.com/spf13/pflag v1.0.5 // indirect + github.com/subosito/gotenv v1.6.0 // indirect + github.com/tmthrgd/go-hex v0.0.0-20190904060850-447a3041c3bc // indirect + github.com/ulikunitz/xz v0.5.12 // indirect + github.com/valyala/bytebufferpool v1.0.0 // indirect + github.com/valyala/fasthttp v1.57.0 // indirect + github.com/valyala/tcplisten v1.0.0 // indirect + github.com/vmihailenco/msgpack/v5 v5.4.1 // indirect + github.com/vmihailenco/tagparser/v2 v2.0.0 // indirect + github.com/x448/float16 v0.8.4 // indirect + gitlab.com/omnibill/archiver v1.0.0 // indirect + gitlab.com/omnibill/linux v1.0.0 // indirect + gitlab.com/omnibill/tui v1.0.1 // indirect + go.uber.org/atomic v1.9.0 // indirect + go.uber.org/multierr v1.11.0 // indirect + golang.org/x/crypto v0.28.0 // indirect + golang.org/x/exp v0.0.0-20240719175910-8a7402abbf56 // indirect + golang.org/x/sync v0.8.0 // indirect + golang.org/x/sys v0.26.0 // indirect + golang.org/x/term v0.25.0 // indirect + golang.org/x/text v0.19.0 // indirect + gopkg.in/ini.v1 v1.67.0 // indirect + gopkg.in/yaml.v3 v3.0.1 // indirect +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..a03ceaf --- /dev/null +++ b/go.sum @@ -0,0 +1,171 @@ +github.com/a-h/templ v0.2.793 h1:Io+/ocnfGWYO4VHdR0zBbf39PQlnzVCVVD+wEEs6/qY= +github.com/a-h/templ v0.2.793/go.mod h1:lq48JXoUvuQrU0VThrK31yFwdRjTCnIE5bcPCM9IP1w= +github.com/andybalholm/brotli v1.1.1 h1:PR2pgnyFznKEugtsUo0xLdDop5SKXd5Qf5ysW+7XdTA= +github.com/andybalholm/brotli v1.1.1/go.mod h1:05ib4cKhjx3OQYUY22hTVd34Bc8upXjOLL2rKwwZBoA= +github.com/cpuguy83/go-md2man/v2 v2.0.4/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o= +github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E= +github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/evanw/esbuild v0.24.0 h1:GZ78naTLp7FKr+K7eNuM/SLs5maeiHYRPsTg6kmdsSE= +github.com/evanw/esbuild v0.24.0/go.mod h1:D2vIQZqV/vIf/VRHtViaUtViZmG7o+kKmlBfVQuRi48= +github.com/fsnotify/fsnotify v1.7.0 h1:8JEhPFa5W2WU7YfeZzPNqzMP6Lwt7L2715Ggo0nosvA= +github.com/fsnotify/fsnotify v1.7.0/go.mod h1:40Bi/Hjc2AVfZrqy+aj+yEI+/bRxZnMJyTJwOpGvigM= +github.com/fxamacker/cbor/v2 v2.7.0 h1:iM5WgngdRBanHcxugY4JySA0nk1wZorNOpTgCMedv5E= +github.com/fxamacker/cbor/v2 v2.7.0/go.mod h1:pxXPTn3joSm21Gbwsv0w9OSA2y1HFR9qXEeXQVeNoDQ= +github.com/go-git/go-billy/v5 v5.6.0 h1:w2hPNtoehvJIxR00Vb4xX94qHQi/ApZfX+nBE2Cjio8= +github.com/go-git/go-billy/v5 v5.6.0/go.mod h1:sFDq7xD3fn3E0GOwUSZqHo9lrkmx8xJhA0ZrfvjBRGM= +github.com/go-webauthn/webauthn v0.11.2 h1:Fgx0/wlmkClTKlnOsdOQ+K5HcHDsDcYIvtYmfhEOSUc= +github.com/go-webauthn/webauthn v0.11.2/go.mod h1:aOtudaF94pM71g3jRwTYYwQTG1KyTILTcZqN1srkmD0= +github.com/go-webauthn/x v0.1.14 h1:1wrB8jzXAofojJPAaRxnZhRgagvLGnLjhCAwg3kTpT0= +github.com/go-webauthn/x v0.1.14/go.mod h1:UuVvFZ8/NbOnkDz3y1NaxtUN87pmtpC1PQ+/5BBQRdc= +github.com/goccy/go-json v0.10.3 h1:KZ5WoDbxAIgm2HNbYckL0se1fHD6rz5j4ywS6ebzDqA= +github.com/goccy/go-json v0.10.3/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M= +github.com/gofiber/fiber/v2 v2.52.5 h1:tWoP1MJQjGEe4GB5TUGOi7P2E0ZMMRx5ZTG4rT+yGMo= +github.com/gofiber/fiber/v2 v2.52.5/go.mod h1:KEOE+cXMhXG0zHc9d8+E38hoX+ZN7bhOtgeF2oT6jrQ= +github.com/gofiber/storage/postgres/v3 v3.0.0 h1:zV2e54PmCO1isZcnWufZ6DlId2FwsRAJgW7WxOK+ei8= +github.com/gofiber/storage/postgres/v3 v3.0.0/go.mod h1:TB7QJeilUS/FGvbwis6lY4tcGOLdAHJt7M11GNGXobA= +github.com/golang-jwt/jwt/v5 v5.2.1 h1:OuVbFODueb089Lh128TAcimifWaLhJwVflnrgM17wHk= +github.com/golang-jwt/jwt/v5 v5.2.1/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk= +github.com/google/go-tpm v0.9.1 h1:0pGc4X//bAlmZzMKf8iz6IsDo1nYTbYJ6FZN/rg4zdM= +github.com/google/go-tpm v0.9.1/go.mod h1:h9jEsEECg7gtLis0upRBQU+GhYVH6jMjrFxI8u6bVUY= +github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= +github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/hashicorp/hcl v1.0.0 h1:0Anlzjpi4vEasTeNFn2mLJgTSwt0+6sfsiTG8qcWGx4= +github.com/hashicorp/hcl v1.0.0/go.mod h1:E5yfLk+7swimpb2L/Alb/PJmXilQ/rhwaUYs4T20WEQ= +github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= +github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= +github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM= +github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg= +github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo= +github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM= +github.com/jackc/pgx/v5 v5.7.1 h1:x7SYsPBYDkHDksogeSmZZ5xzThcTgRz++I5E+ePFUcs= +github.com/jackc/pgx/v5 v5.7.1/go.mod h1:e7O26IywZZ+naJtWWos6i6fvWK+29etgITqrqHLfoZA= +github.com/jackc/puddle/v2 v2.2.2 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo= +github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4= +github.com/jinzhu/inflection v1.0.0 h1:K317FqzuhWc8YvSVlFMCCUb36O/S9MCKRDI7QkRKD/E= +github.com/jinzhu/inflection v1.0.0/go.mod h1:h+uFLlag+Qp1Va5pdKtLDYj+kHp5pxUVkryuEj+Srlc= +github.com/klauspost/compress v1.17.11 h1:In6xLpyWOi1+C7tXUUWv2ot1QvBjxevKAaI6IXrJmUc= +github.com/klauspost/compress v1.17.11/go.mod h1:pMDklpSncoRMuLFrf1W9Ss9KT+0rH90U12bZKk7uwG0= +github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= +github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= +github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= +github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= +github.com/magiconair/properties v1.8.7 h1:IeQXZAiQcpL9mgcAe1Nu6cX9LLw6ExEHKjN0VQdvPDY= +github.com/magiconair/properties v1.8.7/go.mod h1:Dhd985XPs7jluiymwWYZ0G4Z61jb3vdS329zhj2hYo0= +github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA= +github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg= +github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM= +github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= +github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= +github.com/mattn/go-runewidth v0.0.16 h1:E5ScNMtiwvlvB5paMFdw9p4kSQzbXFikJ5SQO6TULQc= +github.com/mattn/go-runewidth v0.0.16/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w= +github.com/mitchellh/mapstructure v1.5.0 h1:jeMsZIYE/09sWLaz43PL7Gy6RuMjD2eJVyuac5Z2hdY= +github.com/mitchellh/mapstructure v1.5.0/go.mod h1:bFUtVrKA4DC2yAKiSyO/QUcy7e+RRV2QTWOzhPopBRo= +github.com/nicksnyder/go-i18n/v2 v2.4.1 h1:zwzjtX4uYyiaU02K5Ia3zSkpJZrByARkRB4V3YPrr0g= +github.com/nicksnyder/go-i18n/v2 v2.4.1/go.mod h1:++Pl70FR6Cki7hdzZRnEEqdc2dJt+SAGotyFg/SvZMk= +github.com/pelletier/go-toml/v2 v2.2.2 h1:aYUidT7k73Pcl9nb2gScu7NSrKCSHIDE89b3+6Wq+LM= +github.com/pelletier/go-toml/v2 v2.2.2/go.mod h1:1t835xjRzz80PqgE6HHgN2JOsmgYu/h4qDAS4n929Rs= +github.com/pkg/diff v0.0.0-20210226163009-20ebb0f2a09e/go.mod h1:pJLUxLENpZxwdsKMEsNbx1VGcRFpLqf3715MtcvvzbA= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/puzpuzpuz/xsync/v3 v3.4.0 h1:DuVBAdXuGFHv8adVXjWWZ63pJq+NRXOWVXlKDBZ+mJ4= +github.com/puzpuzpuz/xsync/v3 v3.4.0/go.mod h1:VjzYrABPabuM4KyBh1Ftq6u8nhwY5tBPKP9jpmh0nnA= +github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= +github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ= +github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88= +github.com/rogpeppe/go-internal v1.9.0/go.mod h1:WtVeX8xhTBvf0smdhujwtBcq4Qrzq/fJaraNFVN+nFs= +github.com/rogpeppe/go-internal v1.13.1 h1:KvO1DLK/DRN07sQ1LQKScxyZJuNnedQ5/wKSR38lUII= +github.com/rogpeppe/go-internal v1.13.1/go.mod h1:uMEvuHeurkdAXX61udpOXGD/AzZDWNMNyH2VO9fmH0o= +github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= +github.com/sagikazarmark/locafero v0.4.0 h1:HApY1R9zGo4DBgr7dqsTH/JJxLTTsOt7u6keLGt6kNQ= +github.com/sagikazarmark/locafero v0.4.0/go.mod h1:Pe1W6UlPYUk/+wc/6KFhbORCfqzgYEpgQ3O5fPuL3H4= +github.com/sagikazarmark/slog-shim v0.1.0 h1:diDBnUNK9N/354PgrxMywXnAwEr1QZcOr6gto+ugjYE= +github.com/sagikazarmark/slog-shim v0.1.0/go.mod h1:SrcSrq8aKtyuqEI1uvTDTK1arOWRIczQRv+GVI1AkeQ= +github.com/sourcegraph/conc v0.3.0 h1:OQTbbt6P72L20UqAkXXuLOj79LfEanQ+YQFNpLA9ySo= +github.com/sourcegraph/conc v0.3.0/go.mod h1:Sdozi7LEKbFPqYX2/J+iBAM6HpqSLTASQIKqDmF7Mt0= +github.com/spf13/afero v1.11.0 h1:WJQKhtpdm3v2IzqG8VMqrr6Rf3UYpEF239Jy9wNepM8= +github.com/spf13/afero v1.11.0/go.mod h1:GH9Y3pIexgf1MTIWtNGyogA5MwRIDXGUr+hbWNoBjkY= +github.com/spf13/cast v1.6.0 h1:GEiTHELF+vaR5dhz3VqZfFSzZjYbgeKDpBxQVS4GYJ0= +github.com/spf13/cast v1.6.0/go.mod h1:ancEpBxwJDODSW/UG4rDrAqiKolqNNh2DX3mk86cAdo= +github.com/spf13/cobra v1.8.1 h1:e5/vxKd/rZsfSJMUX1agtjeTDf+qv1/JdBF8gg5k9ZM= +github.com/spf13/cobra v1.8.1/go.mod h1:wHxEcudfqmLYa8iTfL+OuZPbBZkmvliBWKIezN3kD9Y= +github.com/spf13/pflag v1.0.5 h1:iy+VFUOCP1a+8yFto/drg2CJ5u0yRoB7fZw3DKv/JXA= +github.com/spf13/pflag v1.0.5/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= +github.com/spf13/viper v1.19.0 h1:RWq5SEjt8o25SROyN3z2OrDB9l7RPd3lwTWU8EcEdcI= +github.com/spf13/viper v1.19.0/go.mod h1:GQUN9bilAbhU/jgc1bKs99f/suXKeUMct8Adx5+Ntkg= +github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= +github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= +github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA= +github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= +github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= +github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo= +github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= +github.com/subosito/gotenv v1.6.0 h1:9NlTDc1FTs4qu0DDq7AEtTPNw6SVm7uBMsUCUjABIf8= +github.com/subosito/gotenv v1.6.0/go.mod h1:Dk4QP5c2W3ibzajGcXpNraDfq2IrhjMIvMSWPKKo0FU= +github.com/tmthrgd/go-hex v0.0.0-20190904060850-447a3041c3bc h1:9lRDQMhESg+zvGYmW5DyG0UqvY96Bu5QYsTLvCHdrgo= +github.com/tmthrgd/go-hex v0.0.0-20190904060850-447a3041c3bc/go.mod h1:bciPuU6GHm1iF1pBvUfxfsH0Wmnc2VbpgvbI9ZWuIRs= +github.com/ulikunitz/xz v0.5.12 h1:37Nm15o69RwBkXM0J6A5OlE67RZTfzUxTj8fB3dfcsc= +github.com/ulikunitz/xz v0.5.12/go.mod h1:nbz6k7qbPmH4IRqmfOplQw/tblSgqTqBwxkY0oWt/14= +github.com/uptrace/bun v1.2.5 h1:gSprL5xiBCp+tzcZHgENzJpXnmQwRM/A6s4HnBF85mc= +github.com/uptrace/bun v1.2.5/go.mod h1:vkQMS4NNs4VNZv92y53uBSHXRqYyJp4bGhMHgaNCQpY= +github.com/valyala/bytebufferpool v1.0.0 h1:GqA5TC/0021Y/b9FG4Oi9Mr3q7XYx6KllzawFIhcdPw= +github.com/valyala/bytebufferpool v1.0.0/go.mod h1:6bBcMArwyJ5K/AmCkWv1jt77kVWyCJ6HpOuEn7z0Csc= +github.com/valyala/fasthttp v1.57.0 h1:Xw8SjWGEP/+wAAgyy5XTvgrWlOD1+TxbbvNADYCm1Tg= +github.com/valyala/fasthttp v1.57.0/go.mod h1:h6ZBaPRlzpZ6O3H5t2gEk1Qi33+TmLvfwgLLp0t9CpE= +github.com/valyala/tcplisten v1.0.0 h1:rBHj/Xf+E1tRGZyWIWwJDiRY0zc1Js+CV5DqwacVSA8= +github.com/valyala/tcplisten v1.0.0/go.mod h1:T0xQ8SeCZGxckz9qRXTfG43PvQ/mcWh7FwZEA7Ioqkc= +github.com/vmihailenco/msgpack/v5 v5.4.1 h1:cQriyiUvjTwOHg8QZaPihLWeRAAVoCpE00IUPn0Bjt8= +github.com/vmihailenco/msgpack/v5 v5.4.1/go.mod h1:GaZTsDaehaPpQVyxrf5mtQlH+pc21PIudVV/E3rRQok= +github.com/vmihailenco/tagparser/v2 v2.0.0 h1:y09buUbR+b5aycVFQs/g70pqKVZNBmxwAhO7/IwNM9g= +github.com/vmihailenco/tagparser/v2 v2.0.0/go.mod h1:Wri+At7QHww0WTrCBeu4J6bNtoV6mEfg5OIWRZA9qds= +github.com/x448/float16 v0.8.4 h1:qLwI1I70+NjRFUR3zs1JPUCgaCXSh3SW62uAKT1mSBM= +github.com/x448/float16 v0.8.4/go.mod h1:14CWIYCyZA/cWjXOioeEpHeN/83MdbZDRQHoFcYsOfg= +github.com/xyproto/randomstring v1.0.5/go.mod h1:rgmS5DeNXLivK7YprL0pY+lTuhNQW3iGxZ18UQApw/E= +gitlab.com/omnibill/archiver v1.0.0 h1:5cwP2NoUF+c2zzPYmS9Ds1EY+PA5Cl7oqWx6dV6iR68= +gitlab.com/omnibill/archiver v1.0.0/go.mod h1:SWYvKtq4CX6j196ZYPiOwBKDPJMLXV1cUfBWKqyroLo= +gitlab.com/omnibill/linux v1.0.0 h1:gvrjxRSSY3Mo6BoIf7LQ5wYtl0DxzB17iazvJP4LbbM= +gitlab.com/omnibill/linux v1.0.0/go.mod h1:fngJPKncBHcTzbMqr6rRT5AEnO4X/hzm1g2/TFKr5j8= +gitlab.com/omnibill/tui v1.0.0 h1:YBqqoS9kqJehP1TiofAdleQocXCp+nSO6D4942tK8dw= +gitlab.com/omnibill/tui v1.0.0/go.mod h1:RNOO1V8WPiV65qZ9LSHgakhSJcC8VS1JQLwJvjM1Dug= +gitlab.com/omnibill/tui v1.0.1 h1:aj5ULPEkgcuE8saaAB17onZsjBY9RuJxrKo5e5ZYkiQ= +gitlab.com/omnibill/tui v1.0.1/go.mod h1:RNOO1V8WPiV65qZ9LSHgakhSJcC8VS1JQLwJvjM1Dug= +go.uber.org/atomic v1.9.0 h1:ECmE8Bn/WFTYwEW/bpKD3M8VtR/zQVbavAoalC1PYyE= +go.uber.org/atomic v1.9.0/go.mod h1:fEN4uk6kAWBTFdckzkM89CLk9XfWZrxpCo0nPH17wJc= +go.uber.org/multierr v1.9.0 h1:7fIwc/ZtS0q++VgcfqFDxSBZVv/Xo49/SYnDFupUwlI= +go.uber.org/multierr v1.9.0/go.mod h1:X2jQV1h+kxSjClGpnseKVIxpmcjrj7MNnI0bnlfKTVQ= +go.uber.org/multierr v1.11.0 h1:blXXJkSxSSfBVBlC76pxqeO+LN3aDfLQo+309xJstO0= +go.uber.org/multierr v1.11.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y= +go.uber.org/zap v1.27.0 h1:aJMhYGrd5QSmlpLMr2MftRKl7t8J8PTZPA732ud/XR8= +go.uber.org/zap v1.27.0/go.mod h1:GB2qFLM7cTU87MWRP2mPIjqfIDnGu+VIO4V/SdhGo2E= +golang.org/x/crypto v0.28.0 h1:GBDwsMXVQi34v5CCYUm2jkJvu4cbtru2U4TN2PSyQnw= +golang.org/x/crypto v0.28.0/go.mod h1:rmgy+3RHxRZMyY0jjAJShp2zgEdOqj2AO7U0pYmeQ7U= +golang.org/x/exp v0.0.0-20230905200255-921286631fa9 h1:GoHiUyI/Tp2nVkLI2mCxVkOjsbSXD66ic0XW0js0R9g= +golang.org/x/exp v0.0.0-20230905200255-921286631fa9/go.mod h1:S2oDrQGGwySpoQPVqRShND87VCbxmc6bL1Yd2oYrm6k= +golang.org/x/exp v0.0.0-20240719175910-8a7402abbf56 h1:2dVuKD2vS7b0QIHQbpyTISPd0LeHDbnYEryqj5Q1ug8= +golang.org/x/exp v0.0.0-20240719175910-8a7402abbf56/go.mod h1:M4RDyNAINzryxdtnbRXRL/OHtkFuWGRjvuhBJpk2IlY= +golang.org/x/sync v0.8.0 h1:3NFvSEYkUoMifnESzZl15y791HH1qU2xm6eCJU5ZPXQ= +golang.org/x/sync v0.8.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= +golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.18.0 h1:DBdB3niSjOA/O0blCZBqDefyWNYveAYMNF1Wum0DYQ4= +golang.org/x/sys v0.18.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +golang.org/x/sys v0.24.0 h1:Twjiwq9dn6R1fQcyiK+wQyHWfaz/BJB+YIpzU/Cv3Xg= +golang.org/x/sys v0.24.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +golang.org/x/sys v0.26.0 h1:KHjCJyddX0LoSTb3J+vWpupP9p0oznkqVk/IfjymZbo= +golang.org/x/sys v0.26.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +golang.org/x/term v0.25.0 h1:WtHI/ltw4NvSUig5KARz9h521QvRC8RmF/cuYqifU24= +golang.org/x/term v0.25.0/go.mod h1:RPyXicDX+6vLxogjjRxjgD2TKtmAO6NZBsBRfrOLu7M= +golang.org/x/text v0.14.0 h1:ScX5w1eTa3QqT8oi6+ziP7dTV1S2+ALU0bI+0zXKWiQ= +golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= +golang.org/x/text v0.16.0/go.mod h1:GhwF1Be+LQoKShO3cGOHzqOgRrGaYc9AvblQOmPVHnI= +golang.org/x/text v0.19.0 h1:kTxAhCbGbxhK0IwgSKiMO5awPoDQ0RpfiVYBfK860YM= +golang.org/x/text v0.19.0/go.mod h1:BuEKDfySbSR4drPmRPG/7iBdf8hvFMuRexcpahXilzY= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/ini.v1 v1.67.0 h1:Dgnx+6+nfE+IfzjUEISNeydPJh9AXNNsWbGP9KzCsOA= +gopkg.in/ini.v1 v1.67.0/go.mod h1:pNLf8WUiyNEtQjuu5G5vTm06TEv9tsIgeAvK8hOrP4k= +gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/main.go b/main.go new file mode 100644 index 0000000..bf0fdb4 --- /dev/null +++ b/main.go @@ -0,0 +1,12 @@ +/* +Copyright © 2024 Shane C. +*/ +package main + +import ( + "omnibill.net/omnibill/cmd" +) + +func main() { + cmd.Execute() +} diff --git a/models/models.go b/models/models.go new file mode 100644 index 0000000..f64448f --- /dev/null +++ b/models/models.go @@ -0,0 +1,80 @@ +package models + +import ( + "github.com/go-webauthn/webauthn/protocol" + "github.com/go-webauthn/webauthn/webauthn" + "github.com/uptrace/bun" +) + +type User struct { + bun.BaseModel `bun:"table:users"` + ID string `bun:",pk"` + + Username string + Email string + Hash string + + FirstName string `bun:"nullzero"` + LastName string `bun:",nullzero"` + DisplayName string `bun:",nullzero"` + + LoginMethods []*UserLoginMethod `bun:"rel:has-many,join:id=user_id"` + Logs []*UserLog `bun:"rel:has-many,join:id=user_id"` +} + +type UserLoginMethod struct { + bun.BaseModel `bun:"table:users_login_methods"` + ID int64 `bun:",pk,autoincrement"` + UserID string + + Type string + Name string + WebAuthn *webauthn.Credential `bun:",nullzero,type:jsonb"` +} + +func (u User) WebAuthnID() []byte { + return []byte(u.ID) +} + +func (u User) WebAuthnName() string { + return u.Username +} + +func (u User) WebAuthnDisplayName() string { + if len(u.DisplayName) != 0 { + return u.DisplayName + } else { + return u.FirstName + " " + u.LastName + } +} + +func (u User) WebAuthnCredentials() []webauthn.Credential { + var credentials []webauthn.Credential + for _, l := range u.LoginMethods { + if l.WebAuthn != nil { + credentials = append(credentials, *l.WebAuthn) + } + } + return credentials +} + +func (u User) WebAuthnCredentialExcludeList() []protocol.CredentialDescriptor { + var excludeList []protocol.CredentialDescriptor + + webauthnCreds := u.WebAuthnCredentials() + for _, cred := range webauthnCreds { + descriptor := protocol.CredentialDescriptor{ + Type: protocol.PublicKeyCredentialType, + CredentialID: cred.ID, + } + excludeList = append(excludeList, descriptor) + } + + return excludeList +} + +type UserLog struct { + bun.BaseModel `bun:"table:user_logs"` + ID int64 `bun:",pk,autoincrement"` + UserID int64 `bun:",pk"` +} diff --git a/package.json b/package.json new file mode 100644 index 0000000..180da9b --- /dev/null +++ b/package.json @@ -0,0 +1,44 @@ +{ + "name": "omnibill", + "author": "Shane C.", + "type": "module", + "scripts": { + "lint": "eslint --flag unstable_ts_config --config eslint.config.ts . ", + "lint:fix": "eslint --flag unstable_ts_config --config eslint.config.ts . --fix" + }, + "devDependencies": { + "@eslint/eslintrc": "^3.1.0", + "@eslint/js": "^9.14.0", + "@tailwindcss/forms": "^0.5.9", + "@tailwindcss/typography": "^0.5.15", + "@types/alpinejs": "^3.13.10", + "@types/bun": "latest", + "@types/eslint__js": "^8.42.3", + "@typescript-eslint/parser": "^8.12.2", + "autoprefixer": "^10.4.20", + "daisyui": "^4.12.14", + "esbuild": "^0.24.0", + "eslint": "^9.14.0", + "globals": "^15.11.0", + "jiti": "^2.4.0", + "postcss": "^8.4.47", + "postcss-load-config": "^6.0.1", + "typescript": "^5.6.3", + "typescript-eslint": "^8.12.2" + }, + "dependencies": { + "@tiptap/core": "^2.9.1", + "@tiptap/extension-bold": "^2.9.1", + "@tiptap/extension-document": "^2.9.1", + "@tiptap/extension-heading": "^2.9.1", + "@tiptap/extension-italic": "^2.9.1", + "@tiptap/extension-link": "^2.9.1", + "@tiptap/extension-paragraph": "^2.9.1", + "@tiptap/extension-strike": "^2.9.1", + "@tiptap/extension-text": "^2.9.1", + "@tiptap/extension-underline": "^2.9.1", + "@tiptap/pm": "^2.9.1", + "alpinejs": "^3.14.3", + "email-validator": "^2.0.4" + } +} \ No newline at end of file diff --git a/postcss.config.ts b/postcss.config.ts new file mode 100644 index 0000000..d9106f8 --- /dev/null +++ b/postcss.config.ts @@ -0,0 +1,8 @@ +import type { Config } from 'postcss-load-config'; + +export default { + plugins: { + tailwindcss: {}, + autoprefixer: {}, + }, +} satisfies Config; \ No newline at end of file diff --git a/shared/hash.go b/shared/hash.go new file mode 100644 index 0000000..3be5505 --- /dev/null +++ b/shared/hash.go @@ -0,0 +1,79 @@ +package shared + +import ( + "bytes" + "crypto/rand" + "encoding/base64" + "errors" + "golang.org/x/crypto/argon2" + "runtime" + "strings" +) + +type Hash struct { + Hash []byte + Salt []byte +} + +func (h *Hash) String() string { + return base64.StdEncoding.EncodeToString(h.Salt) + ";" + base64.StdEncoding.EncodeToString(h.Hash) +} + +func NewHash(pass string, salt []byte) (hash *Hash, err error) { + + if salt == nil { + salt = make([]byte, 16) + _, err = rand.Read(salt) + if err != nil { + return nil, err + } + } + + var parallelism uint8 + + if runtime.NumCPU() > 255 { + parallelism = 255 + } else { + parallelism = uint8(runtime.NumCPU()) // #nosec G115 -- False positive, if this does happen, blame radiation. + } + + argonHash := argon2.IDKey([]byte(pass), salt, 6, 64*1024, parallelism, 45) + + return &Hash{ + Salt: salt, + Hash: argonHash, + }, nil +} + +var ErrInvalidHashStr = errors.New("invalid hash string") + +func CompareHash(hash string, pass string) (matches bool, err error) { + + hashSplit := strings.Split(hash, ";") + + if len(hashSplit) != 2 { + return false, ErrInvalidHashStr + } + + salt, err := base64.StdEncoding.DecodeString(hashSplit[0]) + if err != nil { + return false, err + } + + decodedHash, err := base64.StdEncoding.DecodeString(hashSplit[1]) + if err != nil { + return false, err + } + + hashInfo, err := NewHash(pass, salt) + if err != nil { + return false, err + } + + if !bytes.Equal(decodedHash, hashInfo.Hash) { + return false, nil + } + + return true, nil + +} diff --git a/shared/i18n.go b/shared/i18n.go new file mode 100644 index 0000000..ed6bc25 --- /dev/null +++ b/shared/i18n.go @@ -0,0 +1,36 @@ +package shared + +import "github.com/nicksnyder/go-i18n/v2/i18n" + +var I18nLocalizer *i18n.Localizer + +type TranslateOptions struct { + TemplateData interface{} + PluralCount interface{} + Zero string + One string + Two string + Few string + Many string + Other string +} + +func T(id string, options *TranslateOptions) string { + + localizedStr := I18nLocalizer.MustLocalize(&i18n.LocalizeConfig{ + DefaultMessage: &i18n.Message{ + ID: id, + Zero: options.Zero, + One: options.One, + Two: options.Two, + Few: options.Few, + Many: options.Many, + Other: options.Other, + }, + TemplateData: options.TemplateData, + PluralCount: options.PluralCount, + }) + + return localizedStr + +} diff --git a/shared/json.go b/shared/json.go new file mode 100644 index 0000000..ee69ba3 --- /dev/null +++ b/shared/json.go @@ -0,0 +1,44 @@ +package shared + +import ( + "bytes" + "io" + "io/fs" + "os" + + "github.com/goccy/go-json" +) + +func LoadJSONFileFS(fsys fs.FS, fileName string, value interface{}) error { + file, err := fs.ReadFile(fsys, fileName) + if err != nil { + return err + } + + fileBuffer := bytes.NewReader(file) + + if err := json.NewDecoder(fileBuffer).Decode(&value); err != nil && err != io.EOF { + return err + } + + clear(file) + + return nil +} + +func LoadJSONFile(fileName string, value interface{}) error { + file, err := os.ReadFile(fileName) + if err != nil { + return err + } + + fileBuffer := bytes.NewReader(file) + + if err := json.NewDecoder(fileBuffer).Decode(&value); err != nil && err != io.EOF { + return err + } + + clear(file) + + return nil +} diff --git a/shared/json_test.go b/shared/json_test.go new file mode 100644 index 0000000..05480ca --- /dev/null +++ b/shared/json_test.go @@ -0,0 +1,23 @@ +package shared + +import ( + "github.com/stretchr/testify/assert" + "os" + "testing" +) + +func TestLoadJsonFile(t *testing.T) { + err := os.WriteFile("test_out/test.json", []byte(`{"name": "test", "test": 1}`), os.ModePerm) + assert.NoError(t, err) + + var value struct { + Name string `json:"name"` + Test int `json:"test"` + } + + err = LoadJSONFile("test_out/test.json", &value) + assert.NoError(t, err) + + assert.NotEmpty(t, value.Test) + assert.NotEmpty(t, value.Name) +} diff --git a/shared/postgres.go b/shared/postgres.go new file mode 100644 index 0000000..92c3a48 --- /dev/null +++ b/shared/postgres.go @@ -0,0 +1,24 @@ +package shared + +import ( + "github.com/spf13/viper" + "net/url" +) + +func GetPostgresURI() string { + postgresURI := url.URL{ + Scheme: "postgresql", + User: url.UserPassword(viper.GetString("database.user"), viper.GetString("database.password")), + Host: viper.GetString("database.host"), + Path: viper.GetString("database.database"), + } + + values := postgresURI.Query() + + values.Add("sslmode", "disable") + values.Add("timezone", viper.GetString("database.tz")) + + postgresURI.RawQuery = values.Encode() + + return postgresURI.String() +} diff --git a/tailwind.config.ts b/tailwind.config.ts new file mode 100644 index 0000000..0a83854 --- /dev/null +++ b/tailwind.config.ts @@ -0,0 +1,20 @@ +import typography from '@tailwindcss/typography'; +import type { Config } from 'tailwindcss'; +import forms from '@tailwindcss/forms'; +import daisyui from 'daisyui'; + +export default { + content: [ + './web/views/**/*.templ', + ], + safelist: [ + 'editor', + ], + darkMode: 'class', + plugins: [ + typography, + daisyui, + forms, + ], +} satisfies Config; + diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 0000000..1921234 --- /dev/null +++ b/tsconfig.json @@ -0,0 +1,26 @@ +{ + "compilerOptions": { + // Enable latest features + "lib": ["ESNext", "DOM", "DOM.Iterable"], + "target": "ESNext", + "module": "ESNext", + "moduleDetection": "force", + "allowJs": true, + + // Bundler mode + "moduleResolution": "bundler", + "allowImportingTsExtensions": true, + "verbatimModuleSyntax": true, + "noEmit": true, + + // Best practices + "strict": true, + "skipLibCheck": true, + "noFallthroughCasesInSwitch": true, + + // Some stricter flags (disabled by default) + "noUnusedLocals": false, + "noUnusedParameters": false, + "noPropertyAccessFromIndexSignature": false + } +} diff --git a/web/assets/css/styles.css b/web/assets/css/styles.css new file mode 100644 index 0000000..728d6a5 --- /dev/null +++ b/web/assets/css/styles.css @@ -0,0 +1,31 @@ +@tailwind base; +@tailwind components; +@tailwind utilities; + +body { + height: 100svh; + font-family: Inter, system-ui, Avenir, Helvetica, Arial, sans-serif; + line-height: 1.5; + font-weight: 400; + + color-scheme: dark; + color: rgba(255, 255, 255, 0.87); + background-color: #242424; + + font-synthesis: none; + text-rendering: optimizeLegibility; + -webkit-font-smoothing: antialiased; + -moz-osx-font-smoothing: grayscale; +} + +@layer components { + .editor { + @apply prose prose-sm sm:prose-base lg:prose-lg xl:prose-2xl m-5 focus:outline-none p-3 rounded-lg shadow-lg bg-slate-800; + } + .editor-controls button { + @apply btn btn-primary; + } + .active-editor-control { + @apply btn-secondary !important; + } +} \ No newline at end of file diff --git a/web/middleware/auth.go b/web/middleware/auth.go new file mode 100644 index 0000000..e6f16b2 --- /dev/null +++ b/web/middleware/auth.go @@ -0,0 +1,50 @@ +package middleware + +import ( + "github.com/gofiber/fiber/v2" + "github.com/gofiber/fiber/v2/middleware/session" + "github.com/uptrace/bun" + "go.uber.org/zap" + "omnibill.net/omnibill/models" + "reflect" +) + +func Auth(logger *zap.Logger, db *bun.DB, authSessionStore *session.Store, handler interface{}) fiber.Handler { + return func(c *fiber.Ctx) error { + if !c.IsProxyTrusted() { + return fiber.ErrUnauthorized + } + + authSession, err := authSessionStore.Get(c) + if err != nil { + return fiber.ErrUnauthorized + } + + if len(authSession.Keys()) == 0 { + return fiber.ErrUnauthorized + } + + var user models.User + userID := authSession.Get("uid").(string) + keyCount, err := db.NewSelect().Model(&user).Where("id = ?", userID).Count(c.UserContext()) + if err != nil { + logger.Error("error getting columns", zap.Error(err)) + return fiber.ErrInternalServerError + } + + if keyCount == 0 { + if err := authSession.Destroy(); err != nil { + logger.Error("error destroying session", zap.Error(err)) + return fiber.ErrInternalServerError + } + if err := authSession.Save(); err != nil { + logger.Error("error saving session", zap.Error(err)) + return fiber.ErrInternalServerError + } + return fiber.ErrUnauthorized + } + + reflect.ValueOf(handler).Elem().FieldByName("AuthSession").Set(reflect.ValueOf(authSession)) + return nil + } +} diff --git a/web/server.go b/web/server.go new file mode 100644 index 0000000..3efb2ec --- /dev/null +++ b/web/server.go @@ -0,0 +1,264 @@ +package web + +import ( + "bufio" + "embed" + "encoding/gob" + "errors" + "fmt" + "github.com/a-h/templ" + "github.com/go-webauthn/webauthn/webauthn" + "github.com/goccy/go-json" + "github.com/gofiber/fiber/v2" + "github.com/gofiber/fiber/v2/log" + "github.com/gofiber/fiber/v2/middleware/earlydata" + "github.com/gofiber/fiber/v2/middleware/etag" + "github.com/gofiber/fiber/v2/middleware/filesystem" + "github.com/gofiber/fiber/v2/middleware/healthcheck" + "github.com/gofiber/fiber/v2/middleware/helmet" + "github.com/gofiber/fiber/v2/middleware/limiter" + "github.com/gofiber/fiber/v2/middleware/recover" + "github.com/gofiber/fiber/v2/middleware/session" + "github.com/gofiber/storage/postgres/v3" + "github.com/jackc/pgx/v5/pgxpool" + "github.com/spf13/viper" + "github.com/uptrace/bun" + "go.uber.org/zap" + "net/http" + "net/url" + "omnibill.net/omnibill/web/utils" + "omnibill.net/omnibill/web/views/layouts" + "reflect" + "strings" + "time" +) + +//go:embed assets/**/* +var assetDir embed.FS + +func Start(logger *zap.Logger, db *bun.DB, dbPool *pgxpool.Pool) { + + panelURL, err := url.Parse(viper.GetString("omnibill.domain")) + if err != nil { + logger.Fatal("error parsing panel URL", zap.Error(err)) + } + + gob.Register(&webauthn.SessionData{}) + webAuthnConfig := &webauthn.Config{ + RPDisplayName: viper.GetString("omnibill.display_name"), + RPID: panelURL.Host, + RPOrigins: []string{panelURL.String()}, + } + + webAuthn, err := webauthn.New(webAuthnConfig) + if err != nil { + logger.Fatal("error creating webauthn", zap.Error(err)) + } + + appConfig := fiber.Config{ + AppName: viper.GetString("omnibill.display_name"), + JSONEncoder: json.Marshal, + JSONDecoder: json.Unmarshal, + } + + if len(viper.GetString("omnibill.webserver.proxy")) != 0 { + switch strings.ToLower(viper.GetString("omnibill.webserver.proxy")) { + case "cloudflare", "cf": + logger.Info("grabbing trusted proxy list") + + var trustedProxies []string + + v4Req, err := http.NewRequest("GET", "https://www.cloudflare.com/ips-v4/#", nil) + if err != nil { + logger.Fatal("error creating request", zap.Error(err)) + } + + v6Req, err := http.NewRequest("GET", "https://www.cloudflare.com/ips-v6/#", nil) + if err != nil { + logger.Fatal("error creating request", zap.Error(err)) + } + + client := &http.Client{} + + v4Resp, err := client.Do(v4Req) + if err != nil { + logger.Fatal("error doing request", zap.Error(err)) + } + defer v4Resp.Body.Close() + + v4Scanner := bufio.NewScanner(v4Resp.Body) + v4Scanner.Split(bufio.ScanLines) + + for v4Scanner.Scan() { + trustedProxies = append(trustedProxies, v4Scanner.Text()) + } + + v6Resp, err := client.Do(v6Req) + if err != nil { + logger.Fatal("error doing request", zap.Error(err)) + } + defer v6Resp.Body.Close() + + v6Scanner := bufio.NewScanner(v6Resp.Body) + v6Scanner.Split(bufio.ScanLines) + + for v6Scanner.Scan() { + trustedProxies = append(trustedProxies, v6Scanner.Text()) + } + + appConfig.ProxyHeader = "X-Forwarded-For" + appConfig.TrustedProxies = trustedProxies + case "none": + default: + log.Warnf("Proxy '%s' is not supported", viper.GetString("omnibill.webserver.proxy")) + } + } + + app := fiber.New(appConfig) + app.Use(recover.New()) + app.Use(earlydata.New()) + app.Use(healthcheck.New()) + app.Use(helmet.New()) + app.Use(etag.New()) + app.Use(limiter.New(limiter.Config{ + Max: 250, + Expiration: 3 * time.Second, + LimiterMiddleware: limiter.SlidingWindow{}, + })) + app.Use("/assets", filesystem.New(filesystem.Config{ + Root: http.FS(assetDir), + PathPrefix: "assets", + Browse: false, + })) + + storage := postgres.New(postgres.Config{ + DB: dbPool, + Table: "sessions", + }) + authSessionStore := session.New(session.Config{ + Storage: storage, + }) + sessionStore := session.New(session.Config{ + KeyLookup: "cookie:osession", + }) + + for _, handler := range utils.Handlers { + handlerType := reflect.TypeOf(handler).Elem() + handlerValue := reflect.ValueOf(handler).Elem() + + pathField, ok := handlerType.FieldByName("Path") + if !ok { + fmt.Println("invalid handler") + continue + } + + var requireAuth bool + + omnibillTag := pathField.Tag.Get("omnibill") + for _, option := range strings.Split(omnibillTag, ",") { + switch option { + case "requireAuth": + requireAuth = true + } + } + + var pathHandlers []fiber.Handler + if requireAuth { + pathHandlers = append(pathHandlers, nil) + } + + handlerValue.FieldByName("Db").Set(reflect.ValueOf(db)) + handlerValue.FieldByName("AuthSessionStore").Set(reflect.ValueOf(authSessionStore)) + handlerValue.FieldByName("SessionStore").Set(reflect.ValueOf(sessionStore)) + handlerValue.FieldByName("Logger").Set(reflect.ValueOf(logger)) + handlerValue.FieldByName("WebAuthn").Set(reflect.ValueOf(webAuthn)) + + path := handlerValue.FieldByName("Path").String() + if path == "index" { + path = "" + } + path = "/" + path + + if iHandler, ok := handler.(utils.GET); ok { + pathHandlers = append(pathHandlers, iHandler.Get) + app.Get(path, func(ctx *fiber.Ctx) error { + sess, err := sessionStore.Get(ctx) + if err != nil { + return fiber.ErrInternalServerError + } + + handlerValue.FieldByName("Session").Set(reflect.ValueOf(sess)) + for _, pathHandler := range pathHandlers { + err := pathHandler(ctx) + if err != nil { + var e *fiber.Error + if errors.As(err, &e) { + return utils.Render(ctx, layouts.Error(*e), templ.WithStatus(e.Code)) + } else { + return err + } + } + } + return nil + }) + } + if iHandler, ok := handler.(utils.POST); ok { + pathHandlers = append(pathHandlers, iHandler.Post) + app.Post(path, func(ctx *fiber.Ctx) error { + return genericPathHandler(ctx, handlerValue, sessionStore, pathHandlers) + }) + } + if iHandler, ok := handler.(utils.PUT); ok { + pathHandlers = append(pathHandlers, iHandler.Put) + app.Put(path, func(ctx *fiber.Ctx) error { + return genericPathHandler(ctx, handlerValue, sessionStore, pathHandlers) + }) + } + if iHandler, ok := handler.(utils.DELETE); ok { + pathHandlers = append(pathHandlers, iHandler.Delete) + app.Delete(path, func(ctx *fiber.Ctx) error { + return genericPathHandler(ctx, handlerValue, sessionStore, pathHandlers) + }) + } + if iHandler, ok := handler.(utils.PATCH); ok { + pathHandlers = append(pathHandlers, iHandler.Patch) + app.Patch(path, func(ctx *fiber.Ctx) error { + return genericPathHandler(ctx, handlerValue, sessionStore, pathHandlers) + }) + } + if iHandler, ok := handler.(utils.OPTIONS); ok { + pathHandlers = append(pathHandlers, iHandler.Options) + app.Options(path, func(ctx *fiber.Ctx) error { + return genericPathHandler(ctx, handlerValue, sessionStore, pathHandlers) + }) + } + if iHandler, ok := handler.(utils.HEAD); ok { + pathHandlers = append(pathHandlers, iHandler.Head) + app.Head(path, func(ctx *fiber.Ctx) error { + return genericPathHandler(ctx, handlerValue, sessionStore, pathHandlers) + }) + } + } + +} + +func genericPathHandler(ctx *fiber.Ctx, handler reflect.Value, sessionStore *session.Store, handlers []fiber.Handler) error { + sess, err := sessionStore.Get(ctx) + if err != nil { + return fiber.ErrInternalServerError + } + + handler.FieldByName("Session").Set(reflect.ValueOf(sess)) + for _, pathHandler := range handlers { + err := pathHandler(ctx) + if err != nil { + var e *fiber.Error + if errors.As(err, &e) { + return err + } else { + return fiber.ErrInternalServerError + } + } + } + return nil +} diff --git a/web/utils/handlers.go b/web/utils/handlers.go new file mode 100644 index 0000000..26a5019 --- /dev/null +++ b/web/utils/handlers.go @@ -0,0 +1,33 @@ +package utils + +import "github.com/gofiber/fiber/v2" + +var Handlers []interface{} + +type GET interface { + Get(ctx *fiber.Ctx) error +} + +type POST interface { + Post(ctx *fiber.Ctx) error +} + +type PUT interface { + Put(ctx *fiber.Ctx) error +} + +type DELETE interface { + Delete(ctx *fiber.Ctx) error +} + +type HEAD interface { + Head(ctx *fiber.Ctx) error +} + +type OPTIONS interface { + Options(ctx *fiber.Ctx) error +} + +type PATCH interface { + Patch(ctx *fiber.Ctx) error +} diff --git a/web/utils/render.go b/web/utils/render.go new file mode 100644 index 0000000..82cb31d --- /dev/null +++ b/web/utils/render.go @@ -0,0 +1,15 @@ +package utils + +import ( + "github.com/a-h/templ" + "github.com/gofiber/fiber/v2" + "github.com/gofiber/fiber/v2/middleware/adaptor" +) + +func Render(c *fiber.Ctx, component templ.Component, options ...func(*templ.ComponentHandler)) error { + componentHandler := templ.Handler(component) + for _, o := range options { + o(componentHandler) + } + return adaptor.HTTPHandler(componentHandler)(c) +} diff --git a/web/utils/session.go b/web/utils/session.go new file mode 100644 index 0000000..d4b585b --- /dev/null +++ b/web/utils/session.go @@ -0,0 +1 @@ +package utils diff --git a/web/views/components/utils.templ b/web/views/components/utils.templ new file mode 100644 index 0000000..161a2df --- /dev/null +++ b/web/views/components/utils.templ @@ -0,0 +1,29 @@ +package components + +var showOnceHandle = templ.NewOnceHandle() + +type ScriptAssetOptions struct { + IsAsync bool + IsDefer bool + IsModule bool + DoImport bool +} + +templ LoadJSAsset(assetName string, opts *ScriptAssetOptions) { + if opts == nil { + + } else { + if opts.IsModule { + if opts.DoImport { + + } else { + + } + } else { + + } + } + +} \ No newline at end of file diff --git a/web/views/layouts/base.templ b/web/views/layouts/base.templ new file mode 100644 index 0000000..2efb800 --- /dev/null +++ b/web/views/layouts/base.templ @@ -0,0 +1,21 @@ +package layouts + +import "omnibill.net/omnibill/web/views/components" + +templ Base(pageHeading templ.Component) { + + + + + + + @components.LoadJSAsset("main.js", &components.ScriptAssetOptions{IsModule: true, IsDefer: true}) + if pageHeading != nil { + @pageHeading + } + + + { children... } + + +} \ No newline at end of file diff --git a/web/views/layouts/error.templ b/web/views/layouts/error.templ new file mode 100644 index 0000000..c732c20 --- /dev/null +++ b/web/views/layouts/error.templ @@ -0,0 +1,16 @@ +package layouts + +import "github.com/gofiber/fiber/v2" +import "strconv" +import "time" + +templ errorHeading(err fiber.Error) { + Error {err.Message} - OmniBill +} + +templ Error(err fiber.Error) { + @Base(errorHeading(err)) { +

{err.Message}

+

Status Code: {strconv.Itoa(err.Code)} | Time: {time.Now().Format("2006-1-2 15:4:5")}

+ } +} \ No newline at end of file