perf(server): cached openapi document (#4674)

This should lead to a small performance gain by caching the openapi.yml with etags as well as eliminating the use of nonce crypto generation when not required.
pull/4682/head
James Elliott 2023-01-03 14:49:02 +11:00 committed by GitHub
parent acaadd81cb
commit 1c3219e93f
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
18 changed files with 631 additions and 422 deletions

View File

@ -9,6 +9,7 @@ yaml-files:
- '.yamllint' - '.yamllint'
ignore: | ignore: |
api/openapi.yml
docs/pnpm-lock.yaml docs/pnpm-lock.yaml
internal/configuration/test_resources/config_bad_quoting.yml internal/configuration/test_resources/config_bad_quoting.yml
web/pnpm-lock.yaml web/pnpm-lock.yaml

View File

@ -4,10 +4,10 @@
<head> <head>
<meta charset="UTF-8"> <meta charset="UTF-8">
<title>Swagger UI</title> <title>Swagger UI</title>
<link rel="stylesheet" type="text/css" href="{{.Base}}/api/swagger-ui.css" /> <link rel="stylesheet" type="text/css" href="{{ .Base }}/api/swagger-ui.css" />
<link rel="icon" type="image/png" href="{{.Base}}/api/favicon-32x32.png" sizes="32x32" /> <link rel="icon" type="image/png" href="{{ .Base }}/api/favicon-32x32.png" sizes="32x32" />
<link rel="icon" type="image/png" href="{{.Base}}/api/favicon-16x16.png" sizes="16x16" /> <link rel="icon" type="image/png" href="{{ .Base }}/api/favicon-16x16.png" sizes="16x16" />
<style nonce="{{.CSPNonce}}"> <style nonce="{{ .CSPNonce }}">
html html
{ {
box-sizing: border-box; box-sizing: border-box;
@ -33,13 +33,13 @@
<body> <body>
<div id="swagger-ui"></div> <div id="swagger-ui"></div>
<script src="{{.Base}}/api/swagger-ui-bundle.js" charset="UTF-8"> </script> <script src="{{ .Base }}/api/swagger-ui-bundle.js" charset="UTF-8"> </script>
<script src="{{.Base}}/api/swagger-ui-standalone-preset.js" charset="UTF-8"> </script> <script src="{{ .Base }}/api/swagger-ui-standalone-preset.js" charset="UTF-8"> </script>
<script nonce="{{.CSPNonce}}"> <script nonce="{{ .CSPNonce }}">
window.onload = function() { window.onload = function() {
// Begin Swagger UI call region // Begin Swagger UI call region
const ui = SwaggerUIBundle({ const ui = SwaggerUIBundle({
url: "{{.Base}}/api/openapi.yml", url: "{{ .Base }}/api/openapi.yml",
dom_id: '#swagger-ui', dom_id: '#swagger-ui',
deepLinking: true, deepLinking: true,
presets: [ presets: [

View File

@ -1,4 +1,3 @@
# yamllint disable rule:line-length
--- ---
openapi: 3.0.3 openapi: 3.0.3
info: info:
@ -22,18 +21,24 @@ tags:
description: Configuration, health and state endpoints description: Configuration, health and state endpoints
- name: Authentication - name: Authentication
description: Authentication and verification endpoints description: Authentication and verification endpoints
{{- if .PasswordReset }}
- name: Password Reset - name: Password Reset
description: Password reset endpoints description: Password reset endpoints
- name: User Information - name: User Information
description: User configuration endpoints description: User configuration endpoints
{{- end }}
{{- if (or .TOTP .Webauthn .Duo) }}
- name: Second Factor - name: Second Factor
description: TOTP, Webauthn and Duo endpoints description: TOTP, Webauthn and Duo endpoints
externalDocs: externalDocs:
url: https://www.authelia.com/configuration/second-factor/introduction/ url: https://www.authelia.com/configuration/second-factor/introduction/
{{- end }}
{{- if .OpenIDConnect }}
- name: OpenID Connect 1.0 - name: OpenID Connect 1.0
description: OpenID Connect 1.0 and OAuth 2.0 Endpoints description: OpenID Connect 1.0 and OAuth 2.0 Endpoints
externalDocs: externalDocs:
url: https://www.authelia.com/integration/openid-connect/introduction/ url: https://www.authelia.com/integration/openid-connect/introduction/
{{- end }}
paths: paths:
/api/configuration: /api/configuration:
get: get:
@ -97,280 +102,8 @@ paths:
schema: schema:
$ref: '#/components/schemas/handlers.StateResponse' $ref: '#/components/schemas/handlers.StateResponse'
/api/verify: /api/verify:
get: {{- range $method := list "get" "head" "options" "post" "put" "patch" "delete" "trace" }}
tags: {{ $method }}:
- Authentication
summary: Verification
description: >
The verify endpoint provides the ability to verify if a user has the necessary permissions to access a specified
domain.
parameters:
- $ref: '#/components/parameters/originalURLParam'
- $ref: '#/components/parameters/forwardedMethodParam'
- $ref: '#/components/parameters/authParam'
responses:
"200":
description: Successful Operation
headers:
remote-user:
description: Username
schema:
type: string
example: john
remote-name:
description: Name
schema:
type: string
example: John Doe
remote-email:
description: Email
schema:
type: string
example: john.doe@authelia.com
remote-groups:
description: Comma separated list of Groups
schema:
type: string
example: admin,devs
"401":
description: Unauthorized
security:
- authelia_auth: []
head:
tags:
- Authentication
summary: Verification
description: >
The verify endpoint provides the ability to verify if a user has the necessary permissions to access a specified
domain.
parameters:
- $ref: '#/components/parameters/originalURLParam'
- $ref: '#/components/parameters/forwardedMethodParam'
- $ref: '#/components/parameters/authParam'
responses:
"200":
description: Successful Operation
headers:
remote-user:
description: Username
schema:
type: string
example: john
remote-name:
description: Name
schema:
type: string
example: John Doe
remote-email:
description: Email
schema:
type: string
example: john.doe@authelia.com
remote-groups:
description: Comma separated list of Groups
schema:
type: string
example: admin,devs
"401":
description: Unauthorized
security:
- authelia_auth: []
options:
tags:
- Authentication
summary: Verification
description: >
The verify endpoint provides the ability to verify if a user has the necessary permissions to access a specified
domain.
parameters:
- $ref: '#/components/parameters/originalURLParam'
- $ref: '#/components/parameters/forwardedMethodParam'
- $ref: '#/components/parameters/authParam'
responses:
"200":
description: Successful Operation
headers:
remote-user:
description: Username
schema:
type: string
example: john
remote-name:
description: Name
schema:
type: string
example: John Doe
remote-email:
description: Email
schema:
type: string
example: john.doe@authelia.com
remote-groups:
description: Comma separated list of Groups
schema:
type: string
example: admin,devs
"401":
description: Unauthorized
security:
- authelia_auth: []
post:
tags:
- Authentication
summary: Verification
description: >
The verify endpoint provides the ability to verify if a user has the necessary permissions to access a specified
domain.
parameters:
- $ref: '#/components/parameters/originalURLParam'
- $ref: '#/components/parameters/forwardedMethodParam'
- $ref: '#/components/parameters/authParam'
responses:
"200":
description: Successful Operation
headers:
remote-user:
description: Username
schema:
type: string
example: john
remote-name:
description: Name
schema:
type: string
example: John Doe
remote-email:
description: Email
schema:
type: string
example: john.doe@authelia.com
remote-groups:
description: Comma separated list of Groups
schema:
type: string
example: admin,devs
"401":
description: Unauthorized
security:
- authelia_auth: []
put:
tags:
- Authentication
summary: Verification
description: >
The verify endpoint provides the ability to verify if a user has the necessary permissions to access a specified
domain.
parameters:
- $ref: '#/components/parameters/originalURLParam'
- $ref: '#/components/parameters/forwardedMethodParam'
- $ref: '#/components/parameters/authParam'
responses:
"200":
description: Successful Operation
headers:
remote-user:
description: Username
schema:
type: string
example: john
remote-name:
description: Name
schema:
type: string
example: John Doe
remote-email:
description: Email
schema:
type: string
example: john.doe@authelia.com
remote-groups:
description: Comma separated list of Groups
schema:
type: string
example: admin,devs
"401":
description: Unauthorized
security:
- authelia_auth: []
patch:
tags:
- Authentication
summary: Verification
description: >
The verify endpoint provides the ability to verify if a user has the necessary permissions to access a specified
domain.
parameters:
- $ref: '#/components/parameters/originalURLParam'
- $ref: '#/components/parameters/forwardedMethodParam'
- $ref: '#/components/parameters/authParam'
responses:
"200":
description: Successful Operation
headers:
remote-user:
description: Username
schema:
type: string
example: john
remote-name:
description: Name
schema:
type: string
example: John Doe
remote-email:
description: Email
schema:
type: string
example: john.doe@authelia.com
remote-groups:
description: Comma separated list of Groups
schema:
type: string
example: admin,devs
"401":
description: Unauthorized
security:
- authelia_auth: []
delete:
tags:
- Authentication
summary: Verification
description: >
The verify endpoint provides the ability to verify if a user has the necessary permissions to access a specified
domain.
parameters:
- $ref: '#/components/parameters/originalURLParam'
- $ref: '#/components/parameters/forwardedMethodParam'
- $ref: '#/components/parameters/authParam'
responses:
"200":
description: Successful Operation
headers:
remote-user:
description: Username
schema:
type: string
example: john
remote-name:
description: Name
schema:
type: string
example: John Doe
remote-email:
description: Email
schema:
type: string
example: john.doe@authelia.com
remote-groups:
description: Comma separated list of Groups
schema:
type: string
example: admin,devs
"401":
description: Unauthorized
security:
- authelia_auth: []
trace:
tags: tags:
- Authentication - Authentication
summary: Verification summary: Verification
@ -409,6 +142,7 @@ paths:
description: Unauthorized description: Unauthorized
security: security:
- authelia_auth: [] - authelia_auth: []
{{- end }}
/api/firstfactor: /api/firstfactor:
post: post:
tags: tags:
@ -477,6 +211,7 @@ paths:
$ref: '#/components/schemas/handlers.logoutResponseBody' $ref: '#/components/schemas/handlers.logoutResponseBody'
security: security:
- authelia_auth: [] - authelia_auth: []
{{- if .PasswordReset }}
/api/reset-password/identity/start: /api/reset-password/identity/start:
post: post:
tags: tags:
@ -494,7 +229,7 @@ paths:
content: content:
application/json: application/json:
schema: schema:
$ref: '#/components/schemas/handlers.resetPasswordStep1RequestBody' $ref: '#/components/schemas/handlers.PasswordResetStep1RequestBody'
responses: responses:
"200": "200":
description: Successful Operation description: Successful Operation
@ -546,7 +281,7 @@ paths:
content: content:
application/json: application/json:
schema: schema:
$ref: '#/components/schemas/handlers.resetPasswordStep2RequestBody' $ref: '#/components/schemas/handlers.PasswordResetStep2RequestBody'
responses: responses:
"200": "200":
description: Successful Operation description: Successful Operation
@ -556,6 +291,7 @@ paths:
$ref: '#/components/schemas/middlewares.OkResponse' $ref: '#/components/schemas/middlewares.OkResponse'
security: security:
- authelia_auth: [] - authelia_auth: []
{{- end }}
/api/user/info: /api/user/info:
get: get:
tags: tags:
@ -593,25 +329,6 @@ paths:
description: Forbidden description: Forbidden
security: security:
- authelia_auth: [] - authelia_auth: []
/api/user/info/totp:
get:
tags:
- User Information
summary: User TOTP Configuration
description: >
The user TOTP info endpoint provides information necessary to display the TOTP component to validate their
TOTP input such as the period/frequency and number of digits.
responses:
"200":
description: Successful Operation
content:
application/json:
schema:
$ref: '#/components/schemas/handlers.UserInfoTOTP'
"403":
description: Forbidden
security:
- authelia_auth: []
/api/user/info/2fa_method: /api/user/info/2fa_method:
post: post:
tags: tags:
@ -634,6 +351,26 @@ paths:
description: Forbidden description: Forbidden
security: security:
- authelia_auth: [] - authelia_auth: []
{{- if .TOTP }}
/api/user/info/totp:
get:
tags:
- User Information
summary: User TOTP Configuration
description: >
The user TOTP info endpoint provides information necessary to display the TOTP component to validate their
TOTP input such as the period/frequency and number of digits.
responses:
"200":
description: Successful Operation
content:
application/json:
schema:
$ref: '#/components/schemas/handlers.UserInfoTOTP'
"403":
description: Forbidden
security:
- authelia_auth: []
/api/secondfactor/totp/identity/start: /api/secondfactor/totp/identity/start:
post: post:
tags: tags:
@ -706,6 +443,8 @@ paths:
$ref: '#/components/schemas/middlewares.ErrorResponse' $ref: '#/components/schemas/middlewares.ErrorResponse'
security: security:
- authelia_auth: [] - authelia_auth: []
{{- end }}
{{- if .Webauthn }}
/api/secondfactor/webauthn/assertion: /api/secondfactor/webauthn/assertion:
get: get:
tags: tags:
@ -812,6 +551,8 @@ paths:
$ref: '#/components/schemas/middlewares.OkResponse' $ref: '#/components/schemas/middlewares.OkResponse'
security: security:
- authelia_auth: [] - authelia_auth: []
{{- end }}
{{- if .Duo }}
/api/secondfactor/duo: /api/secondfactor/duo:
post: post:
tags: tags:
@ -875,6 +616,8 @@ paths:
description: Unauthorized description: Unauthorized
security: security:
- authelia_auth: [] - authelia_auth: []
{{- end }}
{{- if .OpenIDConnect }}
/.well-known/openid-configuration: /.well-known/openid-configuration:
get: get:
tags: tags:
@ -1389,6 +1132,7 @@ paths:
description: Forbidden description: Forbidden
security: security:
- authelia_auth: [] - authelia_auth: []
{{- end }}
components: components:
parameters: parameters:
originalURLParam: originalURLParam:
@ -1609,7 +1353,8 @@ components:
redirect: redirect:
type: string type: string
example: https://home.example.com example: https://home.example.com
handlers.resetPasswordStep1RequestBody: {{- if .PasswordReset }}
handlers.PasswordResetStep1RequestBody:
required: required:
- username - username
type: object type: object
@ -1617,7 +1362,7 @@ components:
username: username:
type: string type: string
example: john example: john
handlers.resetPasswordStep2RequestBody: handlers.PasswordResetStep2RequestBody:
required: required:
- password - password
type: object type: object
@ -1625,6 +1370,8 @@ components:
password: password:
type: string type: string
example: password example: password
{{- end }}
{{- if .Duo }}
handlers.bodySignDuoRequest: handlers.bodySignDuoRequest:
type: object type: object
properties: properties:
@ -1641,23 +1388,7 @@ components:
format: uuid format: uuid
pattern: '^[0-9a-fA-F]{8}\b-[0-9a-fA-F]{4}\b-[0-9a-fA-F]{4}\b-[0-9a-fA-F]{4}\b-[0-9a-fA-F]{12}$' pattern: '^[0-9a-fA-F]{8}\b-[0-9a-fA-F]{4}\b-[0-9a-fA-F]{4}\b-[0-9a-fA-F]{4}\b-[0-9a-fA-F]{12}$'
example: "3ebcfbc5-b0fd-4ee0-9d3c-080ae1e7298c" example: "3ebcfbc5-b0fd-4ee0-9d3c-080ae1e7298c"
handlers.bodySignTOTPRequest: {{- end }}
type: object
properties:
token:
type: string
example: "123456"
targetURL:
type: string
example: https://secure.example.com
workflow:
type: string
example: openid_connect
workflowID:
type: string
format: uuid
pattern: '^[0-9a-fA-F]{8}\b-[0-9a-fA-F]{4}\b-[0-9a-fA-F]{4}\b-[0-9a-fA-F]{4}\b-[0-9a-fA-F]{12}$'
example: "3ebcfbc5-b0fd-4ee0-9d3c-080ae1e7298c"
handlers.StateResponse: handlers.StateResponse:
type: object type: object
properties: properties:
@ -1676,7 +1407,24 @@ components:
default_redirection_url: default_redirection_url:
type: string type: string
example: https://home.example.com example: https://home.example.com
handlers.TOTPKeyResponse: middlewares.ErrorResponse:
type: object
properties:
status:
type: string
example: KO
message:
type: string
example: Authentication failed, please retry later.
middlewares.IdentityVerificationFinishBody:
required:
- token
type: object
properties:
token:
type: string
example: eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJleHAiOjE2MDc5MjU1OTYsImlzcyI6IkF1dGhlbGlhIiwiYWN0aW9uIjoiUmVzZXRQYXNzd29yZCIsInVzZXJuYW1lIjoiQW1pciJ9.636yqRrUCGCe4jsMCsonleX5CYWHncYqZum-YYb6VaY
middlewares.OkResponse:
type: object type: object
properties: properties:
status: status:
@ -1684,13 +1432,6 @@ components:
example: OK example: OK
data: data:
type: object type: object
properties:
base32_secret:
type: string
example: 5ZH7Y5CTFWOXN7EOLGBMMXADRNQFHVUDZSYKCN5HMFAIRSLAWY3Q
otpauth_url:
type: string
example: otpauth://totp/auth.example.com:john?algorithm=SHA1&digits=6&issuer=auth.example.com&period=30&secret=5ZH7Y5CTFWOXN7EOLGBMMXADRNQFHVUDZSYKCN5HMFAIRSLAWY3Q
handlers.UserInfo: handlers.UserInfo:
type: object type: object
properties: properties:
@ -1719,6 +1460,19 @@ components:
has_duo: has_duo:
type: boolean type: boolean
example: true example: true
handlers.UserInfo.MethodBody:
required:
- method
type: object
properties:
method:
type: string
enum:
- "totp"
- "webauthn"
- "mobile_push"
example: totp
{{- if .TOTP }}
handlers.UserInfoTOTP: handlers.UserInfoTOTP:
type: object type: object
properties: properties:
@ -1738,36 +1492,24 @@ components:
description: The number of digits defined in the users TOTP configuration description: The number of digits defined in the users TOTP configuration
type: integer type: integer
example: 6 example: 6
handlers.UserInfo.MethodBody: handlers.bodySignTOTPRequest:
required:
- method
type: object
properties:
method:
type: string
enum:
- "totp"
- "webauthn"
- "mobile_push"
example: totp
middlewares.ErrorResponse:
type: object
properties:
status:
type: string
example: KO
message:
type: string
example: Authentication failed, please retry later.
middlewares.IdentityVerificationFinishBody:
required:
- token
type: object type: object
properties: properties:
token: token:
type: string type: string
example: eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJleHAiOjE2MDc5MjU1OTYsImlzcyI6IkF1dGhlbGlhIiwiYWN0aW9uIjoiUmVzZXRQYXNzd29yZCIsInVzZXJuYW1lIjoiQW1pciJ9.636yqRrUCGCe4jsMCsonleX5CYWHncYqZum-YYb6VaY example: "123456"
middlewares.OkResponse: targetURL:
type: string
example: https://secure.example.com
workflow:
type: string
example: openid_connect
workflowID:
type: string
format: uuid
pattern: '^[0-9a-fA-F]{8}\b-[0-9a-fA-F]{4}\b-[0-9a-fA-F]{4}\b-[0-9a-fA-F]{4}\b-[0-9a-fA-F]{12}$'
example: "3ebcfbc5-b0fd-4ee0-9d3c-080ae1e7298c"
handlers.TOTPKeyResponse:
type: object type: object
properties: properties:
status: status:
@ -1775,6 +1517,15 @@ components:
example: OK example: OK
data: data:
type: object type: object
properties:
base32_secret:
type: string
example: 5ZH7Y5CTFWOXN7EOLGBMMXADRNQFHVUDZSYKCN5HMFAIRSLAWY3Q
otpauth_url:
type: string
example: otpauth://totp/auth.example.com:john?algorithm=SHA1&digits=6&issuer=auth.example.com&period=30&secret=5ZH7Y5CTFWOXN7EOLGBMMXADRNQFHVUDZSYKCN5HMFAIRSLAWY3Q
{{- end }}
{{- if .Webauthn }}
webauthn.PublicKeyCredential: webauthn.PublicKeyCredential:
type: object type: object
properties: properties:
@ -2073,6 +1824,8 @@ components:
written: written:
type: boolean type: boolean
example: false example: false
{{- end }}
{{- if .OpenIDConnect }}
openid.request.consent: openid.request.consent:
type: object type: object
properties: properties:
@ -3669,12 +3422,15 @@ components:
type: array type: array
items: items:
$ref: '#/components/schemas/jose.spec.JWK' $ref: '#/components/schemas/jose.spec.JWK'
{{- end }}
securitySchemes: securitySchemes:
authelia_auth: authelia_auth:
type: apiKey type: apiKey
name: "{{ .Session }}" name: "{{ .Session }}"
in: cookie in: cookie
{{- if .OpenIDConnect }}
openid: openid:
type: openIdConnect type: openIdConnect
openIdConnectUrl: "{{ .BaseURL }}.well-known/openid-configuration" openIdConnectUrl: "{{ .BaseURL }}.well-known/openid-configuration"
{{- end }}
... ...

View File

@ -56,6 +56,26 @@ The following functions which mimic the behaviour of helm exist in most templati
- b64dec - b64dec
- b32enc - b32enc
- b32dec - b32dec
- list
- dict
- get
- set
- isAbs
- base
- dir
- ext
- clean
- osBase
- osClean
- osDir
- osExt
- osIsAbs
- deepEqual
- typeOf
- typeIs
- typeIsLike
- kindOf
- kindIs
See the [Helm Documentation](https://helm.sh/docs/chart_template_guide/function_list/) for more information. Please See the [Helm Documentation](https://helm.sh/docs/chart_template_guide/function_list/) for more information. Please
note that only the functions listed above are supported and the functions don't necessarily behave exactly the same. note that only the functions listed above are supported and the functions don't necessarily behave exactly the same.

View File

@ -396,3 +396,8 @@ func (ctx *AutheliaCtx) SetContentTypeTextHTML() {
func (ctx *AutheliaCtx) SetContentTypeApplicationJSON() { func (ctx *AutheliaCtx) SetContentTypeApplicationJSON() {
ctx.SetContentTypeBytes(contentTypeApplicationJSON) ctx.SetContentTypeBytes(contentTypeApplicationJSON)
} }
// SetContentTypeApplicationYAML efficiently sets the Content-Type header to 'application/yaml; charset=utf-8'.
func (ctx *AutheliaCtx) SetContentTypeApplicationYAML() {
ctx.SetContentTypeBytes(contentTypeApplicationYAML)
}

View File

@ -16,6 +16,62 @@ import (
"github.com/authelia/authelia/v4/internal/session" "github.com/authelia/authelia/v4/internal/session"
) )
func TestContentTypes(t *testing.T) {
testCases := []struct {
name string
setup func(ctx *middlewares.AutheliaCtx) (err error)
expected string
}{
{
name: "ApplicationJSON",
setup: func(ctx *middlewares.AutheliaCtx) (err error) {
ctx.SetContentTypeApplicationJSON()
return nil
},
expected: "application/json; charset=utf-8",
},
{
name: "ApplicationYAML",
setup: func(ctx *middlewares.AutheliaCtx) (err error) {
ctx.SetContentTypeApplicationYAML()
return nil
},
expected: "application/yaml; charset=utf-8",
},
{
name: "TextPlain",
setup: func(ctx *middlewares.AutheliaCtx) (err error) {
ctx.SetContentTypeTextPlain()
return nil
},
expected: "text/plain; charset=utf-8",
},
{
name: "TextHTML",
setup: func(ctx *middlewares.AutheliaCtx) (err error) {
ctx.SetContentTypeTextHTML()
return nil
},
expected: "text/html; charset=utf-8",
},
}
for _, tc := range testCases {
t.Run(tc.name, func(t *testing.T) {
mock := mocks.NewMockAutheliaCtx(t)
defer mock.Close()
assert.NoError(t, tc.setup(mock.Ctx))
assert.Equal(t, tc.expected, string(mock.Ctx.Response.Header.ContentType()))
})
}
}
func TestIssuerURL(t *testing.T) { func TestIssuerURL(t *testing.T) {
testCases := []struct { testCases := []struct {
name string name string
@ -44,8 +100,8 @@ func TestIssuerURL(t *testing.T) {
mock := mocks.NewMockAutheliaCtx(t) mock := mocks.NewMockAutheliaCtx(t)
defer mock.Close() defer mock.Close()
mock.Ctx.Request.Header.Set("X-Forwarded-Proto", tc.proto) mock.Ctx.Request.Header.Set(fasthttp.HeaderXForwardedProto, tc.proto)
mock.Ctx.Request.Header.Set("X-Forwarded-Host", tc.host) mock.Ctx.Request.Header.Set(fasthttp.HeaderXForwardedHost, tc.host)
if tc.base != "" { if tc.base != "" {
mock.Ctx.SetUserValue("base_url", tc.base) mock.Ctx.SetUserValue("base_url", tc.base)
@ -103,8 +159,8 @@ func TestShouldGetOriginalURLFromOriginalURLHeader(t *testing.T) {
func TestShouldGetOriginalURLFromForwardedHeadersWithoutURI(t *testing.T) { func TestShouldGetOriginalURLFromForwardedHeadersWithoutURI(t *testing.T) {
mock := mocks.NewMockAutheliaCtx(t) mock := mocks.NewMockAutheliaCtx(t)
defer mock.Close() defer mock.Close()
mock.Ctx.Request.Header.Set("X-Forwarded-Proto", "https") mock.Ctx.Request.Header.Set(fasthttp.HeaderXForwardedProto, "https")
mock.Ctx.Request.Header.Set("X-Forwarded-Host", "home.example.com") mock.Ctx.Request.Header.Set(fasthttp.HeaderXForwardedHost, "home.example.com")
originalURL, err := mock.Ctx.GetOriginalURL() originalURL, err := mock.Ctx.GetOriginalURL()
assert.NoError(t, err) assert.NoError(t, err)
@ -142,7 +198,7 @@ func TestShouldOnlyFallbackToNonXForwardedHeadersWhenNil(t *testing.T) {
mock.Ctx.RequestCtx.Request.SetHost("localhost") mock.Ctx.RequestCtx.Request.SetHost("localhost")
mock.Ctx.RequestCtx.Request.Header.Set(fasthttp.HeaderXForwardedHost, "auth.example.com:1234") mock.Ctx.RequestCtx.Request.Header.Set(fasthttp.HeaderXForwardedHost, "auth.example.com:1234")
mock.Ctx.RequestCtx.Request.Header.Set("X-Forwarded-URI", "/base/2fa/one-time-password") mock.Ctx.RequestCtx.Request.Header.Set("X-Forwarded-URI", "/base/2fa/one-time-password")
mock.Ctx.RequestCtx.Request.Header.Set("X-Forwarded-Proto", "https") mock.Ctx.RequestCtx.Request.Header.Set(fasthttp.HeaderXForwardedProto, "https")
mock.Ctx.RequestCtx.Request.Header.Set("X-Forwarded-Method", "GET") mock.Ctx.RequestCtx.Request.Header.Set("X-Forwarded-Method", "GET")
assert.Equal(t, []byte("https"), mock.Ctx.XForwardedProto()) assert.Equal(t, []byte("https"), mock.Ctx.XForwardedProto())

View File

@ -88,6 +88,7 @@ var (
contentTypeTextPlain = []byte("text/plain; charset=utf-8") contentTypeTextPlain = []byte("text/plain; charset=utf-8")
contentTypeTextHTML = []byte("text/html; charset=utf-8") contentTypeTextHTML = []byte("text/html; charset=utf-8")
contentTypeApplicationJSON = []byte("application/json; charset=utf-8") contentTypeApplicationJSON = []byte("application/json; charset=utf-8")
contentTypeApplicationYAML = []byte("application/yaml; charset=utf-8")
) )
const ( const (

View File

@ -5,15 +5,13 @@ import (
) )
const ( const (
assetsRoot = "public_html" assetsRoot = "public_html"
assetsSwagger = assetsRoot + "/api"
fileOpenAPI = "openapi.yml" fileLogo = "logo.png"
fileIndexHTML = "index.html"
fileLogo = "logo.png"
extHTML = ".html" extHTML = ".html"
extJSON = ".json" extJSON = ".json"
extYML = ".yml"
) )
var ( var (
@ -52,8 +50,8 @@ var (
const ( const (
environment = "ENVIRONMENT" environment = "ENVIRONMENT"
dev = "dev" dev = "dev"
f = "false" strFalse = "false"
t = "true" strTrue = "true"
localhost = "localhost" localhost = "localhost"
schemeHTTP = "http" schemeHTTP = "http"
schemeHTTPS = "https" schemeHTTPS = "https"
@ -76,7 +74,8 @@ X_AUTHELIA_HEALTHCHECK_PATH=%s
` `
const ( const (
tmplCSPSwagger = "default-src 'self'; img-src 'self' https://validator.swagger.io data:; object-src 'none'; script-src 'self' 'unsafe-inline' 'nonce-%s'; style-src 'self' 'nonce-%s'; base-uri 'self'" tmplCSPSwaggerNonce = "default-src 'self'; img-src 'self' https://validator.swagger.io data:; object-src 'none'; script-src 'self' 'unsafe-inline' 'nonce-%s'; style-src 'self' 'nonce-%s'; base-uri 'self'"
tmplCSPSwagger = "default-src 'self'; img-src 'self' https://validator.swagger.io data:; object-src 'none'; script-src 'self' 'unsafe-inline'; style-src 'self'; base-uri 'self'"
) )
const ( const (

View File

@ -93,9 +93,9 @@ func handleNotFound(next fasthttp.RequestHandler) fasthttp.RequestHandler {
func handleRouter(config schema.Configuration, providers middlewares.Providers) fasthttp.RequestHandler { func handleRouter(config schema.Configuration, providers middlewares.Providers) fasthttp.RequestHandler {
optsTemplatedFile := NewTemplatedFileOptions(&config) optsTemplatedFile := NewTemplatedFileOptions(&config)
serveIndexHandler := ServeTemplatedFile(assetsRoot, fileIndexHTML, optsTemplatedFile) serveIndexHandler := ServeTemplatedFile(providers.Templates.GetAssetIndexTemplate(), optsTemplatedFile)
serveSwaggerHandler := ServeTemplatedFile(assetsSwagger, fileIndexHTML, optsTemplatedFile) serveOpenAPIHandler := ServeTemplatedOpenAPI(providers.Templates.GetAssetOpenAPIIndexTemplate(), optsTemplatedFile)
serveSwaggerAPIHandler := ServeTemplatedFile(assetsSwagger, fileOpenAPI, optsTemplatedFile) serveOpenAPISpecHandler := ETagRootURL(ServeTemplatedOpenAPI(providers.Templates.GetAssetOpenAPISpecTemplate(), optsTemplatedFile))
handlerPublicHTML := newPublicHTMLEmbeddedHandler() handlerPublicHTML := newPublicHTMLEmbeddedHandler()
handlerLocales := newLocalesEmbeddedHandler() handlerLocales := newLocalesEmbeddedHandler()
@ -126,10 +126,12 @@ func handleRouter(config schema.Configuration, providers middlewares.Providers)
r.GET("/locales/{language:[a-z]{1,3}}/{namespace:[a-z]+}.json", middlewares.AssetOverride(config.Server.AssetPath, 0, handlerLocales)) r.GET("/locales/{language:[a-z]{1,3}}/{namespace:[a-z]+}.json", middlewares.AssetOverride(config.Server.AssetPath, 0, handlerLocales))
// Swagger. // Swagger.
r.GET("/api/", middleware(serveSwaggerHandler)) r.GET("/api/", middleware(serveOpenAPIHandler))
r.OPTIONS("/api/", policyCORSPublicGET.HandleOPTIONS) r.OPTIONS("/api/", policyCORSPublicGET.HandleOPTIONS)
r.GET("/api/"+fileOpenAPI, policyCORSPublicGET.Middleware(middleware(serveSwaggerAPIHandler))) r.GET("/api/index.html", middleware(serveOpenAPIHandler))
r.OPTIONS("/api/"+fileOpenAPI, policyCORSPublicGET.HandleOPTIONS) r.OPTIONS("/api/index.html", policyCORSPublicGET.HandleOPTIONS)
r.GET("/api/openapi.yml", policyCORSPublicGET.Middleware(middleware(serveOpenAPISpecHandler)))
r.OPTIONS("/api/openapi.yml", policyCORSPublicGET.HandleOPTIONS)
for _, file := range filesSwagger { for _, file := range filesSwagger {
r.GET("/api/"+file, handlerPublicHTML) r.GET("/api/"+file, handlerPublicHTML)

View File

@ -19,6 +19,10 @@ import (
// CreateDefaultServer Create Authelia's internal webserver with the given configuration and providers. // CreateDefaultServer Create Authelia's internal webserver with the given configuration and providers.
func CreateDefaultServer(config schema.Configuration, providers middlewares.Providers) (server *fasthttp.Server, listener net.Listener, err error) { func CreateDefaultServer(config schema.Configuration, providers middlewares.Providers) (server *fasthttp.Server, listener net.Listener, err error) {
if err = providers.Templates.LoadTemplatedAssets(assets); err != nil {
return nil, nil, fmt.Errorf("failed to load templated assets")
}
server = &fasthttp.Server{ server = &fasthttp.Server{
ErrorHandler: handleError(), ErrorHandler: handleError(),
Handler: handleRouter(config, providers), Handler: handleRouter(config, providers),

View File

@ -21,6 +21,7 @@ import (
"github.com/authelia/authelia/v4/internal/configuration/schema" "github.com/authelia/authelia/v4/internal/configuration/schema"
"github.com/authelia/authelia/v4/internal/logging" "github.com/authelia/authelia/v4/internal/logging"
"github.com/authelia/authelia/v4/internal/middlewares" "github.com/authelia/authelia/v4/internal/middlewares"
"github.com/authelia/authelia/v4/internal/templates"
"github.com/authelia/authelia/v4/internal/utils" "github.com/authelia/authelia/v4/internal/utils"
) )
@ -134,10 +135,17 @@ type TLSServerContext struct {
port int port int
} }
func NewTLSServerContext(configuration schema.Configuration) (*TLSServerContext, error) { func NewTLSServerContext(configuration schema.Configuration) (serverContext *TLSServerContext, err error) {
serverContext := new(TLSServerContext) serverContext = new(TLSServerContext)
s, listener, err := CreateDefaultServer(configuration, middlewares.Providers{}) providers := middlewares.Providers{}
providers.Templates, err = templates.New(templates.Config{EmailTemplatesPath: configuration.Notifier.TemplatePath})
if err != nil {
return nil, err
}
s, listener, err := CreateDefaultServer(configuration, providers)
if err != nil { if err != nil {
return nil, err return nil, err

View File

@ -1,56 +1,43 @@
package server package server
import ( import (
"bytes"
"crypto/sha1" //nolint:gosec
"encoding/hex"
"fmt" "fmt"
"io"
"os" "os"
"path"
"path/filepath" "path/filepath"
"strconv" "strconv"
"strings" "strings"
"text/template" "sync"
"github.com/valyala/fasthttp" "github.com/valyala/fasthttp"
"github.com/authelia/authelia/v4/internal/configuration/schema" "github.com/authelia/authelia/v4/internal/configuration/schema"
"github.com/authelia/authelia/v4/internal/logging"
"github.com/authelia/authelia/v4/internal/middlewares" "github.com/authelia/authelia/v4/internal/middlewares"
"github.com/authelia/authelia/v4/internal/templates"
"github.com/authelia/authelia/v4/internal/utils" "github.com/authelia/authelia/v4/internal/utils"
) )
// ServeTemplatedFile serves a templated version of a specified file, // ServeTemplatedFile serves a templated version of a specified file,
// this is utilised to pass information between the backend and frontend // this is utilised to pass information between the backend and frontend
// and generate a nonce to support a restrictive CSP while using material-ui. // and generate a nonce to support a restrictive CSP while using material-ui.
func ServeTemplatedFile(publicDir, file string, opts *TemplatedFileOptions) middlewares.RequestHandler { func ServeTemplatedFile(t templates.Template, opts *TemplatedFileOptions) middlewares.RequestHandler {
logger := logging.Logger()
a, err := assets.Open(path.Join(publicDir, file))
if err != nil {
logger.Fatalf("Unable to open %s: %s", file, err)
}
b, err := io.ReadAll(a)
if err != nil {
logger.Fatalf("Unable to read %s: %s", file, err)
}
tmpl, err := template.New("file").Parse(string(b))
if err != nil {
logger.Fatalf("Unable to parse %s template: %s", file, err)
}
isDevEnvironment := os.Getenv(environment) == dev isDevEnvironment := os.Getenv(environment) == dev
ext := filepath.Ext(t.Name())
return func(ctx *middlewares.AutheliaCtx) { return func(ctx *middlewares.AutheliaCtx) {
logoOverride := f var err error
logoOverride := strFalse
if opts.AssetPath != "" { if opts.AssetPath != "" {
if _, err = os.Stat(filepath.Join(opts.AssetPath, fileLogo)); err == nil { if _, err = os.Stat(filepath.Join(opts.AssetPath, fileLogo)); err == nil {
logoOverride = t logoOverride = strTrue
} }
} }
switch extension := filepath.Ext(file); extension { switch ext {
case extHTML: case extHTML:
ctx.SetContentTypeTextHTML() ctx.SetContentTypeTextHTML()
case extJSON: case extJSON:
@ -62,8 +49,6 @@ func ServeTemplatedFile(publicDir, file string, opts *TemplatedFileOptions) midd
nonce := utils.RandomString(32, utils.CharSetAlphaNumeric) nonce := utils.RandomString(32, utils.CharSetAlphaNumeric)
switch { switch {
case publicDir == assetsSwagger:
ctx.Response.Header.Add(fasthttp.HeaderContentSecurityPolicy, fmt.Sprintf(tmplCSPSwagger, nonce, nonce))
case ctx.Configuration.Server.Headers.CSPTemplate != "": case ctx.Configuration.Server.Headers.CSPTemplate != "":
ctx.Response.Header.Add(fasthttp.HeaderContentSecurityPolicy, strings.ReplaceAll(ctx.Configuration.Server.Headers.CSPTemplate, placeholderCSPNonce, nonce)) ctx.Response.Header.Add(fasthttp.HeaderContentSecurityPolicy, strings.ReplaceAll(ctx.Configuration.Server.Headers.CSPTemplate, placeholderCSPNonce, nonce))
case isDevEnvironment: case isDevEnvironment:
@ -72,15 +57,99 @@ func ServeTemplatedFile(publicDir, file string, opts *TemplatedFileOptions) midd
ctx.Response.Header.Add(fasthttp.HeaderContentSecurityPolicy, fmt.Sprintf(tmplCSPDefault, nonce)) ctx.Response.Header.Add(fasthttp.HeaderContentSecurityPolicy, fmt.Sprintf(tmplCSPDefault, nonce))
} }
if err = tmpl.Execute(ctx.Response.BodyWriter(), opts.CommonData(ctx.BasePath(), ctx.RootURLSlash().String(), nonce, logoOverride)); err != nil { if err = t.Execute(ctx.Response.BodyWriter(), opts.CommonData(ctx.BasePath(), ctx.RootURLSlash().String(), nonce, logoOverride)); err != nil {
ctx.RequestCtx.Error("an error occurred", 503) ctx.RequestCtx.Error("an error occurred", 503)
logger.Errorf("Unable to execute template: %v", err) ctx.Logger.WithError(err).Errorf("Error occcurred rendering template")
return return
} }
} }
} }
// ServeTemplatedOpenAPI serves templated OpenAPI related files.
func ServeTemplatedOpenAPI(t templates.Template, opts *TemplatedFileOptions) middlewares.RequestHandler {
ext := filepath.Ext(t.Name())
spec := ext == extYML
return func(ctx *middlewares.AutheliaCtx) {
var nonce string
if spec {
ctx.Response.Header.Add(fasthttp.HeaderContentSecurityPolicy, tmplCSPSwagger)
} else {
nonce = utils.RandomString(32, utils.CharSetAlphaNumeric)
ctx.Response.Header.Add(fasthttp.HeaderContentSecurityPolicy, fmt.Sprintf(tmplCSPSwaggerNonce, nonce, nonce))
}
switch ext {
case extHTML:
ctx.SetContentTypeTextHTML()
case extYML:
ctx.SetContentTypeApplicationYAML()
default:
ctx.SetContentTypeTextPlain()
}
var err error
if err = t.Execute(ctx.Response.BodyWriter(), opts.OpenAPIData(ctx.BasePath(), ctx.RootURLSlash().String(), nonce)); err != nil {
ctx.RequestCtx.Error("an error occurred", 503)
ctx.Logger.WithError(err).Errorf("Error occcurred rendering template")
return
}
}
}
// ETagRootURL dynamically matches the If-None-Match header and adds the ETag header.
func ETagRootURL(next middlewares.RequestHandler) middlewares.RequestHandler {
etags := map[string][]byte{}
h := sha1.New() //nolint:gosec // Usage is for collision avoidance not security.
mu := &sync.Mutex{}
return func(ctx *middlewares.AutheliaCtx) {
k := ctx.RootURLSlash().String()
mu.Lock()
etag, ok := etags[k]
mu.Unlock()
if ok && bytes.Equal(etag, ctx.Request.Header.PeekBytes(headerIfNoneMatch)) {
ctx.Response.Header.SetBytesKV(headerETag, etag)
ctx.Response.Header.SetBytesKV(headerCacheControl, headerValueCacheControlETaggedAssets)
ctx.SetStatusCode(fasthttp.StatusNotModified)
return
}
next(ctx)
mu.Lock()
h.Write(ctx.Response.Body())
sum := h.Sum(nil)
h.Reset()
etagNew := make([]byte, hex.EncodedLen(len(sum)))
hex.Encode(etagNew, sum)
if !ok || !bytes.Equal(etag, etagNew) {
etags[k] = etagNew
}
mu.Unlock()
ctx.Response.Header.SetBytesKV(headerETag, etagNew)
ctx.Response.Header.SetBytesKV(headerCacheControl, headerValueCacheControlETaggedAssets)
}
}
func writeHealthCheckEnv(disabled bool, scheme, host, path string, port int) (err error) { func writeHealthCheckEnv(disabled bool, scheme, host, path string, port int) (err error) {
if disabled { if disabled {
return nil return nil
@ -120,11 +189,17 @@ func writeHealthCheckEnv(disabled bool, scheme, host, path string, port int) (er
func NewTemplatedFileOptions(config *schema.Configuration) (opts *TemplatedFileOptions) { func NewTemplatedFileOptions(config *schema.Configuration) (opts *TemplatedFileOptions) {
opts = &TemplatedFileOptions{ opts = &TemplatedFileOptions{
AssetPath: config.Server.AssetPath, AssetPath: config.Server.AssetPath,
DuoSelfEnrollment: f, DuoSelfEnrollment: strFalse,
RememberMe: strconv.FormatBool(config.Session.RememberMeDuration != schema.RememberMeDisabled), RememberMe: strconv.FormatBool(config.Session.RememberMeDuration != schema.RememberMeDisabled),
ResetPassword: strconv.FormatBool(!config.AuthenticationBackend.PasswordReset.Disable), ResetPassword: strconv.FormatBool(!config.AuthenticationBackend.PasswordReset.Disable),
ResetPasswordCustomURL: config.AuthenticationBackend.PasswordReset.CustomURL.String(), ResetPasswordCustomURL: config.AuthenticationBackend.PasswordReset.CustomURL.String(),
Theme: config.Theme, Theme: config.Theme,
EndpointsPasswordReset: !(config.AuthenticationBackend.PasswordReset.Disable || config.AuthenticationBackend.PasswordReset.CustomURL.String() != ""),
EndpointsWebauthn: !config.Webauthn.Disable,
EndpointsTOTP: !config.TOTP.Disable,
EndpointsDuo: !config.DuoAPI.Disable,
EndpointsOpenIDConnect: !(config.IdentityProviders.OIDC == nil),
} }
if !config.DuoAPI.Disable { if !config.DuoAPI.Disable {
@ -143,6 +218,12 @@ type TemplatedFileOptions struct {
ResetPasswordCustomURL string ResetPasswordCustomURL string
Session string Session string
Theme string Theme string
EndpointsPasswordReset bool
EndpointsWebauthn bool
EndpointsTOTP bool
EndpointsDuo bool
EndpointsOpenIDConnect bool
} }
// CommonData returns a TemplatedFileCommonData with the dynamic options. // CommonData returns a TemplatedFileCommonData with the dynamic options.
@ -161,6 +242,22 @@ func (options *TemplatedFileOptions) CommonData(base, baseURL, nonce, logoOverri
} }
} }
// OpenAPIData returns a TemplatedFileOpenAPIData with the dynamic options.
func (options *TemplatedFileOptions) OpenAPIData(base, baseURL, nonce string) TemplatedFileOpenAPIData {
return TemplatedFileOpenAPIData{
Base: base,
BaseURL: baseURL,
CSPNonce: nonce,
Session: options.Session,
PasswordReset: options.EndpointsPasswordReset,
Webauthn: options.EndpointsWebauthn,
TOTP: options.EndpointsTOTP,
Duo: options.EndpointsDuo,
OpenIDConnect: options.EndpointsOpenIDConnect,
}
}
// TemplatedFileCommonData is a struct which is used for many templated files. // TemplatedFileCommonData is a struct which is used for many templated files.
type TemplatedFileCommonData struct { type TemplatedFileCommonData struct {
Base string Base string
@ -174,3 +271,16 @@ type TemplatedFileCommonData struct {
Session string Session string
Theme string Theme string
} }
// TemplatedFileOpenAPIData is a struct which is used for the OpenAPI spec file.
type TemplatedFileOpenAPIData struct {
Base string
BaseURL string
CSPNonce string
Session string
PasswordReset bool
Webauthn bool
TOTP bool
Duo bool
OpenIDConnect bool
}

View File

@ -8,7 +8,6 @@ const (
// Template File Names. // Template File Names.
const ( const (
TemplateNameEmailIdentityVerification = "IdentityVerification" TemplateNameEmailIdentityVerification = "IdentityVerification"
TemplateNameEmailPasswordReset = "PasswordReset"
TemplateNameEmailEvent = "Event" TemplateNameEmailEvent = "Event"
) )

View File

@ -10,6 +10,8 @@ import (
"fmt" "fmt"
"hash" "hash"
"os" "os"
"path"
"path/filepath"
"reflect" "reflect"
"sort" "sort"
"strconv" "strconv"
@ -49,9 +51,97 @@ func FuncMap() map[string]any {
"b64dec": FuncB64Dec, "b64dec": FuncB64Dec,
"b32enc": FuncB32Enc, "b32enc": FuncB32Enc,
"b32dec": FuncB32Dec, "b32dec": FuncB32Dec,
"list": FuncList,
"dict": FuncDict,
"get": FuncGet,
"set": FuncSet,
"isAbs": path.IsAbs,
"base": path.Base,
"dir": path.Dir,
"ext": path.Ext,
"clean": path.Clean,
"osBase": filepath.Base,
"osClean": filepath.Clean,
"osDir": filepath.Dir,
"osExt": filepath.Ext,
"osIsAbs": filepath.IsAbs,
"deepEqual": reflect.DeepEqual,
"typeOf": FuncTypeOf,
"typeIs": FuncTypeIs,
"typeIsLike": FuncTypeIsLike,
"kindOf": FuncKindOf,
"kindIs": FuncKindIs,
} }
} }
// FuncTypeIs is a helper function that provides similar functionality to the helm typeIs func.
func FuncTypeIs(is string, v any) bool {
return is == FuncTypeOf(v)
}
// FuncTypeIsLike is a helper function that provides similar functionality to the helm typeIsLike func.
func FuncTypeIsLike(is string, v any) bool {
t := FuncTypeOf(v)
return is == t || "*"+is == t
}
// FuncTypeOf is a helper function that provides similar functionality to the helm typeOf func.
func FuncTypeOf(v any) string {
return reflect.ValueOf(v).Type().String()
}
// FuncKindIs is a helper function that provides similar functionality to the helm kindIs func.
func FuncKindIs(is string, v any) bool {
return is == FuncKindOf(v)
}
// FuncKindOf is a helper function that provides similar functionality to the helm kindOf func.
func FuncKindOf(v any) string {
return reflect.ValueOf(v).Kind().String()
}
// FuncList is a helper function that provides similar functionality to the helm list func.
func FuncList(items ...any) []any {
return items
}
// FuncDict is a helper function that provides similar functionality to the helm dict func.
func FuncDict(pairs ...any) map[string]any {
m := map[string]any{}
p := len(pairs)
for i := 0; i < p; i += 2 {
key := strval(pairs[i])
if i+1 >= p {
m[key] = ""
continue
}
m[key] = pairs[i+1]
}
return m
}
// FuncGet is a helper function that provides similar functionality to the helm get func.
func FuncGet(m map[string]any, key string) any {
if val, ok := m[key]; ok {
return val
}
return ""
}
// FuncSet is a helper function that provides similar functionality to the helm set func.
func FuncSet(m map[string]any, key string, value any) map[string]any {
m[key] = value
return m
}
// FuncB64Enc is a helper function that provides similar functionality to the helm b64enc func. // FuncB64Enc is a helper function that provides similar functionality to the helm b64enc func.
func FuncB64Enc(input string) string { func FuncB64Enc(input string) string {
return base64.StdEncoding.EncodeToString([]byte(input)) return base64.StdEncoding.EncodeToString([]byte(input))
@ -202,7 +292,7 @@ func FuncStringQuote(in ...any) string {
return strings.Join(out, " ") return strings.Join(out, " ")
} }
func strval(v interface{}) string { func strval(v any) string {
switch v := v.(type) { switch v := v.(type) {
case string: case string:
return v return v
@ -219,7 +309,7 @@ func strslice(v any) []string {
switch v := v.(type) { switch v := v.(type) {
case []string: case []string:
return v return v
case []interface{}: case []any:
b := make([]string, 0, len(v)) b := make([]string, 0, len(v))
for _, s := range v { for _, s := range v {

View File

@ -469,3 +469,97 @@ func TestFuncStringSQuote(t *testing.T) {
}) })
} }
} }
func TestFuncTypeOf(t *testing.T) {
astring := "typeOfExample"
anint := 5
astringslice := []string{astring}
anintslice := []int{anint}
testCases := []struct {
name string
have any
expected string
expectedKind string
}{
{"String", astring, "string", "string"},
{"StringPtr", &astring, "*string", "ptr"},
{"StringSlice", astringslice, "[]string", "slice"},
{"StringSlicePtr", &astringslice, "*[]string", "ptr"},
{"Integer", anint, "int", "int"},
{"IntegerPtr", &anint, "*int", "ptr"},
{"IntegerSlice", anintslice, "[]int", "slice"},
{"IntegerSlicePtr", &anintslice, "*[]int", "ptr"},
}
for _, tc := range testCases {
t.Run(tc.name, func(t *testing.T) {
assert.Equal(t, tc.expected, FuncTypeOf(tc.have))
assert.Equal(t, tc.expectedKind, FuncKindOf(tc.have))
})
}
}
func TestFuncTypeIs(t *testing.T) {
astring := "typeIsExample"
anint := 10
astringslice := []string{astring}
anintslice := []int{anint}
testCases := []struct {
name string
is string
have any
expected bool
expectedLike bool
expectedKind bool
}{
{"ShouldMatchStringAsString", "string", astring, true, true, true},
{"ShouldMatchStringPtrAsString", "string", &astring, false, true, false},
{"ShouldNotMatchStringAsInt", "int", astring, false, false, false},
{"ShouldNotMatchStringSliceAsStringSlice", "[]string", astringslice, true, true, false},
{"ShouldNotMatchStringSlicePtrAsStringSlice", "[]string", &astringslice, false, true, false},
{"ShouldNotMatchStringSlicePtrAsStringSlicePtr", "*[]string", &astringslice, true, true, false},
{"ShouldNotMatchStringSliceAsString", "string", astringslice, false, false, false},
{"ShouldMatchIntAsInt", "int", anint, true, true, true},
{"ShouldMatchIntPtrAsInt", "int", &anint, false, true, false},
{"ShouldNotMatchIntAsString", "string", anint, false, false, false},
{"ShouldMatchIntegerSliceAsIntSlice", "[]int", anintslice, true, true, false},
{"ShouldMatchIntegerSlicePtrAsIntSlice", "[]int", &anintslice, false, true, false},
{"ShouldMatchIntegerSlicePtrAsIntSlicePtr", "*[]int", &anintslice, true, true, false},
{"ShouldNotMatchIntegerSliceAsInt", "int", anintslice, false, false, false},
{"ShouldMatchKindSlice", "slice", anintslice, false, false, true},
{"ShouldMatchKindPtr", "ptr", &anintslice, false, false, true},
}
for _, tc := range testCases {
t.Run(tc.name, func(t *testing.T) {
assert.Equal(t, tc.expected, FuncTypeIs(tc.is, tc.have))
assert.Equal(t, tc.expectedLike, FuncTypeIsLike(tc.is, tc.have))
assert.Equal(t, tc.expectedKind, FuncKindIs(tc.is, tc.have))
})
}
}
func TestFuncList(t *testing.T) {
assert.Equal(t, []any{"a", "b", "c"}, FuncList("a", "b", "c"))
assert.Equal(t, []any{1, 2, 3}, FuncList(1, 2, 3))
}
func TestFuncDict(t *testing.T) {
assert.Equal(t, map[string]any{"a": 1}, FuncDict("a", 1))
assert.Equal(t, map[string]any{"a": 1, "b": ""}, FuncDict("a", 1, "b"))
assert.Equal(t, map[string]any{"1": 1, "b": 2}, FuncDict(1, 1, "b", 2))
assert.Equal(t, map[string]any{"true": 1, "b": 2}, FuncDict(true, 1, "b", 2))
assert.Equal(t, map[string]any{"a": 2, "b": 3}, FuncDict("a", 1, "a", 2, "b", 3))
}
func TestFuncGet(t *testing.T) {
assert.Equal(t, 123, FuncGet(map[string]any{"abc": 123}, "abc"))
assert.Equal(t, "", FuncGet(map[string]any{"abc": 123}, "123"))
}
func TestFuncSet(t *testing.T) {
assert.Equal(t, map[string]any{"abc": 123, "123": true}, FuncSet(map[string]any{"abc": 123}, "123", true))
assert.Equal(t, map[string]any{"abc": true}, FuncSet(map[string]any{"abc": 123}, "abc", true))
}

View File

@ -1,7 +1,9 @@
package templates package templates
import ( import (
"embed"
"fmt" "fmt"
"text/template"
) )
// New creates a new templates' provider. // New creates a new templates' provider.
@ -23,6 +25,64 @@ type Provider struct {
templates Templates templates Templates
} }
// LoadTemplatedAssets takes an embed.FS and loads each templated asset document into a Template.
func (p *Provider) LoadTemplatedAssets(fs embed.FS) (err error) {
var (
data []byte
)
if data, err = fs.ReadFile("public_html/index.html"); err != nil {
return err
}
if p.templates.asset.index, err = template.
New("assets/public_html/index.html").
Funcs(FuncMap()).
Parse(string(data)); err != nil {
return err
}
if data, err = fs.ReadFile("public_html/api/index.html"); err != nil {
return err
}
if p.templates.asset.api.index, err = template.
New("assets/public_html/api/index.html").
Funcs(FuncMap()).
Parse(string(data)); err != nil {
return err
}
if data, err = fs.ReadFile("public_html/api/openapi.yml"); err != nil {
return err
}
if p.templates.asset.api.spec, err = template.
New("api/public_html/openapi.yaml").
Funcs(FuncMap()).
Parse(string(data)); err != nil {
return err
}
return nil
}
// GetAssetIndexTemplate returns a Template used to generate the React index document.
func (p *Provider) GetAssetIndexTemplate() (t Template) {
return p.templates.asset.index
}
// GetAssetOpenAPIIndexTemplate returns a Template used to generate the OpenAPI index document.
func (p *Provider) GetAssetOpenAPIIndexTemplate() (t Template) {
return p.templates.asset.api.index
}
// GetAssetOpenAPISpecTemplate returns a Template used to generate the OpenAPI specification document.
func (p *Provider) GetAssetOpenAPISpecTemplate() (t Template) {
return p.templates.asset.api.spec
}
// GetEventEmailTemplate returns an EmailTemplate used for generic event notifications.
func (p *Provider) GetEventEmailTemplate() (t *EmailTemplate) { func (p *Provider) GetEventEmailTemplate() (t *EmailTemplate) {
return p.templates.notification.event return p.templates.notification.event
} }

View File

@ -9,6 +9,17 @@ import (
// Templates is the struct which holds all the *template.Template values. // Templates is the struct which holds all the *template.Template values.
type Templates struct { type Templates struct {
notification NotificationTemplates notification NotificationTemplates
asset AssetTemplates
}
type AssetTemplates struct {
index *tt.Template
api APIAssetTemplates
}
type APIAssetTemplates struct {
index *tt.Template
spec *tt.Template
} }
// NotificationTemplates are the templates for the notification system. // NotificationTemplates are the templates for the notification system.

View File

@ -39,24 +39,17 @@ func isSecretEnvKey(key string) (isSecretEnvKey bool) {
return false return false
} }
func templateExists(path string) (exists bool) { func fileExists(path string) (exists bool) {
info, err := os.Stat(path) info, err := os.Stat(path)
if err != nil {
return false
}
if info.IsDir() { return err == nil && !info.IsDir()
return false
}
return true
} }
func readTemplate(name, ext, category, overridePath string) (tPath string, embed bool, data []byte, err error) { func readTemplate(name, ext, category, overridePath string) (tPath string, embed bool, data []byte, err error) {
if overridePath != "" { if overridePath != "" {
tPath = filepath.Join(overridePath, name+ext) tPath = filepath.Join(overridePath, name+ext)
if templateExists(tPath) { if fileExists(tPath) {
if data, err = os.ReadFile(tPath); err != nil { if data, err = os.ReadFile(tPath); err != nil {
return tPath, false, nil, fmt.Errorf("failed to read template override at path '%s': %w", tPath, err) return tPath, false, nil, fmt.Errorf("failed to read template override at path '%s': %w", tPath, err)
} }