Mirror for https://github.com/STBoyden/go-portfolio
3
fork

Configure Feed

Select the types of activity you want to include in your feed.

build: bundle alpinejs and htmx during build but do not include in repository

stboyden.com ed56ff4f 2926a107

verified
+176 -90
+1
.gitignore
··· 3 3 /node_modules/ 4 4 /tmp 5 5 /static/css/styles.css 6 + /static/js/ 6 7 /internal/pkg/persistence 7 8 .env
+137 -87
internal/pkg/routes/api/v1/blog_api.go
··· 12 12 13 13 "github.com/google/uuid" 14 14 15 + "github.com/STBoyden/go-portfolio/internal/pkg/common/consts" 15 16 "github.com/STBoyden/go-portfolio/internal/pkg/common/types" 16 17 "github.com/STBoyden/go-portfolio/internal/pkg/common/utils" 17 18 "github.com/STBoyden/go-portfolio/internal/pkg/middleware" ··· 19 20 "github.com/STBoyden/go-portfolio/internal/pkg/routes/site/views/components" 20 21 ) 21 22 23 + //nolint:gochecknoglobals // These are only accessible in the v1 package, and are not globally accessible by other packages. 24 + var ( 25 + adminUsername string 26 + adminPassword string 27 + ) 28 + 29 + const blogAuthLogTag string = "blog-auth" 30 + 22 31 func blogAdmin() *http.ServeMux { 23 - r := http.NewServeMux() 32 + mux := http.NewServeMux() 24 33 25 - r.HandleFunc("POST /new-post/{slug}", func(_w http.ResponseWriter, r *http.Request) { 34 + mux.HandleFunc("POST /new-post/{slug}", func(_w http.ResponseWriter, r *http.Request) { 26 35 w := utils.MustCast[middleware.AuthMiddleware](_w) 27 36 28 37 if _, authed := w.Details(); !authed { 38 + w.Log(middleware.Info, "user is not authorised to create a new post") 29 39 w.PrepareHeader(http.StatusUnauthorized) 30 40 return 31 41 } 32 42 33 43 reader, err := r.GetBody() 34 44 if err != nil { 45 + w.Log(middleware.Info, "given request has no body. body len <= 0") 35 46 w.PrepareHeader(http.StatusBadRequest) 36 47 return 37 48 } ··· 39 50 40 51 buffer, err := io.ReadAll(reader) 41 52 if err != nil { 53 + w.Log(middleware.Info, "body was malformed and could not be read properly") 42 54 w.PrepareHeader(http.StatusBadRequest) 43 55 return 44 56 } 45 57 46 58 slug := r.PathValue("slug") 47 59 if slug == "" { 60 + w.Log(middleware.Info, "slug was not present in path") 48 61 w.PrepareHeader(http.StatusBadRequest) 49 62 return 50 63 } ··· 52 65 blogContent := types.BlogContent{} 53 66 err = json.Unmarshal(buffer, &blogContent) 54 67 if err != nil { 68 + w.Log(middleware.Info, "body was not in the correct format and could not be parsed: %v", err) 55 69 w.PrepareHeader(http.StatusBadRequest) 56 70 return 57 71 } ··· 73 87 } 74 88 }) 75 89 76 - return r 77 - } 78 - 79 - func BlogAPI() *http.ServeMux { 80 - adminUser := utils.MustEnv("ADMIN_USER") 81 - adminPass := utils.MustEnv("ADMIN_PW") 90 + mux.HandleFunc("GET /posts", func(_w http.ResponseWriter, r *http.Request) { 91 + w := utils.MustCast[middleware.AuthMiddleware](_w) 82 92 83 - r := http.NewServeMux() 93 + if _, authed := w.Details(); !authed { 94 + w.Log(middleware.Info, "user is not authorised to get unpublished posts") 95 + w.PrepareHeader(http.StatusUnauthorized) 96 + return 97 + } 84 98 85 - r.HandleFunc("GET /posts", func(w http.ResponseWriter, r *http.Request) { 86 99 queries := persistence.New(utils.Database) 87 - posts, err := queries.GetPosts(r.Context()) 100 + posts, err := queries.GetAllPosts(r.Context()) 88 101 if err != nil { 89 102 _ = components.Error().Render(r.Context(), w) 90 103 return 91 104 } 92 105 93 - _ = components.PostList(posts).Render(r.Context(), w) 106 + _ = components.PostList(posts, true).Render(r.Context(), w) 94 107 }) 95 108 96 - // responds with a status code relevant to the authentication status of the user. 97 - // 200: the user is authenticated and cookie is outside of a 30 minute expiration warning. 98 - // 202: the user is authenticated and cookie is within a 30 minute expiration warning. 99 - // 401: the user is not authenticated. 100 - r.HandleFunc("POST /check-authentication", func(_w http.ResponseWriter, r *http.Request) { 101 - w := utils.MustCast[middleware.LoggingMiddleware](_w) 109 + return mux 110 + } 102 111 103 - cookie, err := r.Cookie("token") 104 - if err != nil { 105 - w.PrepareHeader(http.StatusUnauthorized) 106 - return 107 - } 112 + // checkAuthentication checks the authentication of the request and responds 113 + // with whether the request has valid authentication. 114 + func checkAuthentication(_w http.ResponseWriter, r *http.Request) { 115 + w := utils.MustCast[middleware.LoggingMiddleware](_w) 108 116 109 - if cookie.Expires.Before(time.Now()) { 110 - w.PrepareHeader(http.StatusUnauthorized) 111 - return 112 - } else if cookie.Expires.Before(time.Now().Add(30 * time.Minute)) { 113 - // if the cookie is about to expire, warn the user that they will 114 - // need to re-authenticate soon. use a 202 status code to indicate 115 - // this to the front-end. 116 - w.PrepareHeader(http.StatusAccepted) 117 - } 117 + cookie, err := r.Cookie(consts.TokenCookieName) 118 + if err != nil { 119 + w.Log(middleware.Info, blogAuthLogTag, "token cookie was missing from client request") 120 + w.PrepareHeader(http.StatusUnauthorized) 121 + return 122 + } 118 123 119 - token, err := uuid.Parse(cookie.Value) 120 - if err != nil { 121 - w.PrepareHeader(http.StatusUnauthorized) 122 - return 123 - } 124 + token, err := uuid.Parse(cookie.Value) 125 + if err != nil { 126 + w.Log(middleware.Info, blogAuthLogTag, "token form cookie was invalid") 127 + w.PrepareHeader(http.StatusUnauthorized) 128 + return 129 + } 124 130 125 - queries := persistence.New(utils.Database) 126 - exists, err := queries.CheckAuthExists(r.Context(), token) 127 - if !exists || err != nil { 128 - w.PrepareHeader(http.StatusUnauthorized) 129 - return 130 - } 131 + queries := persistence.New(utils.Database) 132 + authorisation, err := queries.GetAuthByToken(r.Context(), token) 133 + if err != nil { 134 + w.Log(middleware.Warn, blogAuthLogTag, "internal error occurred: de-authing user just in case: could not get authorisation token: %v", err) 135 + w.PrepareHeader(http.StatusUnauthorized) 136 + return 137 + } 131 138 132 - expired, err := queries.CheckIfAuthExpired(r.Context(), token) 133 - if expired || err != nil { 134 - w.PrepareHeader(http.StatusUnauthorized) 135 - return 136 - } 137 - }) 139 + if authorisation.Expiry.Before(time.Now()) { 140 + w.Log(middleware.Info, blogAuthLogTag, "token associated with request has expired") 141 + w.PrepareHeader(http.StatusUnauthorized) 142 + return 143 + } else if authorisation.Expiry.Before(time.Now().Add(30 * time.Minute)) { 144 + // if the token is within 30 minutes of expiration, return a 202 145 + // status code so that the front-end may warn the user. 146 + w.PrepareHeader(http.StatusAccepted) 147 + } 148 + } 138 149 139 - r.HandleFunc("POST /authenticate", func(_w http.ResponseWriter, r *http.Request) { 140 - w := utils.MustCast[middleware.LoggingMiddleware](_w) 150 + // authenticate creates a new authentication for a user if they have provided 151 + // the correct login details and returns a cookie with a auth token. 152 + func authenticate(_w http.ResponseWriter, r *http.Request) { 153 + w := utils.MustCast[middleware.LoggingMiddleware](_w) 141 154 142 - onError := func(statusCode int) { 143 - w.PrepareHeader(statusCode) 144 - _ = components.Error().Render(r.Context(), w) 145 - } 155 + onError := func(statusCode int) { 156 + w.PrepareHeader(statusCode) 157 + _ = components.Error().Render(r.Context(), w) 158 + } 146 159 147 - headerContent, ok := r.Header["Authorization"] 148 - if !ok { 149 - onError(http.StatusBadRequest) 150 - return 151 - } 160 + headerContent, ok := r.Header["Authorization"] 161 + if !ok { 162 + w.Log(middleware.Info, blogAuthLogTag, "authorization header missing") 163 + onError(http.StatusBadRequest) 164 + return 165 + } 152 166 153 - authorisation := strings.Join(headerContent, " ") 154 - if authorisation == "" { 155 - onError(http.StatusBadRequest) 156 - return 157 - } 167 + authorisation := strings.Join(headerContent, " ") 168 + if authorisation == "" { 169 + w.Log(middleware.Info, blogAuthLogTag, "authorization header content is empty") 170 + onError(http.StatusBadRequest) 171 + return 172 + } 158 173 159 - username, password, ok := r.BasicAuth() 160 - if username == "" || password == "" || !ok { 161 - onError(http.StatusBadRequest) 162 - return 163 - } 174 + username, password, ok := r.BasicAuth() 175 + if username == "" || password == "" || !ok { 176 + w.Log(middleware.Info, blogAuthLogTag, "given username and/or password are empty") 177 + onError(http.StatusBadRequest) 178 + return 179 + } 164 180 165 - h := sha512.Sum512([]byte(password)) 166 - passwordHashed := hex.EncodeToString(h[:]) 181 + h := sha512.Sum512([]byte(password)) 182 + passwordHashed := hex.EncodeToString(h[:]) 167 183 168 - if username != adminUser || passwordHashed != adminPass { 169 - onError(http.StatusUnauthorized) 170 - return 171 - } 184 + if username != adminUsername || passwordHashed != adminPassword { 185 + w.Log(middleware.Info, blogAuthLogTag, "given username and/or password hash does not match administrator details") 186 + onError(http.StatusUnauthorized) 187 + return 188 + } 172 189 190 + queries := persistence.New(utils.Database) 191 + auth, err := queries.NewAuth(r.Context()) 192 + if err != nil { 193 + panic(fmt.Sprintf("unable to create a new token: %v", err)) 194 + } 195 + 196 + w.Header().Add("HX-Trigger", "login-page-reload") 197 + http.SetCookie(w, &http.Cookie{ 198 + Name: consts.TokenCookieName, 199 + Value: auth.ID.String(), 200 + Expires: auth.Expiry, 201 + Path: "/", 202 + Secure: true, 203 + HttpOnly: true, 204 + SameSite: http.SameSiteLaxMode, 205 + }) 206 + 207 + w.Log(middleware.Info, blogAuthLogTag, "setting cookie %s", consts.TokenCookieName) 208 + } 209 + 210 + func BlogAPI() *http.ServeMux { 211 + adminUsername = utils.MustEnv("ADMIN_USER") 212 + adminPassword = utils.MustEnv("ADMIN_PW") 213 + 214 + mux := http.NewServeMux() 215 + 216 + mux.HandleFunc("GET /posts", func(w http.ResponseWriter, r *http.Request) { 173 217 queries := persistence.New(utils.Database) 174 - auth, err := queries.NewAuth(r.Context()) 218 + posts, err := queries.GetPublishedPosts(r.Context()) 175 219 if err != nil { 176 - panic(fmt.Sprintf("unable to create a new token: %v", err)) 220 + _ = components.Error().Render(r.Context(), w) 221 + return 177 222 } 178 223 179 - http.SetCookie(w, &http.Cookie{ 180 - Name: "token", 181 - Value: auth.ID.String(), 182 - Expires: auth.Expiry, 183 - }) 184 - 185 - w.PrepareHeader(http.StatusOK) 224 + _ = components.PostList(posts, false).Render(r.Context(), w) 186 225 }) 187 226 188 - r.Handle("/admin/", middleware.Handlers.Authorisation(http.StripPrefix("/admin", blogAdmin()))) 227 + mux.Handle("/admin/", middleware.Handlers.Authorisation(http.StripPrefix("/admin", blogAdmin()))) 189 228 190 - return r 229 + // responds with a status code relevant to the authentication status of the user. 230 + // 200: the user is authenticated and cookie is outside of a 30 minute 231 + // expiration warning. 232 + // 202: the user is authenticated and cookie is within a 30 minute 233 + // expiration warning. 234 + // 401: the user is not authenticated. 235 + mux.HandleFunc("POST /check-authentication", checkAuthentication) 236 + 237 + // authenticates a user to be able to use the /admin/ endpoints and redirects to the page 238 + mux.HandleFunc("POST /authenticate", authenticate) 239 + 240 + return mux 191 241 }
+2 -2
internal/pkg/routes/site/views/root.templ
··· 8 8 <head> 9 9 <title>Samuel Boyden</title> 10 10 <link href="/static/css/styles.css" rel="stylesheet"/> 11 - <script src="https://unpkg.com/htmx.org@2.0.4" integrity="sha384-HGfztofotfshcF7+8n44JQL2oJmowVChPTg48S+jvZoztPfvwD79OC/LTtG6dMp+" crossorigin="anonymous"></script> 12 - <script src="//unpkg.com/alpinejs" defer></script> 11 + <script src="/static/js/htmx.min.js"></script> 12 + <script src="/static/js/alpinejs.min.js" defer></script> 13 13 <meta name="viewport" content="width=device-width, initial-scale=1.0"/> 14 14 </head> 15 15 <body>
+4 -1
justfile
··· 1 1 install_deps: 2 2 pnpm install 3 + mkdir -p static/js 4 + cp node_modules/htmx.org/dist/htmx.min.js static/js 5 + cp node_modules/alpinejs/dist/cdn.min.js static/js/alpinejs.min.js 3 6 go mod download 4 7 go mod verify 5 8 6 - generate: 9 + generate: install_deps 7 10 go generate ./internal/pkg/routes/site 8 11 node_modules/.bin/tailwindcss -i ./static/css/_styles.css -o ./static/css/styles.css 9 12
+2
package.json
··· 5 5 ] 6 6 }, 7 7 "devDependencies": { 8 + "alpinejs": "^3.14.9", 8 9 "daisyui": "^5.0.6", 10 + "htmx.org": "^1.9.12", 9 11 "rust-just": "^1.40.0" 10 12 }, 11 13 "dependencies": {
+30
pnpm-lock.yaml
··· 15 15 specifier: ^4.0.14 16 16 version: 4.0.14 17 17 devDependencies: 18 + alpinejs: 19 + specifier: ^3.14.9 20 + version: 3.14.9 18 21 daisyui: 19 22 specifier: ^5.0.6 20 23 version: 5.0.6 24 + htmx.org: 25 + specifier: ^1.9.12 26 + version: 1.9.12 21 27 rust-just: 22 28 specifier: ^1.40.0 23 29 version: 1.40.0 ··· 190 196 resolution: {integrity: sha512-M8VCNyO/NBi5vJ2cRcI9u8w7Si+i76a7o1vveoGtbbjpEYJZYiyc7f2VGps/DqawO56l3tImIbq2OT/533jcrA==} 191 197 engines: {node: '>= 10'} 192 198 199 + '@vue/reactivity@3.1.5': 200 + resolution: {integrity: sha512-1tdfLmNjWG6t/CsPldh+foumYFo3cpyCHgBYQ34ylaMsJ+SNHQ1kApMIa8jN+i593zQuaw3AdWH0nJTARzCFhg==} 201 + 202 + '@vue/shared@3.1.5': 203 + resolution: {integrity: sha512-oJ4F3TnvpXaQwZJNF3ZK+kLPHKarDmJjJ6jyzVNDKH9md1dptjC7lWR//jrGuLdek/U6iltWxqAnYOu8gCiOvA==} 204 + 205 + alpinejs@3.14.9: 206 + resolution: {integrity: sha512-gqSOhTEyryU9FhviNqiHBHzgjkvtukq9tevew29fTj+ofZtfsYriw4zPirHHOAy9bw8QoL3WGhyk7QqCh5AYlw==} 207 + 193 208 ansi-regex@5.0.1: 194 209 resolution: {integrity: sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==} 195 210 engines: {node: '>=8'} ··· 262 277 263 278 graceful-fs@4.2.11: 264 279 resolution: {integrity: sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==} 280 + 281 + htmx.org@1.9.12: 282 + resolution: {integrity: sha512-VZAohXyF7xPGS52IM8d1T1283y+X4D+Owf3qY1NZ9RuBypyu9l8cGsxUMAG5fEAb/DhT7rDoJ9Hpu5/HxFD3cw==} 265 283 266 284 human-signals@8.0.0: 267 285 resolution: {integrity: sha512-/1/GPCpDUCCYwlERiYjxoczfP0zfvZMU/OWgQPMya9AbAE24vseigFdhAMObpc8Q4lc/kjutPfUddDYyAmejnA==} ··· 642 660 '@tailwindcss/oxide-win32-arm64-msvc': 4.0.14 643 661 '@tailwindcss/oxide-win32-x64-msvc': 4.0.14 644 662 663 + '@vue/reactivity@3.1.5': 664 + dependencies: 665 + '@vue/shared': 3.1.5 666 + 667 + '@vue/shared@3.1.5': {} 668 + 669 + alpinejs@3.14.9: 670 + dependencies: 671 + '@vue/reactivity': 3.1.5 672 + 645 673 ansi-regex@5.0.1: {} 646 674 647 675 ansi-styles@4.3.0: ··· 716 744 is-stream: 4.0.1 717 745 718 746 graceful-fs@4.2.11: {} 747 + 748 + htmx.org@1.9.12: {} 719 749 720 750 human-signals@8.0.0: {} 721 751