diff --git a/.env b/.env index 1f5f0f141e9c1391bcd5b0325fbb516f0fa4bbfb..f0f1fecc697b60d78a401477ee01c6c5febbc5a5 100644 --- a/.env +++ b/.env @@ -7,4 +7,6 @@ LOG_FLUSH_INTERVAL_MS=1000 DB_STRING="host=localhost user=ocw password=ocw dbname=ocw-db port=5433 sslmode=disable TimeZone=Asia/Shanghai" SMTP_USERNAME="noreply@ocw.id" SMTP_SERVER=localhost -SMTP_PORT=1025 \ No newline at end of file +SMTP_PORT=1025 +REDIS_STRING="localhost" +FE_BASE_URL="http://localhost:3000" \ No newline at end of file diff --git a/.env.docker b/.env.docker index 11f1edfc100a7fcebca3466da30fcdd21feff42d..2f87fea919b87db0f51f52306a1a8c21e13e3aaf 100644 --- a/.env.docker +++ b/.env.docker @@ -1,3 +1,14 @@ POSTGRES_USER=ocw POSTGRES_PASSWORD=ocw POSTGRES_DB=ocw-db +ENV=DEVELOPMENT +LISTEN_ADDR=0.0.0.0 +PORT=8080 +LOGTAIL_TOKEN= +HTTP_TIMEOUT_SEC=2 +LOG_FLUSH_INTERVAL_MS=1000 +DB_STRING="host=database user=ocw password=ocw dbname=ocw-db port=5432 sslmode=disable TimeZone=Asia/Shanghai" +SMTP_USERNAME="noreply@ocw.id" +SMTP_SERVER=mailhog +SMTP_PORT=1025 +REDIS_STRING="redis" \ No newline at end of file diff --git a/.gitignore b/.gitignore index 7e2965e3e35f23ac242d20ffece83456a71de7ef..c5c13586f4cb91a1d501686acc635db166cd0398 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,4 @@ bin/ tmp/ wire_gen.go +__debug_bin* \ No newline at end of file diff --git a/Makefile b/Makefile index 65b15586a8cec0a52bff462637f75ad06310cc3e..948871652a86d5e028ef4f13a7c2571766d27ec1 100644 --- a/Makefile +++ b/Makefile @@ -13,7 +13,7 @@ run: dependency build: dependency @go build -o=bin/server.app . -watch: +watch: dependency @air --build.cmd="make build" --build.bin="./bin/server.app" --build.exclude_dir="bin,tmp,docs" --build.exclude_file="wire_gen.go" test: test-dependency diff --git a/docker-compose.yml b/docker-compose.yml index 7ccabe6b9f336b3be6a3a55f4c8d11c8c2127306..3eccf485e7a93a9172dcdfca800f8bcf2b732655 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -21,7 +21,7 @@ services: - .:/app ports: - 8888:8080 - env_file: .env + env_file: .env.docker depends_on: - database - minio diff --git a/docs/docs.go b/docs/docs.go index 5e9e1a5761c0a19b0b6a37940422fc4f045a04b9..cf30202b3fb0d89fde8a104b485c022b647a0734 100644 --- a/docs/docs.go +++ b/docs/docs.go @@ -315,7 +315,7 @@ const docTemplate = `{ }, "/auth/register": { "post": { - "description": "Generate New Account as Member", + "description": "Do Email Verification", "consumes": [ "application/json" ], @@ -325,7 +325,7 @@ const docTemplate = `{ "tags": [ "auth" ], - "summary": "Register New Account", + "summary": "Email Verification", "parameters": [ { "description": "Register Payload", @@ -333,7 +333,7 @@ const docTemplate = `{ "in": "body", "required": true, "schema": { - "$ref": "#/definitions/register.RegisterRequestPayload" + "$ref": "#/definitions/verification.VerificationRequestPayload" } } ], @@ -358,6 +358,104 @@ const docTemplate = `{ } } } + }, + "/reset/confirm": { + "post": { + "description": "Do confirmation to reset password", + "produces": [ + "application/json" + ], + "tags": [ + "reset" + ], + "summary": "Confirm Reset Password", + "parameters": [ + { + "type": "string", + "description": "Email validation token", + "name": "Authorization", + "in": "header", + "required": true + }, + { + "description": "payload", + "name": "data", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/confirm.ConfirmRequestPayload" + } + } + ], + "responses": { + "200": { + "description": "Login Success", + "schema": { + "$ref": "#/definitions/web.BaseResponse" + } + } + } + } + }, + "/reset/request": { + "post": { + "description": "Send Reset password token to email", + "produces": [ + "application/json" + ], + "tags": [ + "reset" + ], + "summary": "Request Reset Password Token", + "parameters": [ + { + "description": "payload", + "name": "data", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/request.RequestRequestPayload" + } + } + ], + "responses": { + "200": { + "description": "Login Success", + "schema": { + "$ref": "#/definitions/web.BaseResponse" + } + } + } + } + }, + "/reset/validate": { + "get": { + "description": "Send Reset password token to email", + "produces": [ + "application/json" + ], + "tags": [ + "reset" + ], + "summary": "Request Reset Password Token", + "parameters": [ + { + "type": "string", + "description": "Email validation token", + "name": "Authorization", + "in": "header", + "required": true + } + ], + "responses": { + "200": { + "description": "Login Success", + "schema": { + "$ref": "#/definitions/web.BaseResponse" + } + } + } + } } }, "definitions": { @@ -411,6 +509,30 @@ const docTemplate = `{ } } }, + "confirm.ConfirmRequestPayload": { + "description": "Information that should be available when you confirm a password reset", + "type": "object", + "required": [ + "password", + "password_validation" + ], + "properties": { + "confirmToken": { + "description": "Web Token that was appended to the link", + "type": "string" + }, + "password": { + "description": "User Password", + "type": "string", + "example": "secret" + }, + "password_validation": { + "description": "User Password Validation, must be same as user", + "type": "string", + "example": "secret" + } + } + }, "login.LoginRequestPayload": { "description": "Information that should be available when do a login process", "type": "object", @@ -487,6 +609,34 @@ const docTemplate = `{ } } }, + "request.RequestRequestPayload": { + "description": "Information that should be available when password reset is requested", + "type": "object", + "required": [ + "email" + ], + "properties": { + "email": { + "description": "User Email", + "type": "string", + "example": "someone@example.com" + } + } + }, + "verification.VerificationRequestPayload": { + "description": "Information that should be passed when request verify", + "type": "object", + "required": [ + "email" + ], + "properties": { + "email": { + "description": "User Email", + "type": "string", + "example": "someone@example.com" + } + } + }, "web.BaseResponse": { "type": "object", "properties": { diff --git a/docs/swagger.json b/docs/swagger.json index 84edd189e1059594347807ded586f484aa7714d9..b0c36239bb54582921dae56d834e5903e59ebe55 100644 --- a/docs/swagger.json +++ b/docs/swagger.json @@ -307,7 +307,7 @@ }, "/auth/register": { "post": { - "description": "Generate New Account as Member", + "description": "Do Email Verification", "consumes": [ "application/json" ], @@ -317,7 +317,7 @@ "tags": [ "auth" ], - "summary": "Register New Account", + "summary": "Email Verification", "parameters": [ { "description": "Register Payload", @@ -325,7 +325,7 @@ "in": "body", "required": true, "schema": { - "$ref": "#/definitions/register.RegisterRequestPayload" + "$ref": "#/definitions/verification.VerificationRequestPayload" } } ], @@ -350,6 +350,104 @@ } } } + }, + "/reset/confirm": { + "post": { + "description": "Do confirmation to reset password", + "produces": [ + "application/json" + ], + "tags": [ + "reset" + ], + "summary": "Confirm Reset Password", + "parameters": [ + { + "type": "string", + "description": "Email validation token", + "name": "Authorization", + "in": "header", + "required": true + }, + { + "description": "payload", + "name": "data", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/confirm.ConfirmRequestPayload" + } + } + ], + "responses": { + "200": { + "description": "Login Success", + "schema": { + "$ref": "#/definitions/web.BaseResponse" + } + } + } + } + }, + "/reset/request": { + "post": { + "description": "Send Reset password token to email", + "produces": [ + "application/json" + ], + "tags": [ + "reset" + ], + "summary": "Request Reset Password Token", + "parameters": [ + { + "description": "payload", + "name": "data", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/request.RequestRequestPayload" + } + } + ], + "responses": { + "200": { + "description": "Login Success", + "schema": { + "$ref": "#/definitions/web.BaseResponse" + } + } + } + } + }, + "/reset/validate": { + "get": { + "description": "Send Reset password token to email", + "produces": [ + "application/json" + ], + "tags": [ + "reset" + ], + "summary": "Request Reset Password Token", + "parameters": [ + { + "type": "string", + "description": "Email validation token", + "name": "Authorization", + "in": "header", + "required": true + } + ], + "responses": { + "200": { + "description": "Login Success", + "schema": { + "$ref": "#/definitions/web.BaseResponse" + } + } + } + } } }, "definitions": { @@ -403,6 +501,30 @@ } } }, + "confirm.ConfirmRequestPayload": { + "description": "Information that should be available when you confirm a password reset", + "type": "object", + "required": [ + "password", + "password_validation" + ], + "properties": { + "confirmToken": { + "description": "Web Token that was appended to the link", + "type": "string" + }, + "password": { + "description": "User Password", + "type": "string", + "example": "secret" + }, + "password_validation": { + "description": "User Password Validation, must be same as user", + "type": "string", + "example": "secret" + } + } + }, "login.LoginRequestPayload": { "description": "Information that should be available when do a login process", "type": "object", @@ -479,6 +601,34 @@ } } }, + "request.RequestRequestPayload": { + "description": "Information that should be available when password reset is requested", + "type": "object", + "required": [ + "email" + ], + "properties": { + "email": { + "description": "User Email", + "type": "string", + "example": "someone@example.com" + } + } + }, + "verification.VerificationRequestPayload": { + "description": "Information that should be passed when request verify", + "type": "object", + "required": [ + "email" + ], + "properties": { + "email": { + "description": "User Email", + "type": "string", + "example": "someone@example.com" + } + } + }, "web.BaseResponse": { "type": "object", "properties": { diff --git a/docs/swagger.yaml b/docs/swagger.yaml index beaf58f3d9c777673c50b54fdc87d44aa3bae145..e01ccea263c225cf596ae8d2dd0a9750a1422851 100644 --- a/docs/swagger.yaml +++ b/docs/swagger.yaml @@ -37,6 +37,25 @@ definitions: - name - role type: object + confirm.ConfirmRequestPayload: + description: Information that should be available when you confirm a password + reset + properties: + confirmToken: + description: Web Token that was appended to the link + type: string + password: + description: User Password + example: secret + type: string + password_validation: + description: User Password Validation, must be same as user + example: secret + type: string + required: + - password + - password_validation + type: object login.LoginRequestPayload: description: Information that should be available when do a login process properties: @@ -94,6 +113,26 @@ definitions: - password - password_validation type: object + request.RequestRequestPayload: + description: Information that should be available when password reset is requested + properties: + email: + description: User Email + example: someone@example.com + type: string + required: + - email + type: object + verification.VerificationRequestPayload: + description: Information that should be passed when request verify + properties: + email: + description: User Email + example: someone@example.com + type: string + required: + - email + type: object web.BaseResponse: properties: data: {} @@ -302,14 +341,14 @@ paths: post: consumes: - application/json - description: Generate New Account as Member + description: Do Email Verification parameters: - description: Register Payload in: body name: data required: true schema: - $ref: '#/definitions/register.RegisterRequestPayload' + $ref: '#/definitions/verification.VerificationRequestPayload' produces: - application/json responses: @@ -325,7 +364,71 @@ paths: description: Internal Server Error schema: $ref: '#/definitions/web.BaseResponse' - summary: Register New Account + summary: Email Verification tags: - auth + /reset/confirm: + post: + description: Do confirmation to reset password + parameters: + - description: Email validation token + in: header + name: Authorization + required: true + type: string + - description: payload + in: body + name: data + required: true + schema: + $ref: '#/definitions/confirm.ConfirmRequestPayload' + produces: + - application/json + responses: + "200": + description: Login Success + schema: + $ref: '#/definitions/web.BaseResponse' + summary: Confirm Reset Password + tags: + - reset + /reset/request: + post: + description: Send Reset password token to email + parameters: + - description: payload + in: body + name: data + required: true + schema: + $ref: '#/definitions/request.RequestRequestPayload' + produces: + - application/json + responses: + "200": + description: Login Success + schema: + $ref: '#/definitions/web.BaseResponse' + summary: Request Reset Password Token + tags: + - reset + /reset/validate: + get: + description: Send Reset password token to email + parameters: + - description: Email validation token + in: header + name: Authorization + required: true + type: string + produces: + - application/json + responses: + "200": + description: Login Success + schema: + $ref: '#/definitions/web.BaseResponse' + summary: Request Reset Password Token + tags: + - reset swagger: "2.0" diff --git a/go.mod b/go.mod index b69d320688f3db0c6bbdcc1ad19eea994fde74ea..b6cf3e2e6812ad2a15b62a2272bf6fc00fc72b76 100644 --- a/go.mod +++ b/go.mod @@ -19,6 +19,11 @@ require ( gorm.io/gorm v1.24.5 ) +require ( + github.com/cespare/xxhash/v2 v2.1.2 // indirect + github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f // indirect +) + require ( github.com/DATA-DOG/go-sqlmock v1.5.0 // indirect github.com/KyleBanks/depth v1.2.1 // indirect @@ -35,6 +40,8 @@ require ( github.com/go-openapi/swag v0.22.3 // indirect github.com/go-playground/locales v0.14.1 // indirect github.com/go-playground/universal-translator v0.18.1 // indirect + github.com/go-redis/redis/v8 v8.11.5 + github.com/gomodule/redigo v1.8.9 github.com/jackc/pgpassfile v1.0.0 // indirect github.com/jackc/pgservicefile v0.0.0-20221227161230-091c0ba34f0a // indirect github.com/jackc/pgx/v5 v5.2.0 // indirect diff --git a/go.sum b/go.sum index d771a874f694fecc968e13f8658ba7de1cd1ca6f..104db32d430bfc3149b43928c0b68f3784d9056d 100644 --- a/go.sum +++ b/go.sum @@ -8,6 +8,8 @@ github.com/PuerkitoBio/urlesc v0.0.0-20170810143723-de5bf2ad4578 h1:d+Bc7a5rLufV github.com/PuerkitoBio/urlesc v0.0.0-20170810143723-de5bf2ad4578/go.mod h1:uGdkoq3SwY9Y+13GIhn11/XLaGBb4BfwItxLd5jeuXE= github.com/caarlos0/env/v6 v6.10.1 h1:t1mPSxNpei6M5yAeu1qtRdPAK29Nbcf/n3G7x+b3/II= github.com/caarlos0/env/v6 v6.10.1/go.mod h1:hvp/ryKXKipEkcuYjs9mI4bBCg+UI0Yhgm5Zu0ddvwc= +github.com/cespare/xxhash/v2 v2.1.2 h1:YRXhKfTDauu4ajMg1TPgFO5jnlC2HCbmLXMcTG5cbYE= +github.com/cespare/xxhash/v2 v2.1.2/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= github.com/cosmtrek/air v1.41.0 h1:6ck2LbcVvby6cyuwE8ruia41U2nppMZGWOpq+E/EhoU= github.com/cosmtrek/air v1.41.0/go.mod h1:+RBGjJt7T2f3I7td8Tvk0XsH/hZ3E1QBLfiWObICO4c= github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E= @@ -16,6 +18,8 @@ github.com/creack/pty v1.1.18/go.mod h1:MOBLtS5ELjhRRrroQr9kyvTxUAFNvYEK993ew/Vr github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f h1:lO4WD4F/rVNCu3HqELle0jiPLLBs70cWOduZpkS1E78= +github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f/go.mod h1:cuUVRXasLTGF7a8hSLbxyZXjz+1KgoB3wDUb6vlszIc= github.com/fatih/color v1.14.1 h1:qfhVLaG5s+nCROl1zJsZRxFeYrHLqWroPOQ8BWiNb4w= github.com/fatih/color v1.14.1/go.mod h1:2oHN61fhTpgcxD3TSWCgKDiH1+x4OiDVVGH8WlgGZGg= github.com/fsnotify/fsnotify v1.6.0 h1:n+5WquG0fcWoWp6xPWfHdbskMCQaFnG6PfBrh1Ky4HY= @@ -49,8 +53,12 @@ github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJn github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY= github.com/go-playground/validator/v10 v10.11.2 h1:q3SHpufmypg+erIExEKUmsgmhDTyhcJ38oeKGACXohU= github.com/go-playground/validator/v10 v10.11.2/go.mod h1:NieE624vt4SCTJtD87arVLvdmjPAeV8BQlHtMnw9D7s= +github.com/go-redis/redis/v8 v8.11.5 h1:AcZZR7igkdvfVmQTPnu9WE37LRrO/YrBH5zWyjDC0oI= +github.com/go-redis/redis/v8 v8.11.5/go.mod h1:gREzHqY1hg6oD9ngVRbLStwAWKhA0FEgq8Jd4h5lpwo= github.com/golang-jwt/jwt/v4 v4.4.3 h1:Hxl6lhQFj4AnOX6MLrsCb/+7tCj7DxP7VA+2rDIq5AU= github.com/golang-jwt/jwt/v4 v4.4.3/go.mod h1:m21LjoU+eqJr34lmDMbreY2eSTRJ1cv77w39/MY0Ch0= +github.com/gomodule/redigo v1.8.9 h1:Sl3u+2BI/kk+VEatbj0scLdrFhjPmbxOc1myhDP41ws= +github.com/gomodule/redigo v1.8.9/go.mod h1:7ArFNvsTjH8GMMzB4uy1snslv2BwmginuMs06a1uzZE= github.com/google/go-cmp v0.2.0/go.mod h1:oXzfMopK8JAjlY9xF4vHSVASa0yLyX7SntLO5aqRK0M= github.com/google/subcommands v1.0.1/go.mod h1:ZjhPrFU+Olkh9WazFPsl27BQ4UPiG37m3yTrtFlrHVk= github.com/google/wire v0.5.0 h1:I7ELFeVBr3yfPIcc8+MWvrjk+3VjbcSzoXm3JVa+jD8= diff --git a/handler/auth/types.go b/handler/auth/types.go index 71b4d1eba14c50a290f9c43565da1dd707e73f29..1b70a41ef00b627a24521bb24ab8804bfe24c640 100644 --- a/handler/auth/types.go +++ b/handler/auth/types.go @@ -6,4 +6,5 @@ type AuthHandler interface { Login(w http.ResponseWriter, r *http.Request) Register(w http.ResponseWriter, r *http.Request) Refresh(w http.ResponseWriter, r *http.Request) + EmailVerify(w http.ResponseWriter, r *http.Request) } diff --git a/handler/auth/verify.go b/handler/auth/verify.go new file mode 100644 index 0000000000000000000000000000000000000000..6ab6b25233d8c7da2f4339abe7bd439736a979f1 --- /dev/null +++ b/handler/auth/verify.go @@ -0,0 +1,72 @@ +package auth + +import ( + "fmt" + "net/http" + + "github.com/go-playground/validator/v10" + "gitlab.informatika.org/ocw/ocw-backend/model/web" + "gitlab.informatika.org/ocw/ocw-backend/model/web/auth/verification" +) + +// Index godoc +// +// @Tags auth +// @Summary Email Verification +// @Description Do Email Verification +// @Produce json +// @Accept json +// @Param data body verification.VerificationRequestPayload true "Register Payload" +// @Success 200 {object} web.BaseResponse +// @Failure 400 {object} web.BaseResponse +// @Failure 500 {object} web.BaseResponse +// @Router /auth/register [post] +func (a AuthHandlerImpl) EmailVerify(w http.ResponseWriter, r *http.Request) { + payload := verification.VerificationRequestPayload{} + validate := validator.New() + + if r.Header.Get("Content-Type") != "application/json" { + payload := a.WrapperUtil.ErrorResponseWrap("this service only receive json input", nil) + a.HttpUtil.WriteJson(w, http.StatusUnsupportedMediaType, payload) + return + } + + if err := a.HttpUtil.ParseJson(r, &payload); err != nil { + payload := a.WrapperUtil.ErrorResponseWrap("invalid json input", err.Error()) + a.HttpUtil.WriteJson(w, http.StatusUnprocessableEntity, payload) + return + } + + if err := validate.Struct(payload); err != nil { + if _, ok := err.(*validator.InvalidValidationError); ok { + payload := a.WrapperUtil.ErrorResponseWrap(err.Error(), nil) + a.HttpUtil.WriteJson(w, http.StatusBadRequest, payload) + return + } + + errPayload := web.NewResponseErrorFromValidator(err.(validator.ValidationErrors)) + payload := a.WrapperUtil.ErrorResponseWrap(errPayload.Error(), errPayload) + a.HttpUtil.WriteJson(w, http.StatusBadRequest, payload) + return + } + + err := a.AuthService.VerifyEmail(payload) + + if err != nil { + respErr, ok := err.(web.ResponseError) + if ok { + payload := a.WrapperUtil.ErrorResponseWrap("you have reach limit of resend verification", respErr) + a.HttpUtil.WriteJson(w, http.StatusBadRequest, payload) + } else { + a.Logger.Error( + fmt.Sprintf("[AUTH] some error happened when do email verification: %s", err.Error()), + ) + payload := a.WrapperUtil.ErrorResponseWrap("internal server error", nil) + a.HttpUtil.WriteJson(w, http.StatusInternalServerError, payload) + } + return + } + + responsePayload := a.WrapperUtil.SuccessResponseWrap(nil) + a.HttpUtil.WriteSuccessJson(w, responsePayload) +} diff --git a/handler/reset/confirm.go b/handler/reset/confirm.go index e68ba0568c58bacb594068f183d79c7d5a20f94b..1a1d33a297c1a303a2ed787b25a43f636904f8e1 100644 --- a/handler/reset/confirm.go +++ b/handler/reset/confirm.go @@ -10,6 +10,16 @@ import ( "gitlab.informatika.org/ocw/ocw-backend/model/web/reset/confirm" ) +// Index godoc +// +// @Tags reset +// @Summary Confirm Reset Password +// @Description Do confirmation to reset password +// @Produce json +// @Param Authorization header string true "Email validation token" +// @Param data body confirm.ConfirmRequestPayload true "payload" +// @Success 200 {object} web.BaseResponse "Login Success" +// @Router /reset/confirm [post] func (rs ResetHandlerImpl) Confirm(w http.ResponseWriter, r *http.Request) { payload := confirm.ConfirmRequestPayload{} validate := validator.New() @@ -88,4 +98,4 @@ func (rs ResetHandlerImpl) Confirm(w http.ResponseWriter, r *http.Request) { responsePayload := rs.WrapperUtil.SuccessResponseWrap(nil) rs.HttpUtil.WriteSuccessJson(w, responsePayload) -} \ No newline at end of file +} diff --git a/handler/reset/request.go b/handler/reset/request.go index f2dc0d450e58ca4c26d33f574a403e3e3269c221..e118a0261b97d7bd0dbec2cde86644293d786313 100644 --- a/handler/reset/request.go +++ b/handler/reset/request.go @@ -10,6 +10,15 @@ import ( "gitlab.informatika.org/ocw/ocw-backend/model/web/reset/request" ) +// Index godoc +// +// @Tags reset +// @Summary Request Reset Password Token +// @Description Send Reset password token to email +// @Produce json +// @Param data body request.RequestRequestPayload true "payload" +// @Success 200 {object} web.BaseResponse "Login Success" +// @Router /reset/request [post] func (rs ResetHandlerImpl) Request(w http.ResponseWriter, r *http.Request) { payload := request.RequestRequestPayload{} validate := validator.New() @@ -62,4 +71,4 @@ func (rs ResetHandlerImpl) Request(w http.ResponseWriter, r *http.Request) { responsePayload := rs.WrapperUtil.SuccessResponseWrap(nil) rs.HttpUtil.WriteSuccessJson(w, responsePayload) -} \ No newline at end of file +} diff --git a/handler/reset/validate.go b/handler/reset/validate.go index 19d48dbfdd2e9cf42e5c874779a1f03c8db100f9..1bfa4dec36c2c1f3f2d2ff94110c80070906c3db 100644 --- a/handler/reset/validate.go +++ b/handler/reset/validate.go @@ -9,6 +9,15 @@ import ( "gitlab.informatika.org/ocw/ocw-backend/model/web/reset/validate" ) +// Index godoc +// +// @Tags reset +// @Summary Request Reset Password Token +// @Description Send Reset password token to email +// @Produce json +// @Param Authorization header string true "Email validation token" +// @Success 200 {object} web.BaseResponse "Login Success" +// @Router /reset/validate [get] func (rs ResetHandlerImpl) Validate(w http.ResponseWriter, r *http.Request) { payload := validate.ValidateRequestPayload{} validateTokenHeader := r.Header.Get("Authorization") @@ -54,4 +63,4 @@ func (rs ResetHandlerImpl) Validate(w http.ResponseWriter, r *http.Request) { responsePayload := rs.WrapperUtil.SuccessResponseWrap(nil) rs.HttpUtil.WriteSuccessJson(w, responsePayload) -} \ No newline at end of file +} diff --git a/model/domain/cache/cache.go b/model/domain/cache/cache.go new file mode 100644 index 0000000000000000000000000000000000000000..dc0d3361389b8dc39be807a61dbe54d890fa5577 --- /dev/null +++ b/model/domain/cache/cache.go @@ -0,0 +1,60 @@ +package cache + +import "fmt" + +type String struct { + Key Key + Value string + ExpiryInMinutes int +} + +type Hash struct { + Key Key + Values []Value + ExpiryInMinutes int +} + +type Key struct { + Hash string + Id string +} + +type Value struct { + Field string + Store string +} + +func (c *Hash) AppendValue(value Value) *Hash { + c.Values = append(c.Values, value) + return c +} + +func NewKey(hash string, id string) *Key { + return &Key{hash, id} +} + +func (k Key) String() string { + return fmt.Sprintf("%s:%s", k.Hash, k.Id) +} + +func NewValue(field string, store string) *Value { + return &Value{field, store} +} + +func NewString(key Key, value string, expiryInMinutes int) *String { + return &String{key, value, expiryInMinutes} +} + +func NewHash(key Key, initValue Value, expiryInMinutes int) *Hash { + return &Hash{key, []Value{initValue}, expiryInMinutes} +} + +func (c *Hash) Slice() []interface{} { + slice := make([]interface{}, len(c.Values)*2+1) + slice[0] = c.Key.String() + for i := range make([]int, len(c.Values)) { + slice[i*2+1] = c.Values[i].Field + slice[i*2+2] = c.Values[i].Store + } + return slice +} diff --git a/model/domain/user/verification.go b/model/domain/user/verification.go new file mode 100644 index 0000000000000000000000000000000000000000..1d0d04925c079c1f18de8fdb125595aa6ee937f7 --- /dev/null +++ b/model/domain/user/verification.go @@ -0,0 +1,7 @@ +package user + +type EmailData struct { + Email string + BaseUrl string + Token string +} diff --git a/model/web/auth/verification/request.go b/model/web/auth/verification/request.go new file mode 100644 index 0000000000000000000000000000000000000000..52b9cbc8b19133a25aca771ca96f4633e07deafa --- /dev/null +++ b/model/web/auth/verification/request.go @@ -0,0 +1,8 @@ +package verification + +// Email Verification Request Payload +// @Description Information that should be passed when request verify +type VerificationRequestPayload struct { + // User Email + Email string `json:"email" validate:"required,email" example:"someone@example.com"` +} diff --git a/provider/di.go b/provider/di.go index 51e100b166816bee6b5d9c23147f9470831a1b9f..e4e73e3c160c589defec01bf93bee94ab81d93bc 100644 --- a/provider/di.go +++ b/provider/di.go @@ -5,6 +5,7 @@ import ( "gitlab.informatika.org/ocw/ocw-backend/provider/db" "gitlab.informatika.org/ocw/ocw-backend/provider/mail" "gitlab.informatika.org/ocw/ocw-backend/provider/mail/smtp" + "gitlab.informatika.org/ocw/ocw-backend/provider/redis" ) var ProviderTestSet = wire.NewSet( @@ -14,6 +15,10 @@ var ProviderTestSet = wire.NewSet( wire.Bind(new(mail.MailQueue), new(*mail.MailQueueImpl)), wire.Bind(new(mail.MailProvider), new(*smtp.SmtpMailProvider)), + + // Redis utility + wire.Bind(new(redis.Redis), new(*redis.RedisImpl)), + redis.NewRedisEnv, ) var ProviderSet = wire.NewSet( diff --git a/provider/mail/smtp/smtp.go b/provider/mail/smtp/smtp.go index dd2873a17083a7381fa3009679552d7dbd121590..40053da847885d83306541305c303d3aed8b0568 100644 --- a/provider/mail/smtp/smtp.go +++ b/provider/mail/smtp/smtp.go @@ -30,6 +30,7 @@ func (s SmtpMailProvider) Send(to []string, subject string, message string) erro payload := fmt.Sprintf( "To: %s\r\n"+ "Subject: %s\r\n"+ + "Content-Type: text/html; charset=UTF-8\r\n"+ "\r\n%s\r\n", to, subject, message, ) diff --git a/provider/redis/cache.go b/provider/redis/cache.go new file mode 100644 index 0000000000000000000000000000000000000000..9d656ae2093191e41cdd8666e7f20f5b2c185a92 --- /dev/null +++ b/provider/redis/cache.go @@ -0,0 +1,84 @@ +package redis + +import ( + "fmt" + "os" + "runtime/debug" + "strings" + "time" + + "github.com/gomodule/redigo/redis" + "gitlab.informatika.org/ocw/ocw-backend/service/logger" + "gitlab.informatika.org/ocw/ocw-backend/utils/env" +) + +type RedisImpl struct { + pool *redis.Pool +} + +func resolver(log logger.Logger) { + if rec := recover(); rec != nil { + log.Error("Some panic occured when processing request:") + log.Error(fmt.Sprint(rec)) + log.Error("") + + log.Error("Stack Trace:") + stacks := strings.Split(string(debug.Stack()), "\n") + + for _, val := range stacks { + log.Error(val) + } + + os.Exit(-1) + } +} + +func NewRedisEnv( + env *env.Environment, + log logger.Logger, +) (*RedisImpl, error) { + return &RedisImpl{ + &redis.Pool{ + MaxIdle: 3, + IdleTimeout: 240 * time.Second, + Dial: func() (redis.Conn, error) { + defer resolver(log) + conn, err := redis.Dial("tcp", env.RedisConnection+":"+env.RedisPort) + + if err != nil { + log.Warning("failed connect to redis server: tcp " + env.RedisConnection + ":" + env.RedisPort) + log.Warning(err.Error()) + + return nil, err + } + + if env.RedisUseAuth { + if _, err := conn.Do("AUTH", env.RedisUsername, env.RedisPassword); err != nil { + conn.Close() + + log.Warning("failed connect to redis server: authentication failed") + + return nil, err + } + } + + return conn, err + }, + TestOnBorrow: func(c redis.Conn, t time.Time) error { + if time.Since(t) < time.Minute { + return nil + } + _, err := c.Do("PING") + + if err != nil { + log.Warning("redis server is down") + } + + return err + }, + }}, nil +} + +func (r RedisImpl) Pool() *redis.Pool { + return r.pool +} diff --git a/provider/redis/type.go b/provider/redis/type.go new file mode 100644 index 0000000000000000000000000000000000000000..c2b4e2c661a7ce9851e8963e107c7fd063f04c26 --- /dev/null +++ b/provider/redis/type.go @@ -0,0 +1,7 @@ +package redis + +import "github.com/gomodule/redigo/redis" + +type Redis interface { + Pool() *redis.Pool +} \ No newline at end of file diff --git a/repository/cache/cache.go b/repository/cache/cache.go new file mode 100644 index 0000000000000000000000000000000000000000..7f554ed0dfe7148112096c8e58426fddc44fa900 --- /dev/null +++ b/repository/cache/cache.go @@ -0,0 +1,107 @@ +package cache + +import ( + "github.com/gomodule/redigo/redis" + "gitlab.informatika.org/ocw/ocw-backend/model/domain/cache" + rd "gitlab.informatika.org/ocw/ocw-backend/provider/redis" +) + +type CacheRepositoryImpl struct { + pool *redis.Pool +} + +func New( + cache rd.Redis, +) *CacheRepositoryImpl { + return &CacheRepositoryImpl{cache.Pool()} +} + +func (c CacheRepositoryImpl) Get(key cache.Key) (string, error) { + conn := c.pool.Get() + defer conn.Close() + + value, err := redis.String(conn.Do("GET", key)) + + if err != nil { + return "", err + } + + return value, nil +} + +func (c CacheRepositoryImpl) Delete(key string) error { + conn := c.pool.Get() + defer conn.Close() + + _, err := conn.Do("DEL", key) + return err +} + +func (c CacheRepositoryImpl) Set(str cache.String) error { + conn := c.pool.Get() + defer conn.Close() + + _, err := conn.Do("SET", str.Key, str.Value) + + if err != nil { + return err + } + + if str.ExpiryInMinutes > 0 { + _, err = conn.Do("EXPIRE", str.Key, str.ExpiryInMinutes*60) + + if err != nil { + return err + } + } + + return nil +} + +func (c CacheRepositoryImpl) HGet(cache cache.Hash, field string) (string, error) { + conn := c.pool.Get() + defer conn.Close() + + value, err := redis.String(conn.Do("HGET", cache.Key.String(), field)) + + if err != nil { + return "", err + } + + return value, nil +} + +func (c CacheRepositoryImpl) HGetAll(cache cache.Hash) (map[string]string, error) { + conn := c.pool.Get() + defer conn.Close() + + value, err := redis.StringMap(conn.Do("HGETALL", cache.Key.String())) + + if err != nil { + return nil, err + } + + return value, nil +} + +func (c CacheRepositoryImpl) HSet(cache cache.Hash) error { + conn := c.pool.Get() + defer conn.Close() + + slice := cache.Slice() + _, err := conn.Do("HSET", slice...) + + if err != nil { + return err + } + + if cache.ExpiryInMinutes > 0 { + _, err = conn.Do("EXPIRE", cache.Key, cache.ExpiryInMinutes*60) + + if err != nil { + return err + } + } + + return nil +} diff --git a/repository/cache/type.go b/repository/cache/type.go new file mode 100644 index 0000000000000000000000000000000000000000..dca193bc9959ce6ecde1cb35e75e021e7a20d382 --- /dev/null +++ b/repository/cache/type.go @@ -0,0 +1,14 @@ +package cache + +import ( + "gitlab.informatika.org/ocw/ocw-backend/model/domain/cache" +) + +type CacheRepository interface { + Get(key cache.Key) (string, error) + Set(str cache.String) error + Delete(key string) error + HGet(cache cache.Hash, field string) (string, error) + HGetAll(cache cache.Hash) (map[string]string, error) + HSet(cache cache.Hash) error +} diff --git a/repository/di.go b/repository/di.go index ccec9594f14c22bb4d6444b2edbb26f54b8a90f3..3a1d5b9646290496eabde8604276da5c39f15332 100644 --- a/repository/di.go +++ b/repository/di.go @@ -3,12 +3,17 @@ package repository import ( "github.com/google/wire" "gitlab.informatika.org/ocw/ocw-backend/repository/user" + "gitlab.informatika.org/ocw/ocw-backend/repository/cache" ) var RepositoryBasicSet = wire.NewSet( // User Repository user.New, wire.Bind(new(user.UserRepository), new(*user.UserRepositoryImpl)), + + // Cache Repository + cache.New, + wire.Bind(new(cache.CacheRepository), new(*cache.CacheRepositoryImpl)), ) var RepositorySet = wire.NewSet( diff --git a/routes/auth/route.go b/routes/auth/route.go index d7ea811b69fd604111f9a013b7821a10ef005619..02205f37e1215d7ff63512613a7a7117f05edb7c 100644 --- a/routes/auth/route.go +++ b/routes/auth/route.go @@ -14,5 +14,6 @@ func (ar AuthRoutes) Register(r chi.Router) { r.Post("/login", ar.AuthHandler.Login) r.Post("/refresh", ar.AuthHandler.Refresh) r.Post("/register", ar.AuthHandler.Register) + r.Post("/verify", ar.AuthHandler.EmailVerify) }) } diff --git a/routes/reset/route.go b/routes/reset/route.go index 46b32e0f9f389bfe8f3ff730d2c683225dfcee91..1d4522b1e57b4c91d47007d84013d03abd0419cb 100644 --- a/routes/reset/route.go +++ b/routes/reset/route.go @@ -13,6 +13,6 @@ func (rr ResetRoutes) Register(r chi.Router) { r.Route("/reset", func(r chi.Router) { r.Post("/request", rr.ResetHandler.Request) r.Post("/confirm", rr.ResetHandler.Confirm) - r.Post("/validate", rr.ResetHandler.Validate) + r.Get("/validate", rr.ResetHandler.Validate) }) } diff --git a/service/auth/register.go b/service/auth/register.go index 6eb423b39900bb2502234c2558b0414a4e50f10c..b7941c50ba99c941bd7810ad82f3c3862f9ef26c 100644 --- a/service/auth/register.go +++ b/service/auth/register.go @@ -6,9 +6,15 @@ import ( ) func (auth AuthServiceImpl) Register(payload register.RegisterRequestPayload) error { - err := auth.UserRepository.Add(user.User{ + hashedPassword, err := auth.Hash(payload.Password) + + if err != nil { + return err + } + + err = auth.UserRepository.Add(user.User{ Email: payload.Email, - Password: payload.Password, + Password: hashedPassword, Name: payload.Name, Role: user.Student, IsActivated: false, diff --git a/service/auth/type.go b/service/auth/type.go index 2503726fe0562e9b2b26544dabae60017d13c386..937521d8a9aacc0e95cfd1061e6cbc627b545686 100644 --- a/service/auth/type.go +++ b/service/auth/type.go @@ -4,10 +4,12 @@ import ( "gitlab.informatika.org/ocw/ocw-backend/model/web/auth/login" "gitlab.informatika.org/ocw/ocw-backend/model/web/auth/refresh" "gitlab.informatika.org/ocw/ocw-backend/model/web/auth/register" + "gitlab.informatika.org/ocw/ocw-backend/model/web/auth/verification" ) type AuthService interface { Login(payload login.LoginRequestPayload) (*login.LoginResponsePayload, error) Refresh(payload refresh.RefreshRequestPayload) (*refresh.RefreshResponsePayload, error) Register(payload register.RegisterRequestPayload) error + VerifyEmail(payload verification.VerificationRequestPayload) error } diff --git a/service/auth/verify.go b/service/auth/verify.go new file mode 100644 index 0000000000000000000000000000000000000000..a8462cec12a42a999b2910ef831768c8f6f4826d --- /dev/null +++ b/service/auth/verify.go @@ -0,0 +1,7 @@ +package auth + +import "gitlab.informatika.org/ocw/ocw-backend/model/web/auth/verification" + +func (auth AuthServiceImpl) VerifyEmail(payload verification.VerificationRequestPayload) error { + return nil +} diff --git a/service/reset/confirm.go b/service/reset/confirm.go index 426af52f5514eec16fd0a6c4553bd817974177fd..66a42c1eef7843a9888fb9e4a5c0d34c17d9d86b 100644 --- a/service/reset/confirm.go +++ b/service/reset/confirm.go @@ -1,11 +1,47 @@ package reset import ( - // "gitlab.informatika.org/ocw/ocw-backend/model/domain/user" + "gitlab.informatika.org/ocw/ocw-backend/model/domain/cache" + "gitlab.informatika.org/ocw/ocw-backend/model/web" + "gitlab.informatika.org/ocw/ocw-backend/model/web/auth/token" "gitlab.informatika.org/ocw/ocw-backend/model/web/reset/confirm" ) func (rs ResetServiceImpl) Confirm(payload confirm.ConfirmRequestPayload) error { - // TODO replace dummy + // Double Layered Security + // Validate Token + _, err := rs.TokenUtil.Validate(payload.ConfirmToken, token.Access) + + if err != nil { + return web.NewResponseErrorFromError(err, web.TokenError) + } + + // Check if Token is Cached + email, err := rs.CacheRepository.Get(*cache.NewKey(rs.RedisPrefixKey+"resetPassword", payload.ConfirmToken)) + + if err != nil { + return web.NewResponseErrorFromError(err, web.LinkNotAvailable) + } + + // Reset the password + user, err := rs.UserRepository.Get(email) + + if err != nil { + return err + } + + hashedPassword, err := rs.Hash(payload.Password) + + if err != nil { + return err + } + + user.Password = hashedPassword + err = rs.UserRepository.Update(*user) + + if err != nil { + return err + } + return nil -} \ No newline at end of file +} diff --git a/service/reset/impl.go b/service/reset/impl.go index b9986e2509f4af7f8c0deb9053bf8d30640e4168..37c8ec7a35f7fef60c68047fc38946c5b16bfe13 100644 --- a/service/reset/impl.go +++ b/service/reset/impl.go @@ -1,17 +1,25 @@ package reset import ( + "gitlab.informatika.org/ocw/ocw-backend/provider/mail" + "gitlab.informatika.org/ocw/ocw-backend/repository/cache" "gitlab.informatika.org/ocw/ocw-backend/repository/user" + "gitlab.informatika.org/ocw/ocw-backend/service/logger" "gitlab.informatika.org/ocw/ocw-backend/service/verification" "gitlab.informatika.org/ocw/ocw-backend/utils/env" "gitlab.informatika.org/ocw/ocw-backend/utils/password" + "gitlab.informatika.org/ocw/ocw-backend/utils/template" "gitlab.informatika.org/ocw/ocw-backend/utils/token" ) type ResetServiceImpl struct { user.UserRepository + cache.CacheRepository password.PasswordUtil *env.Environment token.TokenUtil verification.VerificationService + logger.Logger + mail.MailQueue + template.TemplateWritterBuilder } diff --git a/service/reset/mail.go b/service/reset/mail.go new file mode 100644 index 0000000000000000000000000000000000000000..7de41926aa002e9e4fd233804ed82c056777aa44 --- /dev/null +++ b/service/reset/mail.go @@ -0,0 +1,7 @@ +package reset + +type mailPayload struct { + BaseUrl string + Email string + Token string +} diff --git a/service/reset/request.go b/service/reset/request.go index 05584e6a9bb3dcf0a7a78c4ec5e12e1c4502732d..0e386525cee3b36e95c0734ae3a9016218802068 100644 --- a/service/reset/request.go +++ b/service/reset/request.go @@ -1,11 +1,83 @@ package reset import ( - // "gitlab.informatika.org/ocw/ocw-backend/model/domain/user" + "errors" + "time" + + "github.com/golang-jwt/jwt/v4" + "gitlab.informatika.org/ocw/ocw-backend/model/web" + tokenModel "gitlab.informatika.org/ocw/ocw-backend/model/web/auth/token" + "gitlab.informatika.org/ocw/ocw-backend/provider/mail" + "gorm.io/gorm" + + "gitlab.informatika.org/ocw/ocw-backend/model/domain/cache" "gitlab.informatika.org/ocw/ocw-backend/model/web/reset/request" ) func (rs ResetServiceImpl) Request(payload request.RequestRequestPayload) error { - // TODO replace dummy + // Fetch user data from email + user, err := rs.UserRepository.Get(payload.Email) + + if err != nil { + var errorObj error + + switch { + case errors.Is(err, gorm.ErrRecordNotFound): + errorObj = web.NewResponseError("Email was not found", web.EmailNotExist) + default: + errorObj = err + } + + return errorObj + } + + if !user.IsActivated { + return web.NewResponseError("user is not activated yet", web.InactiveUser) + } + + // Mint JWT Token for 30 minutes + resetClaim := tokenModel.UserClaim{ + Name: user.Name, + Email: user.Email, + Role: user.Role, + Type: tokenModel.Access, + RegisteredClaims: jwt.RegisteredClaims{ + ExpiresAt: jwt.NewNumericDate(time.Now().Add(time.Duration(rs.TokenAccessExpired*6) * time.Millisecond)), + Issuer: rs.TokenIssuer, + IssuedAt: jwt.NewNumericDate(time.Now()), + }, + } + + resetToken, err := rs.TokenUtil.Generate(resetClaim, rs.TokenUtil.DefaultMethod()) + if err != nil { + return err + } + + // Cache Website on Redis, TTL 30 mins + rs.CacheRepository.Set(*cache.NewString(*cache.NewKey(rs.RedisPrefixKey+"resetPassword", resetToken), payload.Email, 30)) + + // TODO: Send Reset Email + mailBuilder, err := rs.TemplateWritterBuilder.Get("reset-password.format.html") + + if err != nil { + return err + } + + mailData, err := mailBuilder.Write(&mailPayload{ + BaseUrl: rs.FrontendBaseURL + rs.ResetPasswordPath, + Email: user.Email, + Token: resetToken, + }) + + if err != nil { + return err + } + + rs.MailQueue.Send(mail.Mail{ + To: []string{user.Email}, + Subject: "Reset Password", + Message: mailData, + }) + return nil -} \ No newline at end of file +} diff --git a/service/reset/validate.go b/service/reset/validate.go index 7cd09fe9bc83b23a2e4d0bbc0abf95fe1758b5cc..f1ae7028d7e0da998768720f97ff43adbf9f2c98 100644 --- a/service/reset/validate.go +++ b/service/reset/validate.go @@ -1,11 +1,27 @@ package reset import ( - // "gitlab.informatika.org/ocw/ocw-backend/model/domain/user" + "gitlab.informatika.org/ocw/ocw-backend/model/domain/cache" + "gitlab.informatika.org/ocw/ocw-backend/model/web" + "gitlab.informatika.org/ocw/ocw-backend/model/web/auth/token" "gitlab.informatika.org/ocw/ocw-backend/model/web/reset/validate" ) func (rs ResetServiceImpl) Validate(payload validate.ValidateRequestPayload) error { - // TODO replace dummy + // Double Layered Security + // Validate Token + _, err := rs.TokenUtil.Validate(payload.ValidateToken, token.Access) + + if err != nil { + return web.NewResponseErrorFromError(err, web.TokenError) + } + + // Check if Token is Cached + _, err = rs.CacheRepository.Get(*cache.NewKey(rs.RedisPrefixKey+"resetPassword", payload.ValidateToken)) + + if err != nil { + return web.NewResponseErrorFromError(err, web.LinkNotAvailable) + } + return nil -} \ No newline at end of file +} diff --git a/test/di.go b/test/di.go index 9ce05c76ea2a7f5f97645eb7bde26d66fdb121f0..6e9f9ff3ac420b22ee453b42ef68835662842c43 100644 --- a/test/di.go +++ b/test/di.go @@ -18,6 +18,7 @@ import ( "gitlab.informatika.org/ocw/ocw-backend/utils" "gitlab.informatika.org/ocw/ocw-backend/utils/env" + "gitlab.informatika.org/ocw/ocw-backend/utils/template" ) func CreateServer(logger logger.Logger, envTest *env.Environment) (*ApiTestPack, error) { @@ -37,3 +38,11 @@ func CreateServer(logger logger.Logger, envTest *env.Environment) (*ApiTestPack, return nil, nil } + +func CreateTemplateBuilder() template.TemplateWritterBuilder { + wire.Build( + utils.UtilSetTest, + ) + + return nil +} diff --git a/test/utils/template/template_test.go b/test/utils/template/template_test.go new file mode 100644 index 0000000000000000000000000000000000000000..a6e783ba29460022a978183bf452afba00c86c54 --- /dev/null +++ b/test/utils/template/template_test.go @@ -0,0 +1,66 @@ +package template + +import ( + "testing" + + "github.com/stretchr/testify/assert" + "gitlab.informatika.org/ocw/ocw-backend/test" +) + +type testTemplate struct { + Name string + Url string +} + +func TestTemplate(t *testing.T) { + builder := test.CreateTemplateBuilder() + template, err := builder.Get("test.html") + + assert.Nil(t, err) + + if err != nil { + return + } + + t.Run("BuilderSuccess", func(t *testing.T) { + data := &testTemplate{ + Name: "Bayu", + Url: "Hehe", + } + + result, err := template.Write(data) + + assert.Nil(t, err) + assert.Equal(t, result, "<h1>Hello, Bayu. This is your Hehe</h1>") + }) + + t.Run("ShouldBeReuseObject", func(t *testing.T) { + templateInner, _ := builder.Get("test.html") + + assert.Equal(t, template, templateInner) + }) + + t.Run("HtmlShouldBeParsed", func(t *testing.T) { + data := &testTemplate{ + Name: "<script>alert('hayoloh')</script>", + Url: "Hehe", + } + + result, err := template.Write(data) + + assert.Nil(t, err) + assert.NotEqual(t, result, "<h1>Hello, <script>alert('hayoloh')</script>. This is your Hehe</h1>") + }) + + t.Run("URLShouldBeParsed", func(t *testing.T) { + data := &testTemplate{ + Name: "Bayu", + Url: "http://localhost:8080/?q=' AND 10 AND \"", + } + + result, err := template.Write(data) + + assert.Nil(t, err) + assert.Equal(t, result, "<h1>Hello, Bayu. This is your http://localhost:8080/?q=' AND 10 AND "</h1>") + }) +} diff --git a/utils/di.go b/utils/di.go index d6b65b8b8f725bf8c3da03f3ab776ef8aca15e90..3987842c6ebf7c6b1103f237a4c80160b5fd4da2 100644 --- a/utils/di.go +++ b/utils/di.go @@ -8,6 +8,7 @@ import ( "gitlab.informatika.org/ocw/ocw-backend/utils/log" "gitlab.informatika.org/ocw/ocw-backend/utils/password" "gitlab.informatika.org/ocw/ocw-backend/utils/res" + "gitlab.informatika.org/ocw/ocw-backend/utils/template" "gitlab.informatika.org/ocw/ocw-backend/utils/token" "gitlab.informatika.org/ocw/ocw-backend/utils/wrapper" ) @@ -40,6 +41,10 @@ var UtilSetTest = wire.NewSet( // Token utility wire.Struct(new(token.TokenUtilImpl), "*"), wire.Bind(new(token.TokenUtil), new(*token.TokenUtilImpl)), + + // Template Writter + template.NewBuilder, + wire.Bind(new(template.TemplateWritterBuilder), new(*template.TemplateWritterBuilderImpl)), ) var UtilSet = wire.NewSet( diff --git a/utils/env/env.go b/utils/env/env.go index a55416e52bf462568666b3b4de1afc886d97fa44..26bd1d30315d75f149114037acbdac94e11e9e3b 100644 --- a/utils/env/env.go +++ b/utils/env/env.go @@ -36,6 +36,17 @@ type Environment struct { SmtpPassword string `env:"SMTP_PASSWORD"` SmtpServer string `env:"SMTP_SERVER"` SmtpPort int `env:"SMTP_PORT" envDefault:"25"` + + FrontendBaseURL string `env:"FE_BASE_URL"` + ResetPasswordPath string `env:"RESET_PASSWORD_PATH" envDefault:"/reset"` + EmailVerificationPath string `env:"EMAIL_VERIFICATION_PATH" envDefault:"/verification"` + + RedisConnection string `env:"REDIS_STRING"` + RedisPort string `env:"REDIS_PORT" envDefault:"6379"` + RedisUsername string `env:"REDIS_USERNAME"` + RedisPassword string `env:"REDIS_PASSWORD"` + RedisUseAuth bool `env:"REDIS_USE_AUTH" envDefault:"false"` + RedisPrefixKey string `env:"REDIS_PREFIX_KEY" envDefault:"app:"` } func New() (*Environment, error) { diff --git a/utils/res/data/email-verification.format.html b/utils/res/data/email-verification.format.html new file mode 100644 index 0000000000000000000000000000000000000000..a4a763e42aa997d4024843a5b3302be342e61a66 --- /dev/null +++ b/utils/res/data/email-verification.format.html @@ -0,0 +1,298 @@ +<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Strict//EN" "http://www.w3.org/TR/xhtml1/DTD/xhtml1-strict.dtd"> +<html> + +<head> + <!-- Compiled with Bootstrap Email version: 1.3.1 --> + <meta http-equiv="x-ua-compatible" content="ie=edge"> + <meta name="x-apple-disable-message-reformatting"> + <meta name="viewport" content="width=device-width, initial-scale=1"> + <meta name="format-detection" content="telephone=no, date=no, address=no, email=no"> + <meta http-equiv="Content-Type" content="text/html; charset=utf-8"> + <style type="text/css"> + body, + table, + td { + font-family: Helvetica, Arial, sans-serif !important + } + + .ExternalClass { + width: 100% + } + + .ExternalClass, + .ExternalClass p, + .ExternalClass span, + .ExternalClass font, + .ExternalClass td, + .ExternalClass div { + line-height: 150% + } + + a { + text-decoration: none + } + + * { + color: inherit + } + + a[x-apple-data-detectors], + u+#body a, + #MessageViewBody a { + color: inherit; + text-decoration: none; + font-size: inherit; + font-family: inherit; + font-weight: inherit; + line-height: inherit + } + + img { + -ms-interpolation-mode: bicubic + } + + table:not([class^=s-]) { + font-family: Helvetica, Arial, sans-serif; + mso-table-lspace: 0pt; + mso-table-rspace: 0pt; + border-spacing: 0px; + border-collapse: collapse + } + + table:not([class^=s-]) td { + border-spacing: 0px; + border-collapse: collapse + } + + @media screen and (max-width: 600px) { + + .w-full, + .w-full>tbody>tr>td { + width: 100% !important + } + + *[class*=s-lg-]>tbody>tr>td { + font-size: 0 !important; + line-height: 0 !important; + height: 0 !important + } + + .s-2>tbody>tr>td { + font-size: 8px !important; + line-height: 8px !important; + height: 8px !important + } + + .s-5>tbody>tr>td { + font-size: 20px !important; + line-height: 20px !important; + height: 20px !important + } + + .s-10>tbody>tr>td { + font-size: 40px !important; + line-height: 40px !important; + height: 40px !important + } + } + </style> +</head> + +<body class="bg-light" + style="outline: 0; width: 100%; min-width: 100%; height: 100%; -webkit-text-size-adjust: 100%; -ms-text-size-adjust: 100%; font-family: Helvetica, Arial, sans-serif; line-height: 24px; font-weight: normal; font-size: 16px; -moz-box-sizing: border-box; -webkit-box-sizing: border-box; box-sizing: border-box; color: #000000; margin: 0; padding: 0; border-width: 0;" + bgcolor="#f7fafc"> + <table class="bg-light body" valign="top" role="presentation" border="0" cellpadding="0" cellspacing="0" + style="outline: 0; width: 100%; min-width: 100%; height: 100%; -webkit-text-size-adjust: 100%; -ms-text-size-adjust: 100%; font-family: Helvetica, Arial, sans-serif; line-height: 24px; font-weight: normal; font-size: 16px; -moz-box-sizing: border-box; -webkit-box-sizing: border-box; box-sizing: border-box; color: #000000; margin: 0; padding: 0; border-width: 0;" + bgcolor="#f7fafc"> + <tbody> + <tr> + <td valign="top" style="line-height: 24px; font-size: 16px; margin: 0;" align="left" bgcolor="#f7fafc"> + <table class="container" role="presentation" border="0" cellpadding="0" cellspacing="0" style="width: 100%;"> + <tbody> + <tr> + <td align="center" style="line-height: 24px; font-size: 16px; margin: 0; padding: 0 16px;"> + <!--[if (gte mso 9)|(IE)]> + <table align="center" role="presentation"> + <tbody> + <tr> + <td width="600"> + <![endif]--> + <table align="center" role="presentation" border="0" cellpadding="0" cellspacing="0" + style="width: 100%; max-width: 600px; margin: 0 auto;"> + <tbody> + <tr> + <td style="line-height: 24px; font-size: 16px; margin: 0;" align="left"> + <table class="s-10 w-full" role="presentation" border="0" cellpadding="0" cellspacing="0" + style="width: 100%;" width="100%"> + <tbody> + <tr> + <td style="line-height: 40px; font-size: 40px; width: 100%; height: 40px; margin: 0;" + align="left" width="100%" height="40"> +   + </td> + </tr> + </tbody> + </table> + <table class="card" role="presentation" border="0" cellpadding="0" cellspacing="0" + style="border-radius: 6px; border-collapse: separate !important; width: 100%; overflow: hidden; border: 1px solid #e2e8f0;" + bgcolor="#ffffff"> + <tbody> + <tr> + <td style="line-height: 24px; font-size: 16px; width: 100%; margin: 0;" align="left" + bgcolor="#ffffff"> + <table class="card-body" role="presentation" border="0" cellpadding="0" + cellspacing="0" style="width: 100%;"> + <tbody> + <tr> + <td + style="line-height: 24px; font-size: 16px; width: 100%; margin: 0; padding: 20px;" + align="left"> + <h1 class="h3" + style="padding-top: 0; padding-bottom: 0; font-weight: 500; vertical-align: baseline; font-size: 28px; line-height: 33.6px; margin: 0;" + align="left">Email Verifikasi</h1> + <table class="s-2 w-full" role="presentation" border="0" cellpadding="0" + cellspacing="0" style="width: 100%;" width="100%"> + <tbody> + <tr> + <td + style="line-height: 8px; font-size: 8px; width: 100%; height: 8px; margin: 0;" + align="left" width="100%" height="8"> +   + </td> + </tr> + </tbody> + </table> + <table class="s-5 w-full" role="presentation" border="0" cellpadding="0" + cellspacing="0" style="width: 100%;" width="100%"> + <tbody> + <tr> + <td + style="line-height: 20px; font-size: 20px; width: 100%; height: 20px; margin: 0;" + align="left" width="100%" height="20"> +   + </td> + </tr> + </tbody> + </table> + <table class="hr" role="presentation" border="0" cellpadding="0" + cellspacing="0" style="width: 100%;"> + <tbody> + <tr> + <td + style="line-height: 24px; font-size: 16px; border-top-width: 1px; border-top-color: #e2e8f0; border-top-style: solid; height: 1px; width: 100%; margin: 0;" + align="left"> + </td> + </tr> + </tbody> + </table> + <table class="s-5 w-full" role="presentation" border="0" cellpadding="0" + cellspacing="0" style="width: 100%;" width="100%"> + <tbody> + <tr> + <td + style="line-height: 20px; font-size: 20px; width: 100%; height: 20px; margin: 0;" + align="left" width="100%" height="20"> +   + </td> + </tr> + </tbody> + </table> + <div class="space-y-3"> + <p class="text-gray-700" + style="line-height: 24px; font-size: 16px; color: #4a5568; width: 100%; margin: 0;" + align="left"> + Anda telah melakukan pendaftaran pada OCW ITB menggunakan email {{ .Email + }}. Untuk menyelesaikan proses + pendaftaran, anda dapat menekan tombol dibawah ini. + </p> + </div> + <table class="s-5 w-full" role="presentation" border="0" cellpadding="0" + cellspacing="0" style="width: 100%;" width="100%"> + <tbody> + <tr> + <td + style="line-height: 20px; font-size: 20px; width: 100%; height: 20px; margin: 0;" + align="left" width="100%" height="20"> +   + </td> + </tr> + </tbody> + </table> + <table class="hr" role="presentation" border="0" cellpadding="0" + cellspacing="0" style="width: 100%;"> + <tbody> + <tr> + <td + style="line-height: 24px; font-size: 16px; border-top-width: 1px; border-top-color: #e2e8f0; border-top-style: solid; height: 1px; width: 100%; margin: 0;" + align="left"> + </td> + </tr> + </tbody> + </table> + <table class="s-5 w-full" role="presentation" border="0" cellpadding="0" + cellspacing="0" style="width: 100%;" width="100%"> + <tbody> + <tr> + <td + style="line-height: 20px; font-size: 20px; width: 100%; height: 20px; margin: 0;" + align="left" width="100%" height="20"> +   + </td> + </tr> + </tbody> + </table> + <table class="btn btn-primary" role="presentation" border="0" cellpadding="0" + cellspacing="0" + style="border-radius: 6px; border-collapse: separate !important;"> + <tbody> + <tr> + <td + style="line-height: 24px; font-size: 16px; border-radius: 6px; margin: 0;" + align="center" bgcolor="#0d6efd"> + <a href="{{ .BaseUrl }}/?token={{ .Token }}" target="_blank" + style="color: #ffffff; font-size: 16px; font-family: Helvetica, Arial, sans-serif; text-decoration: none; border-radius: 6px; line-height: 20px; display: block; font-weight: normal; white-space: nowrap; background-color: #0d6efd; padding: 8px 12px; border: 1px solid #0d6efd;">Reset + Password</a> + </td> + </tr> + </tbody> + </table> + </td> + </tr> + </tbody> + </table> + </td> + </tr> + </tbody> + </table> + <table class="s-10 w-full" role="presentation" border="0" cellpadding="0" cellspacing="0" + style="width: 100%;" width="100%"> + <tbody> + <tr> + <td style="line-height: 40px; font-size: 40px; width: 100%; height: 40px; margin: 0;" + align="left" width="100%" height="40"> +   + </td> + </tr> + </tbody> + </table> + </td> + </tr> + </tbody> + </table> + <!--[if (gte mso 9)|(IE)]> + </td> + </tr> + </tbody> + </table> + <![endif]--> + </td> + </tr> + </tbody> + </table> + </td> + </tr> + </tbody> + </table> +</body> + +</html> \ No newline at end of file diff --git a/utils/res/data/reset-password.format.html b/utils/res/data/reset-password.format.html new file mode 100644 index 0000000000000000000000000000000000000000..80ec4d4f8b7de170e4d8388e23008efc4449cb19 --- /dev/null +++ b/utils/res/data/reset-password.format.html @@ -0,0 +1,297 @@ +<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Strict//EN" "http://www.w3.org/TR/xhtml1/DTD/xhtml1-strict.dtd"> +<html> + +<head> + <!-- Compiled with Bootstrap Email version: 1.3.1 --> + <meta http-equiv="x-ua-compatible" content="ie=edge"> + <meta name="x-apple-disable-message-reformatting"> + <meta name="viewport" content="width=device-width, initial-scale=1"> + <meta name="format-detection" content="telephone=no, date=no, address=no, email=no"> + <meta http-equiv="Content-Type" content="text/html; charset=utf-8"> + <style type="text/css"> + body, + table, + td { + font-family: Helvetica, Arial, sans-serif !important + } + + .ExternalClass { + width: 100% + } + + .ExternalClass, + .ExternalClass p, + .ExternalClass span, + .ExternalClass font, + .ExternalClass td, + .ExternalClass div { + line-height: 150% + } + + a { + text-decoration: none + } + + * { + color: inherit + } + + a[x-apple-data-detectors], + u+#body a, + #MessageViewBody a { + color: inherit; + text-decoration: none; + font-size: inherit; + font-family: inherit; + font-weight: inherit; + line-height: inherit + } + + img { + -ms-interpolation-mode: bicubic + } + + table:not([class^=s-]) { + font-family: Helvetica, Arial, sans-serif; + mso-table-lspace: 0pt; + mso-table-rspace: 0pt; + border-spacing: 0px; + border-collapse: collapse + } + + table:not([class^=s-]) td { + border-spacing: 0px; + border-collapse: collapse + } + + @media screen and (max-width: 600px) { + + .w-full, + .w-full>tbody>tr>td { + width: 100% !important + } + + *[class*=s-lg-]>tbody>tr>td { + font-size: 0 !important; + line-height: 0 !important; + height: 0 !important + } + + .s-2>tbody>tr>td { + font-size: 8px !important; + line-height: 8px !important; + height: 8px !important + } + + .s-5>tbody>tr>td { + font-size: 20px !important; + line-height: 20px !important; + height: 20px !important + } + + .s-10>tbody>tr>td { + font-size: 40px !important; + line-height: 40px !important; + height: 40px !important + } + } + </style> +</head> + +<body class="bg-light" + style="outline: 0; width: 100%; min-width: 100%; height: 100%; -webkit-text-size-adjust: 100%; -ms-text-size-adjust: 100%; font-family: Helvetica, Arial, sans-serif; line-height: 24px; font-weight: normal; font-size: 16px; -moz-box-sizing: border-box; -webkit-box-sizing: border-box; box-sizing: border-box; color: #000000; margin: 0; padding: 0; border-width: 0;" + bgcolor="#f7fafc"> + <table class="bg-light body" valign="top" role="presentation" border="0" cellpadding="0" cellspacing="0" + style="outline: 0; width: 100%; min-width: 100%; height: 100%; -webkit-text-size-adjust: 100%; -ms-text-size-adjust: 100%; font-family: Helvetica, Arial, sans-serif; line-height: 24px; font-weight: normal; font-size: 16px; -moz-box-sizing: border-box; -webkit-box-sizing: border-box; box-sizing: border-box; color: #000000; margin: 0; padding: 0; border-width: 0;" + bgcolor="#f7fafc"> + <tbody> + <tr> + <td valign="top" style="line-height: 24px; font-size: 16px; margin: 0;" align="left" bgcolor="#f7fafc"> + <table class="container" role="presentation" border="0" cellpadding="0" cellspacing="0" style="width: 100%;"> + <tbody> + <tr> + <td align="center" style="line-height: 24px; font-size: 16px; margin: 0; padding: 0 16px;"> + <!--[if (gte mso 9)|(IE)]> + <table align="center" role="presentation"> + <tbody> + <tr> + <td width="600"> + <![endif]--> + <table align="center" role="presentation" border="0" cellpadding="0" cellspacing="0" + style="width: 100%; max-width: 600px; margin: 0 auto;"> + <tbody> + <tr> + <td style="line-height: 24px; font-size: 16px; margin: 0;" align="left"> + <table class="s-10 w-full" role="presentation" border="0" cellpadding="0" cellspacing="0" + style="width: 100%;" width="100%"> + <tbody> + <tr> + <td style="line-height: 40px; font-size: 40px; width: 100%; height: 40px; margin: 0;" + align="left" width="100%" height="40"> +   + </td> + </tr> + </tbody> + </table> + <table class="card" role="presentation" border="0" cellpadding="0" cellspacing="0" + style="border-radius: 6px; border-collapse: separate !important; width: 100%; overflow: hidden; border: 1px solid #e2e8f0;" + bgcolor="#ffffff"> + <tbody> + <tr> + <td style="line-height: 24px; font-size: 16px; width: 100%; margin: 0;" align="left" + bgcolor="#ffffff"> + <table class="card-body" role="presentation" border="0" cellpadding="0" + cellspacing="0" style="width: 100%;"> + <tbody> + <tr> + <td + style="line-height: 24px; font-size: 16px; width: 100%; margin: 0; padding: 20px;" + align="left"> + <h1 class="h3" + style="padding-top: 0; padding-bottom: 0; font-weight: 500; vertical-align: baseline; font-size: 28px; line-height: 33.6px; margin: 0;" + align="left">Reset Password</h1> + <table class="s-2 w-full" role="presentation" border="0" cellpadding="0" + cellspacing="0" style="width: 100%;" width="100%"> + <tbody> + <tr> + <td + style="line-height: 8px; font-size: 8px; width: 100%; height: 8px; margin: 0;" + align="left" width="100%" height="8"> +   + </td> + </tr> + </tbody> + </table> + <table class="s-5 w-full" role="presentation" border="0" cellpadding="0" + cellspacing="0" style="width: 100%;" width="100%"> + <tbody> + <tr> + <td + style="line-height: 20px; font-size: 20px; width: 100%; height: 20px; margin: 0;" + align="left" width="100%" height="20"> +   + </td> + </tr> + </tbody> + </table> + <table class="hr" role="presentation" border="0" cellpadding="0" + cellspacing="0" style="width: 100%;"> + <tbody> + <tr> + <td + style="line-height: 24px; font-size: 16px; border-top-width: 1px; border-top-color: #e2e8f0; border-top-style: solid; height: 1px; width: 100%; margin: 0;" + align="left"> + </td> + </tr> + </tbody> + </table> + <table class="s-5 w-full" role="presentation" border="0" cellpadding="0" + cellspacing="0" style="width: 100%;" width="100%"> + <tbody> + <tr> + <td + style="line-height: 20px; font-size: 20px; width: 100%; height: 20px; margin: 0;" + align="left" width="100%" height="20"> +   + </td> + </tr> + </tbody> + </table> + <div class="space-y-3"> + <p class="text-gray-700" + style="line-height: 24px; font-size: 16px; color: #4a5568; width: 100%; margin: 0;" + align="left"> + Anda telah mengirimkan reset password untuk email {{ .Email }}. Untuk + melakukan reset password tersebut, anda dapat menekan tombol dibawah ini. + </p> + </div> + <table class="s-5 w-full" role="presentation" border="0" cellpadding="0" + cellspacing="0" style="width: 100%;" width="100%"> + <tbody> + <tr> + <td + style="line-height: 20px; font-size: 20px; width: 100%; height: 20px; margin: 0;" + align="left" width="100%" height="20"> +   + </td> + </tr> + </tbody> + </table> + <table class="hr" role="presentation" border="0" cellpadding="0" + cellspacing="0" style="width: 100%;"> + <tbody> + <tr> + <td + style="line-height: 24px; font-size: 16px; border-top-width: 1px; border-top-color: #e2e8f0; border-top-style: solid; height: 1px; width: 100%; margin: 0;" + align="left"> + </td> + </tr> + </tbody> + </table> + <table class="s-5 w-full" role="presentation" border="0" cellpadding="0" + cellspacing="0" style="width: 100%;" width="100%"> + <tbody> + <tr> + <td + style="line-height: 20px; font-size: 20px; width: 100%; height: 20px; margin: 0;" + align="left" width="100%" height="20"> +   + </td> + </tr> + </tbody> + </table> + <table class="btn btn-primary" role="presentation" border="0" cellpadding="0" + cellspacing="0" + style="border-radius: 6px; border-collapse: separate !important;"> + <tbody> + <tr> + <td + style="line-height: 24px; font-size: 16px; border-radius: 6px; margin: 0;" + align="center" bgcolor="#0d6efd"> + <a href="{{ .BaseUrl }}/?token={{ .Token }}" target="_blank" + style="color: #ffffff; font-size: 16px; font-family: Helvetica, Arial, sans-serif; text-decoration: none; border-radius: 6px; line-height: 20px; display: block; font-weight: normal; white-space: nowrap; background-color: #0d6efd; padding: 8px 12px; border: 1px solid #0d6efd;">Reset + Password</a> + </td> + </tr> + </tbody> + </table> + </td> + </tr> + </tbody> + </table> + </td> + </tr> + </tbody> + </table> + <table class="s-10 w-full" role="presentation" border="0" cellpadding="0" cellspacing="0" + style="width: 100%;" width="100%"> + <tbody> + <tr> + <td style="line-height: 40px; font-size: 40px; width: 100%; height: 40px; margin: 0;" + align="left" width="100%" height="40"> +   + </td> + </tr> + </tbody> + </table> + </td> + </tr> + </tbody> + </table> + <!--[if (gte mso 9)|(IE)]> + </td> + </tr> + </tbody> + </table> + <![endif]--> + </td> + </tr> + </tbody> + </table> + </td> + </tr> + </tbody> + </table> +</body> + +</html> \ No newline at end of file diff --git a/utils/res/data/test.html b/utils/res/data/test.html new file mode 100644 index 0000000000000000000000000000000000000000..8d432ede97e9a3ddad9b5fcbbfefaece69f7fdb4 --- /dev/null +++ b/utils/res/data/test.html @@ -0,0 +1 @@ +<h1>Hello, {{ .Name }}. This is your {{ .Url }}</h1> \ No newline at end of file diff --git a/utils/res/embed.go b/utils/res/embed.go index 598ad06c4a4d25269165e6676bfb2d127df69fd9..19f072892de5c8faf0a23d205dc00caaa5be0425 100644 --- a/utils/res/embed.go +++ b/utils/res/embed.go @@ -2,6 +2,7 @@ package res import ( "embed" + "io/fs" ) //go:embed data/* @@ -13,6 +14,9 @@ func (EmbedResources) GetBytesResource(path string) ([]byte, error) { return data.ReadFile("data/" + path) } +func (EmbedResources) GetFile(path string) (fs.File, error) { + return data.Open("data/" + path) +} func (EmbedResources) GetStringResource(path string) (string, error) { content, err := data.ReadFile("data/" + path) @@ -22,4 +26,4 @@ func (EmbedResources) GetStringResource(path string) (string, error) { } return string(content), nil -} \ No newline at end of file +} diff --git a/utils/res/res.go b/utils/res/res.go index 387823d3a50c02d2a0f8630ae5e0c273ec5d29b1..f37288d570af4d2cf684bfb634769048d5001290 100644 --- a/utils/res/res.go +++ b/utils/res/res.go @@ -1,6 +1,9 @@ package res +import "io/fs" + type Resource interface { GetBytesResource(path string) ([]byte, error) GetStringResource(path string) (string, error) + GetFile(path string) (fs.File, error) } diff --git a/utils/template/builder.go b/utils/template/builder.go new file mode 100644 index 0000000000000000000000000000000000000000..b2317d5d72e83ff925ca019b8bc80cb860d10e44 --- /dev/null +++ b/utils/template/builder.go @@ -0,0 +1,5 @@ +package template + +type TemplateWritterBuilder interface { + Get(templatePath string) (TemplateWritter, error) +} diff --git a/utils/template/builder_impl.go b/utils/template/builder_impl.go new file mode 100644 index 0000000000000000000000000000000000000000..db5386d28fa19e33eeaedf8319ef5dd343e4cbcd --- /dev/null +++ b/utils/template/builder_impl.go @@ -0,0 +1,30 @@ +package template + +import "gitlab.informatika.org/ocw/ocw-backend/utils/res" + +type TemplateWritterBuilderImpl struct { + res res.Resource + templatePool map[string]TemplateWritter +} + +func NewBuilder(res res.Resource) *TemplateWritterBuilderImpl { + return &TemplateWritterBuilderImpl{ + res: res, + templatePool: map[string]TemplateWritter{}, + } +} + +func (t *TemplateWritterBuilderImpl) Get(templatePath string) (TemplateWritter, error) { + if t.templatePool[templatePath] == nil { + template, err := NewTemplateWritterImpl(t.res, templatePath) + + if err != nil { + return nil, err + } + + t.templatePool[templatePath] = template + return template, nil + } + + return t.templatePool[templatePath], nil +} diff --git a/utils/template/impl.go b/utils/template/impl.go new file mode 100644 index 0000000000000000000000000000000000000000..36574a484ca5eb9ec816b5de635051ae2af4929d --- /dev/null +++ b/utils/template/impl.go @@ -0,0 +1,39 @@ +package template + +import ( + "bytes" + "html/template" + + "gitlab.informatika.org/ocw/ocw-backend/utils/res" +) + +type TemplateWritterImpl struct { + template *template.Template +} + +func NewTemplateWritterImpl(res res.Resource, templatePath string) (*TemplateWritterImpl, error) { + file, err := res.GetStringResource(templatePath) + + if err != nil { + return nil, err + } + + templateData, err := template.New(templatePath).Parse(file) + + if err != nil { + return nil, err + } + + return &TemplateWritterImpl{templateData}, nil +} + +func (m TemplateWritterImpl) Write(data interface{}) (string, error) { + buffer := bytes.NewBuffer([]byte{}) + err := m.template.Execute(buffer, data) + + if err != nil { + return "", err + } + + return buffer.String(), nil +} diff --git a/utils/template/type.go b/utils/template/type.go new file mode 100644 index 0000000000000000000000000000000000000000..bd38384b0e32e125f951cd891d9040c8c817c84b --- /dev/null +++ b/utils/template/type.go @@ -0,0 +1,5 @@ +package template + +type TemplateWritter interface { + Write(data interface{}) (string, error) +}