├── .gitignore ├── .vscode ├── extensions.json └── settings.json ├── LICENSE ├── README.md ├── api ├── post │ ├── config.go │ ├── data.go │ ├── go.mod │ ├── go.sum │ ├── main.go │ ├── middleware.go │ ├── model.go │ └── utils.go └── profile │ ├── .gitignore │ ├── Cargo.lock │ ├── Cargo.toml │ └── src │ ├── auth.rs │ ├── config.rs │ ├── lib.rs │ └── model.rs ├── modules └── spin_static_fs.wasm ├── scripts ├── create-profile.json ├── db │ ├── initdb.d │ │ ├── 1_create_table_profiles.sql │ │ └── 2_create_table_posts.sql │ └── up.sh ├── update-profile.json └── validate.sh ├── spin.toml └── web ├── .eslintrc.cjs ├── .gitignore ├── .prettierrc.json ├── README.md ├── e2e ├── tsconfig.json └── vue.spec.ts ├── env.d.ts ├── index.html ├── package-lock.json ├── package.json ├── playwright.config.ts ├── postcss.config.js ├── public └── favicon.ico ├── src ├── App.vue ├── api │ └── index.ts ├── assets │ ├── logo-black.svg │ ├── logo-white.svg │ └── main.css ├── components │ ├── Avatar.vue │ ├── CodePreview.vue │ ├── DropdownMenu.vue │ ├── UserProfileImage.vue │ └── nav │ │ ├── NavBar.vue │ │ ├── NavLink.vue │ │ └── index.ts ├── main.ts ├── router │ └── index.ts ├── stores │ └── counter.ts ├── utils │ └── index.ts └── views │ ├── HomeView.vue │ ├── ProfileView.vue │ └── posts │ ├── MyPostsView.vue │ └── NewPostView.vue ├── tailwind.config.js ├── tsconfig.app.json ├── tsconfig.config.json ├── tsconfig.json ├── tsconfig.vitest.json └── vite.config.ts /.gitignore: -------------------------------------------------------------------------------- 1 | scripts/db/data/ 2 | .spin/ 3 | api/post/main.wasm 4 | -------------------------------------------------------------------------------- /.vscode/extensions.json: -------------------------------------------------------------------------------- 1 | { 2 | "recommendations": [ 3 | "bradlc.vscode-tailwindcss", 4 | "esbenp.prettier-vscode", 5 | "rust-lang.rust-analyzer", 6 | "serayuzgur.crates", 7 | "vue.vscode-typescript-vue-plugin", 8 | "vue.volar" 9 | ] 10 | } -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "[rust]": { 3 | "editor.defaultFormatter": "rust-lang.rust-analyzer" 4 | }, 5 | "rust-analyzer.linkedProjects": [ 6 | "api/profile/Cargo.toml" 7 | ], 8 | } -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Apache License 2 | Version 2.0, January 2004 3 | http://www.apache.org/licenses/ 4 | 5 | TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 6 | 7 | 1. Definitions. 8 | 9 | "License" shall mean the terms and conditions for use, reproduction, 10 | and distribution as defined by Sections 1 through 9 of this document. 11 | 12 | "Licensor" shall mean the copyright owner or entity authorized by 13 | the copyright owner that is granting the License. 14 | 15 | "Legal Entity" shall mean the union of the acting entity and all 16 | other entities that control, are controlled by, or are under common 17 | control with that entity. For the purposes of this definition, 18 | "control" means (i) the power, direct or indirect, to cause the 19 | direction or management of such entity, whether by contract or 20 | otherwise, or (ii) ownership of fifty percent (50%) or more of the 21 | outstanding shares, or (iii) beneficial ownership of such entity. 22 | 23 | "You" (or "Your") shall mean an individual or Legal Entity 24 | exercising permissions granted by this License. 25 | 26 | "Source" form shall mean the preferred form for making modifications, 27 | including but not limited to software source code, documentation 28 | source, and configuration files. 29 | 30 | "Object" form shall mean any form resulting from mechanical 31 | transformation or translation of a Source form, including but 32 | not limited to compiled object code, generated documentation, 33 | and conversions to other media types. 34 | 35 | "Work" shall mean the work of authorship, whether in Source or 36 | Object form, made available under the License, as indicated by a 37 | copyright notice that is included in or attached to the work 38 | (an example is provided in the Appendix below). 39 | 40 | "Derivative Works" shall mean any work, whether in Source or Object 41 | form, that is based on (or derived from) the Work and for which the 42 | editorial revisions, annotations, elaborations, or other modifications 43 | represent, as a whole, an original work of authorship. For the purposes 44 | of this License, Derivative Works shall not include works that remain 45 | separable from, or merely link (or bind by name) to the interfaces of, 46 | the Work and Derivative Works thereof. 47 | 48 | "Contribution" shall mean any work of authorship, including 49 | the original version of the Work and any modifications or additions 50 | to that Work or Derivative Works thereof, that is intentionally 51 | submitted to Licensor for inclusion in the Work by the copyright owner 52 | or by an individual or Legal Entity authorized to submit on behalf of 53 | the copyright owner. For the purposes of this definition, "submitted" 54 | means any form of electronic, verbal, or written communication sent 55 | to the Licensor or its representatives, including but not limited to 56 | communication on electronic mailing lists, source code control systems, 57 | and issue tracking systems that are managed by, or on behalf of, the 58 | Licensor for the purpose of discussing and improving the Work, but 59 | excluding communication that is conspicuously marked or otherwise 60 | designated in writing by the copyright owner as "Not a Contribution." 61 | 62 | "Contributor" shall mean Licensor and any individual or Legal Entity 63 | on behalf of whom a Contribution has been received by Licensor and 64 | subsequently incorporated within the Work. 65 | 66 | 2. Grant of Copyright License. Subject to the terms and conditions of 67 | this License, each Contributor hereby grants to You a perpetual, 68 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 69 | copyright license to reproduce, prepare Derivative Works of, 70 | publicly display, publicly perform, sublicense, and distribute the 71 | Work and such Derivative Works in Source or Object form. 72 | 73 | 3. Grant of Patent License. Subject to the terms and conditions of 74 | this License, each Contributor hereby grants to You a perpetual, 75 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 76 | (except as stated in this section) patent license to make, have made, 77 | use, offer to sell, sell, import, and otherwise transfer the Work, 78 | where such license applies only to those patent claims licensable 79 | by such Contributor that are necessarily infringed by their 80 | Contribution(s) alone or by combination of their Contribution(s) 81 | with the Work to which such Contribution(s) was submitted. If You 82 | institute patent litigation against any entity (including a 83 | cross-claim or counterclaim in a lawsuit) alleging that the Work 84 | or a Contribution incorporated within the Work constitutes direct 85 | or contributory patent infringement, then any patent licenses 86 | granted to You under this License for that Work shall terminate 87 | as of the date such litigation is filed. 88 | 89 | 4. Redistribution. You may reproduce and distribute copies of the 90 | Work or Derivative Works thereof in any medium, with or without 91 | modifications, and in Source or Object form, provided that You 92 | meet the following conditions: 93 | 94 | (a) You must give any other recipients of the Work or 95 | Derivative Works a copy of this License; and 96 | 97 | (b) You must cause any modified files to carry prominent notices 98 | stating that You changed the files; and 99 | 100 | (c) You must retain, in the Source form of any Derivative Works 101 | that You distribute, all copyright, patent, trademark, and 102 | attribution notices from the Source form of the Work, 103 | excluding those notices that do not pertain to any part of 104 | the Derivative Works; and 105 | 106 | (d) If the Work includes a "NOTICE" text file as part of its 107 | distribution, then any Derivative Works that You distribute must 108 | include a readable copy of the attribution notices contained 109 | within such NOTICE file, excluding those notices that do not 110 | pertain to any part of the Derivative Works, in at least one 111 | of the following places: within a NOTICE text file distributed 112 | as part of the Derivative Works; within the Source form or 113 | documentation, if provided along with the Derivative Works; or, 114 | within a display generated by the Derivative Works, if and 115 | wherever such third-party notices normally appear. The contents 116 | of the NOTICE file are for informational purposes only and 117 | do not modify the License. You may add Your own attribution 118 | notices within Derivative Works that You distribute, alongside 119 | or as an addendum to the NOTICE text from the Work, provided 120 | that such additional attribution notices cannot be construed 121 | as modifying the License. 122 | 123 | You may add Your own copyright statement to Your modifications and 124 | may provide additional or different license terms and conditions 125 | for use, reproduction, or distribution of Your modifications, or 126 | for any such Derivative Works as a whole, provided Your use, 127 | reproduction, and distribution of the Work otherwise complies with 128 | the conditions stated in this License. 129 | 130 | 5. Submission of Contributions. Unless You explicitly state otherwise, 131 | any Contribution intentionally submitted for inclusion in the Work 132 | by You to the Licensor shall be under the terms and conditions of 133 | this License, without any additional terms or conditions. 134 | Notwithstanding the above, nothing herein shall supersede or modify 135 | the terms of any separate license agreement you may have executed 136 | with Licensor regarding such Contributions. 137 | 138 | 6. Trademarks. This License does not grant permission to use the trade 139 | names, trademarks, service marks, or product names of the Licensor, 140 | except as required for reasonable and customary use in describing the 141 | origin of the Work and reproducing the content of the NOTICE file. 142 | 143 | 7. Disclaimer of Warranty. Unless required by applicable law or 144 | agreed to in writing, Licensor provides the Work (and each 145 | Contributor provides its Contributions) on an "AS IS" BASIS, 146 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or 147 | implied, including, without limitation, any warranties or conditions 148 | of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A 149 | PARTICULAR PURPOSE. You are solely responsible for determining the 150 | appropriateness of using or redistributing the Work and assume any 151 | risks associated with Your exercise of permissions under this License. 152 | 153 | 8. Limitation of Liability. In no event and under no legal theory, 154 | whether in tort (including negligence), contract, or otherwise, 155 | unless required by applicable law (such as deliberate and grossly 156 | negligent acts) or agreed to in writing, shall any Contributor be 157 | liable to You for damages, including any direct, indirect, special, 158 | incidental, or consequential damages of any character arising as a 159 | result of this License or out of the use or inability to use the 160 | Work (including but not limited to damages for loss of goodwill, 161 | work stoppage, computer failure or malfunction, or any and all 162 | other commercial damages or losses), even if such Contributor 163 | has been advised of the possibility of such damages. 164 | 165 | 9. Accepting Warranty or Additional Liability. While redistributing 166 | the Work or Derivative Works thereof, You may choose to offer, 167 | and charge a fee for, acceptance of support, warranty, indemnity, 168 | or other liability obligations and/or rights consistent with this 169 | License. However, in accepting such obligations, You may act only 170 | on Your own behalf and on Your sole responsibility, not on behalf 171 | of any other Contributor, and only if You agree to indemnify, 172 | defend, and hold each Contributor harmless for any liability 173 | incurred by, or claims asserted against, such Contributor by reason 174 | of your accepting any such warranty or additional liability. 175 | 176 | END OF TERMS AND CONDITIONS 177 | 178 | APPENDIX: How to apply the Apache License to your work. 179 | 180 | To apply the Apache License to your work, attach the following 181 | boilerplate notice, with the fields enclosed by brackets "[]" 182 | replaced with your own identifying information. (Don't include 183 | the brackets!) The text should be enclosed in the appropriate 184 | comment syntax for the file format. We also recommend that a 185 | file or class name and description of purpose be included on the 186 | same "printed page" as the copyright notice for easier 187 | identification within third-party archives. 188 | 189 | Copyright [yyyy] [name of copyright owner] 190 | 191 | Licensed under the Apache License, Version 2.0 (the "License"); 192 | you may not use this file except in compliance with the License. 193 | You may obtain a copy of the License at 194 | 195 | http://www.apache.org/licenses/LICENSE-2.0 196 | 197 | Unless required by applicable law or agreed to in writing, software 198 | distributed under the License is distributed on an "AS IS" BASIS, 199 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 200 | See the License for the specific language governing permissions and 201 | limitations under the License. 202 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Code Things 2 | This social media application built using the Spin SDK is intended to be an in-depth reference or guide for Spin developers. It focuses on more common real-world use cases like CRUD APIs, Token auth, etc. 3 | 4 | ## SDK Requirements 5 | The following SDKs are required to build this application. 6 | - [Spin SDK](https://developer.fermyon.com/spin/install) 7 | - [rustup](https://rustup.rs) 8 | - [Node.js](https://nodejs.org) 9 | 10 | ## External Resources 11 | The following resources are required to run this application. For local development, these resources can be started via docker. 12 | - [PostreSQL](https://www.postgresql.org) 13 | 14 | ## Auth0 Setup 15 | 16 | To complete this setup for Fermyon Cloud, you must have run `spin deploy` at least once to capture the app's URL. For example, the application's URL in the following example is `https://code-things-xxx.fermyon.app`: 17 | ``` 18 | % spin deploy 19 | Uploading code-things version 0.1.0+rcf68d278... 20 | Deploying... 21 | Waiting for application to become ready.......... ready 22 | Available Routes: 23 | web: https://code-things-xxx.fermyon.app (wildcard) 24 | profile-api: https://code-things-xxx.fermyon.app/api/profile (wildcard) 25 | ``` 26 | 27 | 1. Sign up for Auth0 account (free) 28 | 2. Create a "Single Page Web" application 29 | a. Configure callback URLs: `http://127.0.0.1:3000, https://code-things-xxx.fermyon.app` 30 | b. Configure logout URLs: `http://127.0.0.1:3000, https://code-things-xxx.fermyon.app` 31 | c. Allowed web origins: `http://127.0.0.1:3000, https://code-things-xxx.fermyon.app` 32 | d. Add GitHub Connection 33 | 3. Create API 34 | a. Name: 'Code Things API' 35 | b. Identifier: `https://code-things-xxx.fermyon.app/` 36 | c. Signing Algorithm: `RS256` 37 | 4. Add the Auth0 configuration to Vue.js: 38 | a. Create a file at `./web/.env.local` (this is gitignored) 39 | b. Add domain: `VITE_AUTH0_DOMAIN = "dev-xxx.us.auth0.com"` 40 | c. Add client id: `VITE_AUTH0_CLIENT_ID = "xxx"` 41 | c. Add audience: `VITE_AUTH0_AUDIENCE = "https://code-things.fermyon.app/api"` 42 | -------------------------------------------------------------------------------- /api/post/config.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "fmt" 5 | 6 | "github.com/fermyon/spin/sdk/go/config" 7 | ) 8 | 9 | // Config Helpers 10 | 11 | func configGetRequired(key string) string { 12 | if val, err := config.Get(key); err != nil { 13 | panic(fmt.Sprintf("Missing required config item 'jwks_uri': %v", err)) 14 | } else { 15 | return val 16 | } 17 | } 18 | 19 | func getIssuer() string { 20 | domain := configGetRequired("auth_domain") 21 | return fmt.Sprintf("https://%v/", domain) 22 | } 23 | 24 | func getAudience() string { 25 | return configGetRequired("auth_audience") 26 | } 27 | 28 | func getJwksUri() string { 29 | domain := configGetRequired("auth_domain") 30 | return fmt.Sprintf("https://%v/.well-known/jwks.json", domain) 31 | } 32 | 33 | func getDbUrl() string { 34 | return configGetRequired("db_url") 35 | } 36 | -------------------------------------------------------------------------------- /api/post/data.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "fmt" 5 | 6 | "github.com/fermyon/spin/sdk/go/postgres" 7 | ) 8 | 9 | // Database Operations 10 | 11 | func DbInsert(post *Post) error { 12 | db_url := getDbUrl() 13 | statement := "INSERT INTO posts (author_id, content, type, data, visibility) VALUES ($1, $2, $3, $4, $5)" 14 | params := []postgres.ParameterValue{ 15 | postgres.ParameterValueStr(post.AuthorID), 16 | postgres.ParameterValueStr(post.Content), 17 | postgres.ParameterValueStr(post.Type.String()), 18 | postgres.ParameterValueStr(post.Data), 19 | postgres.ParameterValueStr(post.Visibility.String()), 20 | } 21 | 22 | _, err := postgres.Execute(db_url, statement, params) 23 | if err != nil { 24 | return fmt.Errorf("Error inserting into database: %s", err.Error()) 25 | } 26 | 27 | // this is a gross hack that will surely bite me later 28 | rowset, err := postgres.Query(db_url, "SELECT lastval()", []postgres.ParameterValue{}) 29 | if err != nil || len(rowset.Rows) != 1 || len(rowset.Rows[0]) != 1 { 30 | return fmt.Errorf("Error querying id from database: %s", err.Error()) 31 | } 32 | 33 | id_val := rowset.Rows[0][0] 34 | if id_val.Kind() == postgres.DbValueKindInt64 { 35 | post.ID = int(id_val.GetInt64()) 36 | } else { 37 | fmt.Printf("Failed to populate created post's identifier, invalid kind returned from database: %v\n", id_val.Kind()) 38 | } 39 | 40 | return nil 41 | } 42 | 43 | func DbReadById(id int, authorId string) (Post, error) { 44 | db_url := getDbUrl() 45 | statement := "SELECT id, author_id, content, type, data, visibility FROM posts WHERE id=$1 and author_id=$2" 46 | params := []postgres.ParameterValue{ 47 | postgres.ParameterValueInt32(int32(id)), 48 | postgres.ParameterValueStr(authorId), 49 | } 50 | 51 | rowset, err := postgres.Query(db_url, statement, params) 52 | if err != nil { 53 | return Post{}, fmt.Errorf("Error reading from database: %s", err.Error()) 54 | } 55 | 56 | if rowset.Rows == nil || len(rowset.Rows) == 0 { 57 | return Post{}, nil 58 | } else { 59 | return fromRow(rowset.Rows[0]) 60 | } 61 | } 62 | 63 | func DbReadAll(limit int, offset int, authorId string) ([]Post, error) { 64 | db_url := getDbUrl() 65 | statement := "SELECT id, author_id, content, type, data, visibility FROM posts WHERE author_id=$3 ORDER BY id LIMIT $1 OFFSET $2" 66 | params := []postgres.ParameterValue{ 67 | postgres.ParameterValueInt64(int64(limit)), 68 | postgres.ParameterValueInt64(int64(offset)), 69 | postgres.ParameterValueStr(authorId), 70 | } 71 | rowset, err := postgres.Query(db_url, statement, params) 72 | if err != nil { 73 | return []Post{}, fmt.Errorf("Error reading from database: %s", err.Error()) 74 | } 75 | 76 | posts := make([]Post, len(rowset.Rows)) 77 | for i, row := range rowset.Rows { 78 | if post, err := fromRow(row); err != nil { 79 | return []Post{}, err 80 | } else { 81 | posts[i] = post 82 | } 83 | } 84 | 85 | return posts, nil 86 | } 87 | 88 | func DbUpdate(post Post) error { 89 | db_url := getDbUrl() 90 | statement := "UPDATE posts SET content=$1, type=$2, data=$3, visibility=$4 WHERE id=$5" 91 | params := []postgres.ParameterValue{ 92 | postgres.ParameterValueStr(post.Content), 93 | postgres.ParameterValueStr(post.Type.String()), 94 | postgres.ParameterValueStr(post.Data), 95 | postgres.ParameterValueStr(post.Visibility.String()), 96 | postgres.ParameterValueInt32(int32(post.ID)), 97 | } 98 | 99 | _, err := postgres.Execute(db_url, statement, params) 100 | if err != nil { 101 | return fmt.Errorf("Error updating database: %s", err.Error()) 102 | } 103 | 104 | return nil 105 | } 106 | 107 | func DbDelete(id int, authorId string) error { 108 | db_url := getDbUrl() 109 | statement := "DELETE FROM posts WHERE id=$1 and author_id=$2" 110 | params := []postgres.ParameterValue{ 111 | postgres.ParameterValueInt32(int32(id)), 112 | postgres.ParameterValueStr(authorId), 113 | } 114 | 115 | _, err := postgres.Execute(db_url, statement, params) 116 | return err 117 | } 118 | 119 | // Database Helper Functions 120 | 121 | func assertValueKind(row []postgres.DbValue, col int, expected postgres.DbValueKind) (postgres.DbValue, error) { 122 | if row[col].Kind() != expected { 123 | return postgres.DbValue{}, fmt.Errorf("Expected column %v to be %v kind but got %v\n", col, expected, row[col].Kind()) 124 | } 125 | return row[col], nil 126 | } 127 | 128 | func fromRow(row []postgres.DbValue) (Post, error) { 129 | var post Post 130 | 131 | if val, err := assertValueKind(row, 0, postgres.DbValueKindInt32); err != nil { 132 | return post, err 133 | } else { 134 | post.ID = int(val.GetInt32()) 135 | } 136 | 137 | if val, err := assertValueKind(row, 1, postgres.DbValueKindStr); err != nil { 138 | return post, err 139 | } else { 140 | post.AuthorID = val.GetStr() 141 | } 142 | 143 | if val, err := assertValueKind(row, 2, postgres.DbValueKindStr); err != nil { 144 | return post, err 145 | } else { 146 | post.Content = val.GetStr() 147 | } 148 | 149 | if val, err := assertValueKind(row, 3, postgres.DbValueKindStr); err != nil { 150 | return post, err 151 | } else if val, err := ParsePostType(val.GetStr()); err != nil { 152 | return post, err 153 | } else { 154 | post.Type = val 155 | } 156 | 157 | if val, err := assertValueKind(row, 4, postgres.DbValueKindStr); err != nil { 158 | return post, err 159 | } else { 160 | post.Data = val.GetStr() 161 | } 162 | 163 | if val, err := assertValueKind(row, 5, postgres.DbValueKindStr); err != nil { 164 | return post, err 165 | } else if val, err := ParsePostVisibility(val.GetStr()); err != nil { 166 | return post, err 167 | } else { 168 | post.Visibility = val 169 | } 170 | 171 | return post, nil 172 | } 173 | -------------------------------------------------------------------------------- /api/post/go.mod: -------------------------------------------------------------------------------- 1 | module github.com/fermyon/code-things/api/post 2 | 3 | go 1.20 4 | 5 | replace github.com/fermyon/spin/sdk/go v1.0.0 => github.com/jpflueger/spin/sdk/go v0.6.1-0.20230405131322-423d9b11be46 6 | 7 | require ( 8 | github.com/MicahParks/keyfunc v1.9.0 9 | github.com/fermyon/spin/sdk/go v1.0.0 10 | github.com/go-chi/chi/v5 v5.0.8 11 | github.com/golang-jwt/jwt/v4 v4.5.0 12 | ) 13 | -------------------------------------------------------------------------------- /api/post/go.sum: -------------------------------------------------------------------------------- 1 | github.com/MicahParks/keyfunc v1.9.0 h1:lhKd5xrFHLNOWrDc4Tyb/Q1AJ4LCzQ48GVJyVIID3+o= 2 | github.com/MicahParks/keyfunc v1.9.0/go.mod h1:IdnCilugA0O/99dW+/MkvlyrsX8+L8+x95xuVNtM5jw= 3 | github.com/go-chi/chi/v5 v5.0.8 h1:lD+NLqFcAi1ovnVZpsnObHGW4xb4J8lNmoYVfECH1Y0= 4 | github.com/go-chi/chi/v5 v5.0.8/go.mod h1:DslCQbL2OYiznFReuXYUmQ2hGd1aDpCnlMNITLSKoi8= 5 | github.com/golang-jwt/jwt/v4 v4.4.2/go.mod h1:m21LjoU+eqJr34lmDMbreY2eSTRJ1cv77w39/MY0Ch0= 6 | github.com/golang-jwt/jwt/v4 v4.5.0 h1:7cYmW1XlMY7h7ii7UhUyChSgS5wUJEnm9uZVTGqOWzg= 7 | github.com/golang-jwt/jwt/v4 v4.5.0/go.mod h1:m21LjoU+eqJr34lmDMbreY2eSTRJ1cv77w39/MY0Ch0= 8 | github.com/jpflueger/spin/sdk/go v0.6.1-0.20230405131322-423d9b11be46 h1:pcspeUjy3L8kvh6s1obO7KYI9I3uYwSVar7EeHayydE= 9 | github.com/jpflueger/spin/sdk/go v0.6.1-0.20230405131322-423d9b11be46/go.mod h1:ARV2oVtnUCykLM+xCBZq8MQrCZddzb3JbeBettYv1S0= 10 | -------------------------------------------------------------------------------- /api/post/main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "fmt" 5 | "net/http" 6 | 7 | spinhttp "github.com/fermyon/spin/sdk/go/http" 8 | "github.com/go-chi/chi/v5" 9 | ) 10 | 11 | func main() {} 12 | 13 | func init() { 14 | spinhttp.Handle(func(res http.ResponseWriter, req *http.Request) { 15 | // we need to setup the router inside spin handler 16 | router := chi.NewRouter() 17 | 18 | router.Use(TokenVerification) 19 | 20 | // mount our routes using the prefix 21 | routePrefix := req.Header.Get("Spin-Component-Route") 22 | router.Mount(routePrefix, PostRouter()) 23 | 24 | // hand the request/response off to chi 25 | router.ServeHTTP(res, req) 26 | }) 27 | } 28 | 29 | // TODO: create wrapper function to handle errors? 30 | func PostRouter() chi.Router { 31 | posts := chi.NewRouter() 32 | idParamPattern := fmt.Sprintf("/{%v:[0-9]+}", postIdCtxKey) 33 | posts.Use(PostCtx) 34 | posts.Post("/", createPost) 35 | posts.Get("/", listPosts) 36 | posts.Get(idParamPattern, readPost) 37 | posts.Put(idParamPattern, updatePost) 38 | posts.Delete(idParamPattern, deletePost) 39 | return posts 40 | } 41 | 42 | func createPost(res http.ResponseWriter, req *http.Request) { 43 | post := req.Context().Value(postCtxKey{}).(Post) 44 | 45 | if getUserId(req) != post.AuthorID { 46 | renderForbiddenResponse(res) 47 | return 48 | } 49 | 50 | if err := DbInsert(&post); err != nil { 51 | renderErrorResponse(res, err) 52 | return 53 | } 54 | 55 | res.WriteHeader(http.StatusCreated) 56 | res.Header().Add("location", fmt.Sprintf("/api/post/%v", post.ID)) 57 | renderJsonResponse(res, post) 58 | } 59 | 60 | func listPosts(res http.ResponseWriter, req *http.Request) { 61 | limit, offset := getPaginationParams(req) 62 | authorId := getUserId(req) 63 | 64 | if posts, err := DbReadAll(limit, offset, authorId); err != nil { 65 | renderErrorResponse(res, err) 66 | } else { 67 | renderJsonResponse(res, posts) 68 | } 69 | } 70 | 71 | func readPost(res http.ResponseWriter, req *http.Request) { 72 | id, err := getPostId(req) 73 | if err != nil { 74 | msg := fmt.Sprintf("Failed to parse URL param 'id': %v", id) 75 | renderBadRequestResponse(res, msg) 76 | return 77 | } 78 | 79 | authorId := getUserId(req) 80 | 81 | post, err := DbReadById(id, authorId) 82 | if err != nil { 83 | renderErrorResponse(res, err) 84 | return 85 | } 86 | if (post == Post{}) { 87 | renderNotFound(res) 88 | return 89 | } 90 | 91 | renderJsonResponse(res, post) 92 | } 93 | 94 | func updatePost(res http.ResponseWriter, req *http.Request) { 95 | post := req.Context().Value(postCtxKey{}).(Post) 96 | 97 | if id, err := getPostId(req); err != nil { 98 | msg := fmt.Sprintf("Failed to parse URL param 'id': %v", id) 99 | renderBadRequestResponse(res, msg) 100 | return 101 | } else { 102 | post.ID = id 103 | } 104 | 105 | if err := DbUpdate(post); err != nil { 106 | renderErrorResponse(res, err) 107 | } 108 | renderJsonResponse(res, post) 109 | } 110 | 111 | func deletePost(res http.ResponseWriter, req *http.Request) { 112 | id, err := getPostId(req) 113 | if err != nil { 114 | msg := fmt.Sprintf("Failed to parse URL param 'id': %v", id) 115 | renderBadRequestResponse(res, msg) 116 | return 117 | } 118 | 119 | authorId := getUserId(req) 120 | 121 | if err := DbDelete(id, authorId); err != nil { 122 | renderErrorResponse(res, err) 123 | } 124 | res.WriteHeader(http.StatusNoContent) 125 | } 126 | -------------------------------------------------------------------------------- /api/post/middleware.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "context" 5 | "errors" 6 | "fmt" 7 | "net/http" 8 | "strconv" 9 | 10 | "github.com/MicahParks/keyfunc" 11 | "github.com/go-chi/chi/v5" 12 | "github.com/golang-jwt/jwt/v4" 13 | "github.com/golang-jwt/jwt/v4/request" 14 | ) 15 | 16 | // Authorization Middleware 17 | 18 | type claimsCtxKey struct{} 19 | 20 | func TokenVerification(next http.Handler) http.Handler { 21 | return http.HandlerFunc(func(res http.ResponseWriter, req *http.Request) { 22 | // ensure RS256 was used to sign the token 23 | parserOpts := []request.ParseFromRequestOption{ 24 | request.WithParser(jwt.NewParser(jwt.WithValidMethods([]string{ 25 | jwt.SigningMethodRS256.Alg(), 26 | }))), 27 | } 28 | token, err := request.ParseFromRequest(req, request.OAuth2Extractor, fetchAuthSigningKey, parserOpts...) 29 | if err != nil { 30 | if errors.Is(err, jwt.ValidationError{}) { 31 | // token parsed but was invalid 32 | renderUnauthorized(res, err) 33 | } else { 34 | // unable to parse or verify signing 35 | renderErrorResponse(res, err) 36 | } 37 | return 38 | } 39 | 40 | claims := token.Claims.(jwt.MapClaims) 41 | if !token.Valid { 42 | renderUnauthorized(res, fmt.Errorf("token not valid")) 43 | return 44 | } 45 | 46 | if !claims.VerifyIssuer(getIssuer(), true) { 47 | renderUnauthorized(res, jwt.ErrTokenInvalidIssuer) 48 | return 49 | } 50 | 51 | if !claims.VerifyAudience(getAudience(), true) { 52 | renderUnauthorized(res, jwt.ErrTokenInvalidAudience) 53 | return 54 | } 55 | 56 | ctx := context.WithValue(req.Context(), claimsCtxKey{}, claims) 57 | next.ServeHTTP(res, req.WithContext(ctx)) 58 | }) 59 | } 60 | 61 | func fetchAuthSigningKey(t *jwt.Token) (interface{}, error) { 62 | jwksUri := getJwksUri() 63 | if jwks, err := keyfunc.Get(jwksUri, keyfunc.Options{ 64 | Client: NewHttpClient(), 65 | }); err != nil { 66 | return nil, err 67 | } else { 68 | return jwks.Keyfunc(t) 69 | } 70 | } 71 | 72 | // Post Model Middleware 73 | 74 | var postIdCtxKey string = "id" 75 | 76 | type postCtxKey struct{} 77 | 78 | func PostCtx(next http.Handler) http.Handler { 79 | return http.HandlerFunc(func(res http.ResponseWriter, req *http.Request) { 80 | var post Post 81 | var err error 82 | 83 | if req.ContentLength > 0 && req.Header.Get("Content-Type") == "application/json" { 84 | if post, err = DecodePost(req.Body); err != nil { 85 | // parsing failed end the request here 86 | msg := fmt.Sprintf("Failed to parse the post from request body: %v\n", err) 87 | renderBadRequestResponse(res, msg) 88 | return 89 | } 90 | if err = post.Validate(); err != nil { 91 | msg := fmt.Sprintf("Request body failed validation: %v\n", err) 92 | renderBadRequestResponse(res, msg) 93 | return 94 | } 95 | } 96 | 97 | ctx := context.WithValue(req.Context(), postCtxKey{}, post) 98 | next.ServeHTTP(res, req.WithContext(ctx)) 99 | }) 100 | } 101 | 102 | func getPostId(r *http.Request) (int, error) { 103 | idStr := chi.URLParam(r, postIdCtxKey) 104 | return strconv.Atoi(idStr) 105 | } 106 | -------------------------------------------------------------------------------- /api/post/model.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "encoding/json" 5 | "errors" 6 | "fmt" 7 | "io" 8 | "strings" 9 | ) 10 | 11 | // Post model 12 | 13 | type Post struct { 14 | ID int `json:"id,omitempty"` // auto-incremented by postgres 15 | AuthorID string `json:"author_id,omitempty"` // foreign key to user's id 16 | Content string `json:"content,omitempty"` // anything the poster wants to say about a piece of code they're sharing 17 | Type PostType `json:"type,omitempty"` // post could be a permalink, pasted code, gist, etc. 18 | Data string `json:"data,omitempty"` // actual permalink, code, gist link, etc. 19 | Visibility PostVisibility `json:"visibility,omitempty"` // basic visibility of public, followers, etc. 20 | } 21 | 22 | func (p Post) Validate() error { 23 | var errs []error 24 | 25 | if p.AuthorID == "" { 26 | errs = append(errs, fmt.Errorf("field 'author_id' is required")) 27 | } 28 | 29 | if p.Content == "" { 30 | errs = append(errs, fmt.Errorf("field 'content' is required")) 31 | } 32 | 33 | if p.Type == 0 { 34 | errs = append(errs, fmt.Errorf("field 'type' contains unknown value")) 35 | } 36 | 37 | if p.Data == "" { 38 | errs = append(errs, fmt.Errorf("field 'data' is required")) 39 | } 40 | 41 | if p.Visibility == 0 { 42 | errs = append(errs, fmt.Errorf("field 'visibility' contains unknown value")) 43 | } 44 | 45 | return errors.Join(errs...) 46 | } 47 | 48 | // Post Type enum 49 | 50 | type PostType uint8 51 | 52 | const ( 53 | PostTypePermalinkRange PostType = iota + 1 54 | ) 55 | 56 | var ( 57 | PostType_name = map[PostType]string{ 58 | PostTypePermalinkRange: "permalink-range", 59 | } 60 | PostType_value = map[string]PostType{ 61 | "permalink-range": PostTypePermalinkRange, 62 | } 63 | ) 64 | 65 | func (t PostType) String() string { 66 | return PostType_name[t] 67 | } 68 | 69 | func ParsePostType(s string) (PostType, error) { 70 | s = strings.TrimSpace(strings.ToLower(s)) 71 | value, ok := PostType_value[s] 72 | if !ok { 73 | return PostType(0), fmt.Errorf("%q is not a valid post type", s) 74 | } 75 | return PostType(value), nil 76 | } 77 | 78 | func (t PostType) MarshalJSON() ([]byte, error) { 79 | return json.Marshal(t.String()) 80 | } 81 | 82 | func (t *PostType) UnmarshalJSON(data []byte) (err error) { 83 | var postType string 84 | if err := json.Unmarshal(data, &postType); err != nil { 85 | return err 86 | } 87 | if *t, err = ParsePostType(postType); err != nil { 88 | return err 89 | } 90 | return nil 91 | } 92 | 93 | // Post Visibility enum 94 | 95 | type PostVisibility uint8 96 | 97 | const ( 98 | PostVisibilityPublic PostVisibility = iota + 1 99 | PostVisibilityFollowers 100 | ) 101 | 102 | var ( 103 | PostVisibility_name = map[PostVisibility]string{ 104 | PostVisibilityPublic: "public", 105 | PostVisibilityFollowers: "followers", 106 | } 107 | PostVisibility_value = map[string]PostVisibility{ 108 | "public": PostVisibilityPublic, 109 | "followers": PostVisibilityFollowers, 110 | } 111 | ) 112 | 113 | func (v PostVisibility) String() string { 114 | return PostVisibility_name[v] 115 | } 116 | 117 | func ParsePostVisibility(s string) (PostVisibility, error) { 118 | s = strings.TrimSpace(strings.ToLower(s)) 119 | value, ok := PostVisibility_value[s] 120 | if !ok { 121 | return PostVisibility(0), fmt.Errorf("%q is not a valid post visibility", s) 122 | } 123 | return PostVisibility(value), nil 124 | } 125 | 126 | func (v PostVisibility) MarshalJSON() ([]byte, error) { 127 | return json.Marshal(v.String()) 128 | } 129 | 130 | func (v *PostVisibility) UnmarshalJSON(data []byte) (err error) { 131 | var visibility string 132 | if err := json.Unmarshal(data, &visibility); err != nil { 133 | return err 134 | } 135 | if *v, err = ParsePostVisibility(visibility); err != nil { 136 | return err 137 | } 138 | return nil 139 | } 140 | 141 | // JSON helpers 142 | 143 | func DecodePost(r io.ReadCloser) (Post, error) { 144 | decoder := json.NewDecoder(r) 145 | var p Post 146 | err := decoder.Decode(&p) 147 | return p, err 148 | } 149 | -------------------------------------------------------------------------------- /api/post/utils.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "encoding/json" 5 | "fmt" 6 | "net/http" 7 | "strconv" 8 | "time" 9 | 10 | spinhttp "github.com/fermyon/spin/sdk/go/http" 11 | "github.com/golang-jwt/jwt/v4" 12 | ) 13 | 14 | // HTTP Response Helpers 15 | 16 | func renderBadRequestResponse(res http.ResponseWriter, msg string) { 17 | http.Error(res, msg, http.StatusBadRequest) 18 | } 19 | 20 | func renderErrorResponse(res http.ResponseWriter, err error) { 21 | //TODO: does this work if the response has already been partially written to? 22 | http.Error(res, err.Error(), http.StatusInternalServerError) 23 | } 24 | 25 | func renderForbiddenResponse(res http.ResponseWriter) { 26 | // intentionally make this one obscure in case of malicious intent 27 | http.Error(res, "Forbidden: You do not have permissions to perform this action", http.StatusForbidden) 28 | } 29 | 30 | func renderNotFound(res http.ResponseWriter) { 31 | http.Error(res, "Not found", http.StatusNotFound) 32 | } 33 | 34 | func renderUnauthorized(res http.ResponseWriter, err error) { 35 | http.Error(res, err.Error(), http.StatusUnauthorized) 36 | } 37 | 38 | func renderJsonResponse(res http.ResponseWriter, body any) { 39 | if err := json.NewEncoder(res).Encode(body); err != nil { 40 | renderErrorResponse(res, err) 41 | } else { 42 | res.Header().Add("Content-Type", "application/json") 43 | } 44 | } 45 | 46 | // Pagination Helpers 47 | 48 | func getPaginationParams(req *http.Request) (limit int, offset int) { 49 | // helper function to clamp the value 50 | clamp := func(val int, min int, max int) int { 51 | if val < min { 52 | return min 53 | } else if val > max { 54 | return max 55 | } else { 56 | return val 57 | } 58 | } 59 | 60 | // get the limit from the URL 61 | limit_param := req.URL.Query().Get("limit") 62 | if limit_val, err := strconv.Atoi(limit_param); err != nil { 63 | // error occurred, just use a default value 64 | fmt.Printf("Failed to parse the limit from URL: %v\n", err) 65 | limit = 5 66 | } else { 67 | // clamp the value in case of invalid parameters (intentional or otherwise) 68 | limit = clamp(limit_val, 0, 25) 69 | } 70 | 71 | // get the offset from the URL 72 | offset_param := req.URL.Query().Get("offset") 73 | if offset_val, err := strconv.Atoi(offset_param); err != nil { 74 | // error occurred, just use a default value 75 | fmt.Printf("Failed to parse the offset from URL: %v\n", err) 76 | offset = 0 77 | } else { 78 | // clamp the value in case of invalid parameters (intentional or otherwise) 79 | // limiting this one to 10,000 because I find it unlikely that anyone will post 10k times :) 80 | offset = clamp(offset_val, 0, 10000) 81 | } 82 | 83 | return limit, offset 84 | } 85 | 86 | // Auth Helpers 87 | 88 | func getUserId(req *http.Request) string { 89 | claims := req.Context().Value(claimsCtxKey{}).(jwt.MapClaims) 90 | return claims["sub"].(string) 91 | } 92 | 93 | // HTTP Client 94 | //TODO: this could be contributed to spin go sdk 95 | 96 | type spinRoundTripper struct{} 97 | 98 | func (t spinRoundTripper) RoundTrip(req *http.Request) (resp *http.Response, err error) { 99 | return spinhttp.Send(req) 100 | } 101 | 102 | func NewHttpClient() *http.Client { 103 | return &http.Client{ 104 | Transport: spinRoundTripper{}, 105 | Timeout: time.Duration(5) * time.Second, 106 | } 107 | } 108 | -------------------------------------------------------------------------------- /api/profile/.gitignore: -------------------------------------------------------------------------------- 1 | target/ 2 | -------------------------------------------------------------------------------- /api/profile/Cargo.lock: -------------------------------------------------------------------------------- 1 | # This file is automatically @generated by Cargo. 2 | # It is not intended for manual editing. 3 | version = 3 4 | 5 | [[package]] 6 | name = "anyhow" 7 | version = "1.0.68" 8 | source = "registry+https://github.com/rust-lang/crates.io-index" 9 | checksum = "2cb2f989d18dd141ab8ae82f64d1a8cdd37e0840f73a406896cf5e99502fab61" 10 | 11 | [[package]] 12 | name = "async-trait" 13 | version = "0.1.61" 14 | source = "registry+https://github.com/rust-lang/crates.io-index" 15 | checksum = "705339e0e4a9690e2908d2b3d049d85682cf19fbd5782494498fbf7003a6a282" 16 | dependencies = [ 17 | "proc-macro2", 18 | "quote", 19 | "syn", 20 | ] 21 | 22 | [[package]] 23 | name = "autocfg" 24 | version = "1.1.0" 25 | source = "registry+https://github.com/rust-lang/crates.io-index" 26 | checksum = "d468802bab17cbc0cc575e9b053f41e72aa36bfa6b7f55e3529ffa43161b97fa" 27 | 28 | [[package]] 29 | name = "base16ct" 30 | version = "0.1.1" 31 | source = "registry+https://github.com/rust-lang/crates.io-index" 32 | checksum = "349a06037c7bf932dd7e7d1f653678b2038b9ad46a74102f1fc7bd7872678cce" 33 | 34 | [[package]] 35 | name = "base64" 36 | version = "0.21.0" 37 | source = "registry+https://github.com/rust-lang/crates.io-index" 38 | checksum = "a4a4ddaa51a5bc52a6948f74c06d20aaaddb71924eab79b8c97a8c556e942d6a" 39 | 40 | [[package]] 41 | name = "base64ct" 42 | version = "1.5.3" 43 | source = "registry+https://github.com/rust-lang/crates.io-index" 44 | checksum = "b645a089122eccb6111b4f81cbc1a49f5900ac4666bb93ac027feaecf15607bf" 45 | 46 | [[package]] 47 | name = "binstring" 48 | version = "0.1.1" 49 | source = "registry+https://github.com/rust-lang/crates.io-index" 50 | checksum = "7e0d60973d9320722cb1206f412740e162a33b8547ea8d6be75d7cff237c7a85" 51 | 52 | [[package]] 53 | name = "bitflags" 54 | version = "1.3.2" 55 | source = "registry+https://github.com/rust-lang/crates.io-index" 56 | checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a" 57 | 58 | [[package]] 59 | name = "block-buffer" 60 | version = "0.10.3" 61 | source = "registry+https://github.com/rust-lang/crates.io-index" 62 | checksum = "69cce20737498f97b993470a6e536b8523f0af7892a4f928cceb1ac5e52ebe7e" 63 | dependencies = [ 64 | "generic-array", 65 | ] 66 | 67 | [[package]] 68 | name = "bumpalo" 69 | version = "3.12.0" 70 | source = "registry+https://github.com/rust-lang/crates.io-index" 71 | checksum = "0d261e256854913907f67ed06efbc3338dfe6179796deefc1ff763fc1aee5535" 72 | 73 | [[package]] 74 | name = "byteorder" 75 | version = "1.4.3" 76 | source = "registry+https://github.com/rust-lang/crates.io-index" 77 | checksum = "14c189c53d098945499cdfa7ecc63567cf3886b3332b312a5b4585d8d3a6a610" 78 | 79 | [[package]] 80 | name = "bytes" 81 | version = "1.3.0" 82 | source = "registry+https://github.com/rust-lang/crates.io-index" 83 | checksum = "dfb24e866b15a1af2a1b663f10c6b6b8f397a84aadb828f12e5b289ec23a3a3c" 84 | 85 | [[package]] 86 | name = "cfg-if" 87 | version = "1.0.0" 88 | source = "registry+https://github.com/rust-lang/crates.io-index" 89 | checksum = "baf1de4339761588bc0619e3cbc0120ee582ebb74b53b4efbf79117bd2da40fd" 90 | 91 | [[package]] 92 | name = "coarsetime" 93 | version = "0.1.22" 94 | source = "registry+https://github.com/rust-lang/crates.io-index" 95 | checksum = "454038500439e141804c655b4cd1bc6a70bcb95cd2bc9463af5661b6956f0e46" 96 | dependencies = [ 97 | "libc", 98 | "once_cell", 99 | "wasi", 100 | "wasm-bindgen", 101 | ] 102 | 103 | [[package]] 104 | name = "const-oid" 105 | version = "0.9.1" 106 | source = "registry+https://github.com/rust-lang/crates.io-index" 107 | checksum = "cec318a675afcb6a1ea1d4340e2d377e56e47c266f28043ceccbf4412ddfdd3b" 108 | 109 | [[package]] 110 | name = "cpufeatures" 111 | version = "0.2.5" 112 | source = "registry+https://github.com/rust-lang/crates.io-index" 113 | checksum = "28d997bd5e24a5928dd43e46dc529867e207907fe0b239c3477d924f7f2ca320" 114 | dependencies = [ 115 | "libc", 116 | ] 117 | 118 | [[package]] 119 | name = "crypto-bigint" 120 | version = "0.4.9" 121 | source = "registry+https://github.com/rust-lang/crates.io-index" 122 | checksum = "ef2b4b23cddf68b89b8f8069890e8c270d54e2d5fe1b143820234805e4cb17ef" 123 | dependencies = [ 124 | "generic-array", 125 | "rand_core", 126 | "subtle", 127 | "zeroize", 128 | ] 129 | 130 | [[package]] 131 | name = "crypto-common" 132 | version = "0.1.6" 133 | source = "registry+https://github.com/rust-lang/crates.io-index" 134 | checksum = "1bfb12502f3fc46cca1bb51ac28df9d618d813cdc3d2f25b9fe775a34af26bb3" 135 | dependencies = [ 136 | "generic-array", 137 | "typenum", 138 | ] 139 | 140 | [[package]] 141 | name = "ct-codecs" 142 | version = "1.1.1" 143 | source = "registry+https://github.com/rust-lang/crates.io-index" 144 | checksum = "f3b7eb4404b8195a9abb6356f4ac07d8ba267045c8d6d220ac4dc992e6cc75df" 145 | 146 | [[package]] 147 | name = "der" 148 | version = "0.6.1" 149 | source = "registry+https://github.com/rust-lang/crates.io-index" 150 | checksum = "f1a467a65c5e759bce6e65eaf91cc29f466cdc57cb65777bd646872a8a1fd4de" 151 | dependencies = [ 152 | "const-oid", 153 | "pem-rfc7468", 154 | "zeroize", 155 | ] 156 | 157 | [[package]] 158 | name = "digest" 159 | version = "0.10.6" 160 | source = "registry+https://github.com/rust-lang/crates.io-index" 161 | checksum = "8168378f4e5023e7218c89c891c0fd8ecdb5e5e4f18cb78f38cf245dd021e76f" 162 | dependencies = [ 163 | "block-buffer", 164 | "const-oid", 165 | "crypto-common", 166 | "subtle", 167 | ] 168 | 169 | [[package]] 170 | name = "ecdsa" 171 | version = "0.15.1" 172 | source = "registry+https://github.com/rust-lang/crates.io-index" 173 | checksum = "12844141594ad74185a926d030f3b605f6a903b4e3fec351f3ea338ac5b7637e" 174 | dependencies = [ 175 | "der", 176 | "elliptic-curve", 177 | "rfc6979", 178 | "signature 2.0.0", 179 | ] 180 | 181 | [[package]] 182 | name = "ed25519-compact" 183 | version = "2.0.4" 184 | source = "registry+https://github.com/rust-lang/crates.io-index" 185 | checksum = "6a3d382e8464107391c8706b4c14b087808ecb909f6c15c34114bc42e53a9e4c" 186 | dependencies = [ 187 | "ct-codecs", 188 | "getrandom", 189 | ] 190 | 191 | [[package]] 192 | name = "elliptic-curve" 193 | version = "0.12.3" 194 | source = "registry+https://github.com/rust-lang/crates.io-index" 195 | checksum = "e7bb888ab5300a19b8e5bceef25ac745ad065f3c9f7efc6de1b91958110891d3" 196 | dependencies = [ 197 | "base16ct", 198 | "crypto-bigint", 199 | "der", 200 | "digest", 201 | "ff", 202 | "generic-array", 203 | "group", 204 | "hkdf", 205 | "pem-rfc7468", 206 | "pkcs8", 207 | "rand_core", 208 | "sec1", 209 | "subtle", 210 | "zeroize", 211 | ] 212 | 213 | [[package]] 214 | name = "ff" 215 | version = "0.12.1" 216 | source = "registry+https://github.com/rust-lang/crates.io-index" 217 | checksum = "d013fc25338cc558c5c2cfbad646908fb23591e2404481826742b651c9af7160" 218 | dependencies = [ 219 | "rand_core", 220 | "subtle", 221 | ] 222 | 223 | [[package]] 224 | name = "fnv" 225 | version = "1.0.7" 226 | source = "registry+https://github.com/rust-lang/crates.io-index" 227 | checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1" 228 | 229 | [[package]] 230 | name = "form_urlencoded" 231 | version = "1.1.0" 232 | source = "registry+https://github.com/rust-lang/crates.io-index" 233 | checksum = "a9c384f161156f5260c24a097c56119f9be8c798586aecc13afbcbe7b7e26bf8" 234 | dependencies = [ 235 | "percent-encoding", 236 | ] 237 | 238 | [[package]] 239 | name = "generic-array" 240 | version = "0.14.6" 241 | source = "registry+https://github.com/rust-lang/crates.io-index" 242 | checksum = "bff49e947297f3312447abdca79f45f4738097cc82b06e72054d2223f601f1b9" 243 | dependencies = [ 244 | "typenum", 245 | "version_check", 246 | ] 247 | 248 | [[package]] 249 | name = "getrandom" 250 | version = "0.2.8" 251 | source = "registry+https://github.com/rust-lang/crates.io-index" 252 | checksum = "c05aeb6a22b8f62540c194aac980f2115af067bfe15a0734d7277a768d396b31" 253 | dependencies = [ 254 | "cfg-if", 255 | "libc", 256 | "wasi", 257 | ] 258 | 259 | [[package]] 260 | name = "group" 261 | version = "0.12.1" 262 | source = "registry+https://github.com/rust-lang/crates.io-index" 263 | checksum = "5dfbfb3a6cfbd390d5c9564ab283a0349b9b9fcd46a706c1eb10e0db70bfbac7" 264 | dependencies = [ 265 | "ff", 266 | "rand_core", 267 | "subtle", 268 | ] 269 | 270 | [[package]] 271 | name = "heck" 272 | version = "0.3.3" 273 | source = "registry+https://github.com/rust-lang/crates.io-index" 274 | checksum = "6d621efb26863f0e9924c6ac577e8275e5e6b77455db64ffa6c65c904e9e132c" 275 | dependencies = [ 276 | "unicode-segmentation", 277 | ] 278 | 279 | [[package]] 280 | name = "hkdf" 281 | version = "0.12.3" 282 | source = "registry+https://github.com/rust-lang/crates.io-index" 283 | checksum = "791a029f6b9fc27657f6f188ec6e5e43f6911f6f878e0dc5501396e09809d437" 284 | dependencies = [ 285 | "hmac", 286 | ] 287 | 288 | [[package]] 289 | name = "hmac" 290 | version = "0.12.1" 291 | source = "registry+https://github.com/rust-lang/crates.io-index" 292 | checksum = "6c49c37c09c17a53d937dfbb742eb3a961d65a994e6bcdcf37e7399d0cc8ab5e" 293 | dependencies = [ 294 | "digest", 295 | ] 296 | 297 | [[package]] 298 | name = "hmac-sha1-compact" 299 | version = "1.1.3" 300 | source = "registry+https://github.com/rust-lang/crates.io-index" 301 | checksum = "05e2440a0078e20c3b68ca01234cea4219f23e64b0c0bdb1200c5550d54239bb" 302 | 303 | [[package]] 304 | name = "hmac-sha256" 305 | version = "1.1.6" 306 | source = "registry+https://github.com/rust-lang/crates.io-index" 307 | checksum = "fc736091aacb31ddaa4cd5f6988b3c21e99913ac846b41f32538c5fae5d71bfe" 308 | dependencies = [ 309 | "digest", 310 | ] 311 | 312 | [[package]] 313 | name = "hmac-sha512" 314 | version = "1.1.4" 315 | source = "registry+https://github.com/rust-lang/crates.io-index" 316 | checksum = "520c9c3f6040661669bc5c91e551b605a520c8e0a63a766a91a65adef734d151" 317 | dependencies = [ 318 | "digest", 319 | ] 320 | 321 | [[package]] 322 | name = "http" 323 | version = "0.2.8" 324 | source = "registry+https://github.com/rust-lang/crates.io-index" 325 | checksum = "75f43d41e26995c17e71ee126451dd3941010b0514a81a9d11f3b341debc2399" 326 | dependencies = [ 327 | "bytes", 328 | "fnv", 329 | "itoa", 330 | ] 331 | 332 | [[package]] 333 | name = "id-arena" 334 | version = "2.2.1" 335 | source = "registry+https://github.com/rust-lang/crates.io-index" 336 | checksum = "25a2bc672d1148e28034f176e01fffebb08b35768468cc954630da77a1449005" 337 | 338 | [[package]] 339 | name = "itoa" 340 | version = "1.0.5" 341 | source = "registry+https://github.com/rust-lang/crates.io-index" 342 | checksum = "fad582f4b9e86b6caa621cabeb0963332d92eea04729ab12892c2533951e6440" 343 | 344 | [[package]] 345 | name = "jwt-simple" 346 | version = "0.11.3" 347 | source = "registry+https://github.com/rust-lang/crates.io-index" 348 | checksum = "21a4c8e544a27e20e2fe4b82a93a9e823f01ebcc1e4e135e839db66df0e7dc54" 349 | dependencies = [ 350 | "anyhow", 351 | "binstring", 352 | "coarsetime", 353 | "ct-codecs", 354 | "ed25519-compact", 355 | "hmac-sha1-compact", 356 | "hmac-sha256", 357 | "hmac-sha512", 358 | "k256", 359 | "p256", 360 | "p384", 361 | "rand", 362 | "rsa", 363 | "serde", 364 | "serde_json", 365 | "spki", 366 | "thiserror", 367 | "zeroize", 368 | ] 369 | 370 | [[package]] 371 | name = "k256" 372 | version = "0.12.0" 373 | source = "registry+https://github.com/rust-lang/crates.io-index" 374 | checksum = "92a55e0ff3b72c262bcf041d9e97f1b84492b68f1c1a384de2323d3dc9403397" 375 | dependencies = [ 376 | "cfg-if", 377 | "ecdsa", 378 | "elliptic-curve", 379 | "once_cell", 380 | "sha2", 381 | "signature 2.0.0", 382 | ] 383 | 384 | [[package]] 385 | name = "lazy_static" 386 | version = "1.4.0" 387 | source = "registry+https://github.com/rust-lang/crates.io-index" 388 | checksum = "e2abad23fbc42b3700f2f279844dc832adb2b2eb069b2df918f455c4e18cc646" 389 | dependencies = [ 390 | "spin", 391 | ] 392 | 393 | [[package]] 394 | name = "libc" 395 | version = "0.2.139" 396 | source = "registry+https://github.com/rust-lang/crates.io-index" 397 | checksum = "201de327520df007757c1f0adce6e827fe8562fbc28bfd9c15571c66ca1f5f79" 398 | 399 | [[package]] 400 | name = "libm" 401 | version = "0.2.6" 402 | source = "registry+https://github.com/rust-lang/crates.io-index" 403 | checksum = "348108ab3fba42ec82ff6e9564fc4ca0247bdccdc68dd8af9764bbc79c3c8ffb" 404 | 405 | [[package]] 406 | name = "log" 407 | version = "0.4.17" 408 | source = "registry+https://github.com/rust-lang/crates.io-index" 409 | checksum = "abb12e687cfb44aa40f41fc3978ef76448f9b6038cad6aef4259d3c095a2382e" 410 | dependencies = [ 411 | "cfg-if", 412 | ] 413 | 414 | [[package]] 415 | name = "memchr" 416 | version = "2.5.0" 417 | source = "registry+https://github.com/rust-lang/crates.io-index" 418 | checksum = "2dffe52ecf27772e601905b7522cb4ef790d2cc203488bbd0e2fe85fcb74566d" 419 | 420 | [[package]] 421 | name = "num-bigint-dig" 422 | version = "0.8.2" 423 | source = "registry+https://github.com/rust-lang/crates.io-index" 424 | checksum = "2399c9463abc5f909349d8aa9ba080e0b88b3ce2885389b60b993f39b1a56905" 425 | dependencies = [ 426 | "byteorder", 427 | "lazy_static", 428 | "libm", 429 | "num-integer", 430 | "num-iter", 431 | "num-traits", 432 | "rand", 433 | "smallvec", 434 | "zeroize", 435 | ] 436 | 437 | [[package]] 438 | name = "num-integer" 439 | version = "0.1.45" 440 | source = "registry+https://github.com/rust-lang/crates.io-index" 441 | checksum = "225d3389fb3509a24c93f5c29eb6bde2586b98d9f016636dff58d7c6f7569cd9" 442 | dependencies = [ 443 | "autocfg", 444 | "num-traits", 445 | ] 446 | 447 | [[package]] 448 | name = "num-iter" 449 | version = "0.1.43" 450 | source = "registry+https://github.com/rust-lang/crates.io-index" 451 | checksum = "7d03e6c028c5dc5cac6e2dec0efda81fc887605bb3d884578bb6d6bf7514e252" 452 | dependencies = [ 453 | "autocfg", 454 | "num-integer", 455 | "num-traits", 456 | ] 457 | 458 | [[package]] 459 | name = "num-traits" 460 | version = "0.2.15" 461 | source = "registry+https://github.com/rust-lang/crates.io-index" 462 | checksum = "578ede34cf02f8924ab9447f50c28075b4d3e5b269972345e7e0372b38c6cdcd" 463 | dependencies = [ 464 | "autocfg", 465 | "libm", 466 | ] 467 | 468 | [[package]] 469 | name = "once_cell" 470 | version = "1.17.0" 471 | source = "registry+https://github.com/rust-lang/crates.io-index" 472 | checksum = "6f61fba1741ea2b3d6a1e3178721804bb716a68a6aeba1149b5d52e3d464ea66" 473 | 474 | [[package]] 475 | name = "p256" 476 | version = "0.12.0" 477 | source = "registry+https://github.com/rust-lang/crates.io-index" 478 | checksum = "49c124b3cbce43bcbac68c58ec181d98ed6cc7e6d0aa7c3ba97b2563410b0e55" 479 | dependencies = [ 480 | "ecdsa", 481 | "elliptic-curve", 482 | "primeorder", 483 | "sha2", 484 | ] 485 | 486 | [[package]] 487 | name = "p384" 488 | version = "0.12.0" 489 | source = "registry+https://github.com/rust-lang/crates.io-index" 490 | checksum = "630a4a9b2618348ececfae61a4905f564b817063bf2d66cdfc2ced523fe1d2d4" 491 | dependencies = [ 492 | "ecdsa", 493 | "elliptic-curve", 494 | "primeorder", 495 | "sha2", 496 | ] 497 | 498 | [[package]] 499 | name = "pem-rfc7468" 500 | version = "0.6.0" 501 | source = "registry+https://github.com/rust-lang/crates.io-index" 502 | checksum = "24d159833a9105500e0398934e205e0773f0b27529557134ecfc51c27646adac" 503 | dependencies = [ 504 | "base64ct", 505 | ] 506 | 507 | [[package]] 508 | name = "percent-encoding" 509 | version = "2.2.0" 510 | source = "registry+https://github.com/rust-lang/crates.io-index" 511 | checksum = "478c572c3d73181ff3c2539045f6eb99e5491218eae919370993b890cdbdd98e" 512 | 513 | [[package]] 514 | name = "pkcs1" 515 | version = "0.4.1" 516 | source = "registry+https://github.com/rust-lang/crates.io-index" 517 | checksum = "eff33bdbdfc54cc98a2eca766ebdec3e1b8fb7387523d5c9c9a2891da856f719" 518 | dependencies = [ 519 | "der", 520 | "pkcs8", 521 | "spki", 522 | "zeroize", 523 | ] 524 | 525 | [[package]] 526 | name = "pkcs8" 527 | version = "0.9.0" 528 | source = "registry+https://github.com/rust-lang/crates.io-index" 529 | checksum = "9eca2c590a5f85da82668fa685c09ce2888b9430e83299debf1f34b65fd4a4ba" 530 | dependencies = [ 531 | "der", 532 | "spki", 533 | ] 534 | 535 | [[package]] 536 | name = "ppv-lite86" 537 | version = "0.2.17" 538 | source = "registry+https://github.com/rust-lang/crates.io-index" 539 | checksum = "5b40af805b3121feab8a3c29f04d8ad262fa8e0561883e7653e024ae4479e6de" 540 | 541 | [[package]] 542 | name = "primeorder" 543 | version = "0.12.1" 544 | source = "registry+https://github.com/rust-lang/crates.io-index" 545 | checksum = "0b54f7131b3dba65a2f414cf5bd25b66d4682e4608610668eae785750ba4c5b2" 546 | dependencies = [ 547 | "elliptic-curve", 548 | ] 549 | 550 | [[package]] 551 | name = "proc-macro2" 552 | version = "1.0.50" 553 | source = "registry+https://github.com/rust-lang/crates.io-index" 554 | checksum = "6ef7d57beacfaf2d8aee5937dab7b7f28de3cb8b1828479bb5de2a7106f2bae2" 555 | dependencies = [ 556 | "unicode-ident", 557 | ] 558 | 559 | [[package]] 560 | name = "profile-api" 561 | version = "0.1.0" 562 | dependencies = [ 563 | "anyhow", 564 | "base64", 565 | "bytes", 566 | "http", 567 | "jwt-simple", 568 | "serde", 569 | "serde_json", 570 | "spin-sdk", 571 | "wit-bindgen-rust", 572 | ] 573 | 574 | [[package]] 575 | name = "pulldown-cmark" 576 | version = "0.8.0" 577 | source = "registry+https://github.com/rust-lang/crates.io-index" 578 | checksum = "ffade02495f22453cd593159ea2f59827aae7f53fa8323f756799b670881dcf8" 579 | dependencies = [ 580 | "bitflags", 581 | "memchr", 582 | "unicase", 583 | ] 584 | 585 | [[package]] 586 | name = "quote" 587 | version = "1.0.23" 588 | source = "registry+https://github.com/rust-lang/crates.io-index" 589 | checksum = "8856d8364d252a14d474036ea1358d63c9e6965c8e5c1885c18f73d70bff9c7b" 590 | dependencies = [ 591 | "proc-macro2", 592 | ] 593 | 594 | [[package]] 595 | name = "rand" 596 | version = "0.8.5" 597 | source = "registry+https://github.com/rust-lang/crates.io-index" 598 | checksum = "34af8d1a0e25924bc5b7c43c079c942339d8f0a8b57c39049bef581b46327404" 599 | dependencies = [ 600 | "libc", 601 | "rand_chacha", 602 | "rand_core", 603 | ] 604 | 605 | [[package]] 606 | name = "rand_chacha" 607 | version = "0.3.1" 608 | source = "registry+https://github.com/rust-lang/crates.io-index" 609 | checksum = "e6c10a63a0fa32252be49d21e7709d4d4baf8d231c2dbce1eaa8141b9b127d88" 610 | dependencies = [ 611 | "ppv-lite86", 612 | "rand_core", 613 | ] 614 | 615 | [[package]] 616 | name = "rand_core" 617 | version = "0.6.4" 618 | source = "registry+https://github.com/rust-lang/crates.io-index" 619 | checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c" 620 | dependencies = [ 621 | "getrandom", 622 | ] 623 | 624 | [[package]] 625 | name = "rfc6979" 626 | version = "0.3.1" 627 | source = "registry+https://github.com/rust-lang/crates.io-index" 628 | checksum = "7743f17af12fa0b03b803ba12cd6a8d9483a587e89c69445e3909655c0b9fabb" 629 | dependencies = [ 630 | "crypto-bigint", 631 | "hmac", 632 | "zeroize", 633 | ] 634 | 635 | [[package]] 636 | name = "rsa" 637 | version = "0.7.2" 638 | source = "registry+https://github.com/rust-lang/crates.io-index" 639 | checksum = "094052d5470cbcef561cb848a7209968c9f12dfa6d668f4bca048ac5de51099c" 640 | dependencies = [ 641 | "byteorder", 642 | "digest", 643 | "num-bigint-dig", 644 | "num-integer", 645 | "num-iter", 646 | "num-traits", 647 | "pkcs1", 648 | "pkcs8", 649 | "rand_core", 650 | "signature 1.6.4", 651 | "smallvec", 652 | "subtle", 653 | "zeroize", 654 | ] 655 | 656 | [[package]] 657 | name = "ryu" 658 | version = "1.0.12" 659 | source = "registry+https://github.com/rust-lang/crates.io-index" 660 | checksum = "7b4b9743ed687d4b4bcedf9ff5eaa7398495ae14e61cba0a295704edbc7decde" 661 | 662 | [[package]] 663 | name = "sec1" 664 | version = "0.3.0" 665 | source = "registry+https://github.com/rust-lang/crates.io-index" 666 | checksum = "3be24c1842290c45df0a7bf069e0c268a747ad05a192f2fd7dcfdbc1cba40928" 667 | dependencies = [ 668 | "base16ct", 669 | "der", 670 | "generic-array", 671 | "pkcs8", 672 | "subtle", 673 | "zeroize", 674 | ] 675 | 676 | [[package]] 677 | name = "serde" 678 | version = "1.0.152" 679 | source = "registry+https://github.com/rust-lang/crates.io-index" 680 | checksum = "bb7d1f0d3021d347a83e556fc4683dea2ea09d87bccdf88ff5c12545d89d5efb" 681 | dependencies = [ 682 | "serde_derive", 683 | ] 684 | 685 | [[package]] 686 | name = "serde_derive" 687 | version = "1.0.152" 688 | source = "registry+https://github.com/rust-lang/crates.io-index" 689 | checksum = "af487d118eecd09402d70a5d72551860e788df87b464af30e5ea6a38c75c541e" 690 | dependencies = [ 691 | "proc-macro2", 692 | "quote", 693 | "syn", 694 | ] 695 | 696 | [[package]] 697 | name = "serde_json" 698 | version = "1.0.91" 699 | source = "registry+https://github.com/rust-lang/crates.io-index" 700 | checksum = "877c235533714907a8c2464236f5c4b2a17262ef1bd71f38f35ea592c8da6883" 701 | dependencies = [ 702 | "itoa", 703 | "ryu", 704 | "serde", 705 | ] 706 | 707 | [[package]] 708 | name = "sha2" 709 | version = "0.10.6" 710 | source = "registry+https://github.com/rust-lang/crates.io-index" 711 | checksum = "82e6b795fe2e3b1e845bafcb27aa35405c4d47cdfc92af5fc8d3002f76cebdc0" 712 | dependencies = [ 713 | "cfg-if", 714 | "cpufeatures", 715 | "digest", 716 | ] 717 | 718 | [[package]] 719 | name = "signature" 720 | version = "1.6.4" 721 | source = "registry+https://github.com/rust-lang/crates.io-index" 722 | checksum = "74233d3b3b2f6d4b006dc19dee745e73e2a6bfb6f93607cd3b02bd5b00797d7c" 723 | dependencies = [ 724 | "digest", 725 | "rand_core", 726 | ] 727 | 728 | [[package]] 729 | name = "signature" 730 | version = "2.0.0" 731 | source = "registry+https://github.com/rust-lang/crates.io-index" 732 | checksum = "8fe458c98333f9c8152221191a77e2a44e8325d0193484af2e9421a53019e57d" 733 | dependencies = [ 734 | "digest", 735 | "rand_core", 736 | ] 737 | 738 | [[package]] 739 | name = "smallvec" 740 | version = "1.10.0" 741 | source = "registry+https://github.com/rust-lang/crates.io-index" 742 | checksum = "a507befe795404456341dfab10cef66ead4c041f62b8b11bbb92bffe5d0953e0" 743 | 744 | [[package]] 745 | name = "spin" 746 | version = "0.5.2" 747 | source = "registry+https://github.com/rust-lang/crates.io-index" 748 | checksum = "6e63cff320ae2c57904679ba7cb63280a3dc4613885beafb148ee7bf9aa9042d" 749 | 750 | [[package]] 751 | name = "spin-macro" 752 | version = "0.1.0" 753 | source = "git+https://github.com/fermyon/spin?tag=v1.0.0#df99be238267b498451993d47b7e42e17da95c09" 754 | dependencies = [ 755 | "anyhow", 756 | "bytes", 757 | "http", 758 | "proc-macro2", 759 | "quote", 760 | "syn", 761 | "wit-bindgen-gen-core", 762 | "wit-bindgen-gen-rust-wasm", 763 | "wit-bindgen-rust", 764 | ] 765 | 766 | [[package]] 767 | name = "spin-sdk" 768 | version = "1.0.0" 769 | source = "git+https://github.com/fermyon/spin?tag=v1.0.0#df99be238267b498451993d47b7e42e17da95c09" 770 | dependencies = [ 771 | "anyhow", 772 | "bytes", 773 | "form_urlencoded", 774 | "http", 775 | "spin-macro", 776 | "thiserror", 777 | "wit-bindgen-rust", 778 | ] 779 | 780 | [[package]] 781 | name = "spki" 782 | version = "0.6.0" 783 | source = "registry+https://github.com/rust-lang/crates.io-index" 784 | checksum = "67cf02bbac7a337dc36e4f5a693db6c21e7863f45070f7064577eb4367a3212b" 785 | dependencies = [ 786 | "base64ct", 787 | "der", 788 | ] 789 | 790 | [[package]] 791 | name = "subtle" 792 | version = "2.4.1" 793 | source = "registry+https://github.com/rust-lang/crates.io-index" 794 | checksum = "6bdef32e8150c2a081110b42772ffe7d7c9032b606bc226c8260fd97e0976601" 795 | 796 | [[package]] 797 | name = "syn" 798 | version = "1.0.107" 799 | source = "registry+https://github.com/rust-lang/crates.io-index" 800 | checksum = "1f4064b5b16e03ae50984a5a8ed5d4f8803e6bc1fd170a3cda91a1be4b18e3f5" 801 | dependencies = [ 802 | "proc-macro2", 803 | "quote", 804 | "unicode-ident", 805 | ] 806 | 807 | [[package]] 808 | name = "thiserror" 809 | version = "1.0.38" 810 | source = "registry+https://github.com/rust-lang/crates.io-index" 811 | checksum = "6a9cd18aa97d5c45c6603caea1da6628790b37f7a34b6ca89522331c5180fed0" 812 | dependencies = [ 813 | "thiserror-impl", 814 | ] 815 | 816 | [[package]] 817 | name = "thiserror-impl" 818 | version = "1.0.38" 819 | source = "registry+https://github.com/rust-lang/crates.io-index" 820 | checksum = "1fb327af4685e4d03fa8cbcf1716380da910eeb2bb8be417e7f9fd3fb164f36f" 821 | dependencies = [ 822 | "proc-macro2", 823 | "quote", 824 | "syn", 825 | ] 826 | 827 | [[package]] 828 | name = "tinyvec" 829 | version = "1.6.0" 830 | source = "registry+https://github.com/rust-lang/crates.io-index" 831 | checksum = "87cc5ceb3875bb20c2890005a4e226a4651264a5c75edb2421b52861a0a0cb50" 832 | dependencies = [ 833 | "tinyvec_macros", 834 | ] 835 | 836 | [[package]] 837 | name = "tinyvec_macros" 838 | version = "0.1.0" 839 | source = "registry+https://github.com/rust-lang/crates.io-index" 840 | checksum = "cda74da7e1a664f795bb1f8a87ec406fb89a02522cf6e50620d016add6dbbf5c" 841 | 842 | [[package]] 843 | name = "typenum" 844 | version = "1.16.0" 845 | source = "registry+https://github.com/rust-lang/crates.io-index" 846 | checksum = "497961ef93d974e23eb6f433eb5fe1b7930b659f06d12dec6fc44a8f554c0bba" 847 | 848 | [[package]] 849 | name = "unicase" 850 | version = "2.6.0" 851 | source = "registry+https://github.com/rust-lang/crates.io-index" 852 | checksum = "50f37be617794602aabbeee0be4f259dc1778fabe05e2d67ee8f79326d5cb4f6" 853 | dependencies = [ 854 | "version_check", 855 | ] 856 | 857 | [[package]] 858 | name = "unicode-ident" 859 | version = "1.0.6" 860 | source = "registry+https://github.com/rust-lang/crates.io-index" 861 | checksum = "84a22b9f218b40614adcb3f4ff08b703773ad44fa9423e4e0d346d5db86e4ebc" 862 | 863 | [[package]] 864 | name = "unicode-normalization" 865 | version = "0.1.22" 866 | source = "registry+https://github.com/rust-lang/crates.io-index" 867 | checksum = "5c5713f0fc4b5db668a2ac63cdb7bb4469d8c9fed047b1d0292cc7b0ce2ba921" 868 | dependencies = [ 869 | "tinyvec", 870 | ] 871 | 872 | [[package]] 873 | name = "unicode-segmentation" 874 | version = "1.10.0" 875 | source = "registry+https://github.com/rust-lang/crates.io-index" 876 | checksum = "0fdbf052a0783de01e944a6ce7a8cb939e295b1e7be835a1112c3b9a7f047a5a" 877 | 878 | [[package]] 879 | name = "unicode-xid" 880 | version = "0.2.4" 881 | source = "registry+https://github.com/rust-lang/crates.io-index" 882 | checksum = "f962df74c8c05a667b5ee8bcf162993134c104e96440b663c8daa176dc772d8c" 883 | 884 | [[package]] 885 | name = "version_check" 886 | version = "0.9.4" 887 | source = "registry+https://github.com/rust-lang/crates.io-index" 888 | checksum = "49874b5167b65d7193b8aba1567f5c7d93d001cafc34600cee003eda787e483f" 889 | 890 | [[package]] 891 | name = "wasi" 892 | version = "0.11.0+wasi-snapshot-preview1" 893 | source = "registry+https://github.com/rust-lang/crates.io-index" 894 | checksum = "9c8d87e72b64a3b4db28d11ce29237c246188f4f51057d65a7eab63b7987e423" 895 | 896 | [[package]] 897 | name = "wasm-bindgen" 898 | version = "0.2.84" 899 | source = "registry+https://github.com/rust-lang/crates.io-index" 900 | checksum = "31f8dcbc21f30d9b8f2ea926ecb58f6b91192c17e9d33594b3df58b2007ca53b" 901 | dependencies = [ 902 | "cfg-if", 903 | "wasm-bindgen-macro", 904 | ] 905 | 906 | [[package]] 907 | name = "wasm-bindgen-backend" 908 | version = "0.2.84" 909 | source = "registry+https://github.com/rust-lang/crates.io-index" 910 | checksum = "95ce90fd5bcc06af55a641a86428ee4229e44e07033963a2290a8e241607ccb9" 911 | dependencies = [ 912 | "bumpalo", 913 | "log", 914 | "once_cell", 915 | "proc-macro2", 916 | "quote", 917 | "syn", 918 | "wasm-bindgen-shared", 919 | ] 920 | 921 | [[package]] 922 | name = "wasm-bindgen-macro" 923 | version = "0.2.84" 924 | source = "registry+https://github.com/rust-lang/crates.io-index" 925 | checksum = "4c21f77c0bedc37fd5dc21f897894a5ca01e7bb159884559461862ae90c0b4c5" 926 | dependencies = [ 927 | "quote", 928 | "wasm-bindgen-macro-support", 929 | ] 930 | 931 | [[package]] 932 | name = "wasm-bindgen-macro-support" 933 | version = "0.2.84" 934 | source = "registry+https://github.com/rust-lang/crates.io-index" 935 | checksum = "2aff81306fcac3c7515ad4e177f521b5c9a15f2b08f4e32d823066102f35a5f6" 936 | dependencies = [ 937 | "proc-macro2", 938 | "quote", 939 | "syn", 940 | "wasm-bindgen-backend", 941 | "wasm-bindgen-shared", 942 | ] 943 | 944 | [[package]] 945 | name = "wasm-bindgen-shared" 946 | version = "0.2.84" 947 | source = "registry+https://github.com/rust-lang/crates.io-index" 948 | checksum = "0046fef7e28c3804e5e38bfa31ea2a0f73905319b677e57ebe37e49358989b5d" 949 | 950 | [[package]] 951 | name = "wit-bindgen-gen-core" 952 | version = "0.2.0" 953 | source = "git+https://github.com/bytecodealliance/wit-bindgen?rev=cb871cfa1ee460b51eb1d144b175b9aab9c50aba#cb871cfa1ee460b51eb1d144b175b9aab9c50aba" 954 | dependencies = [ 955 | "anyhow", 956 | "wit-parser", 957 | ] 958 | 959 | [[package]] 960 | name = "wit-bindgen-gen-rust" 961 | version = "0.2.0" 962 | source = "git+https://github.com/bytecodealliance/wit-bindgen?rev=cb871cfa1ee460b51eb1d144b175b9aab9c50aba#cb871cfa1ee460b51eb1d144b175b9aab9c50aba" 963 | dependencies = [ 964 | "heck", 965 | "wit-bindgen-gen-core", 966 | ] 967 | 968 | [[package]] 969 | name = "wit-bindgen-gen-rust-wasm" 970 | version = "0.2.0" 971 | source = "git+https://github.com/bytecodealliance/wit-bindgen?rev=cb871cfa1ee460b51eb1d144b175b9aab9c50aba#cb871cfa1ee460b51eb1d144b175b9aab9c50aba" 972 | dependencies = [ 973 | "heck", 974 | "wit-bindgen-gen-core", 975 | "wit-bindgen-gen-rust", 976 | ] 977 | 978 | [[package]] 979 | name = "wit-bindgen-rust" 980 | version = "0.2.0" 981 | source = "git+https://github.com/bytecodealliance/wit-bindgen?rev=cb871cfa1ee460b51eb1d144b175b9aab9c50aba#cb871cfa1ee460b51eb1d144b175b9aab9c50aba" 982 | dependencies = [ 983 | "async-trait", 984 | "bitflags", 985 | "wit-bindgen-rust-impl", 986 | ] 987 | 988 | [[package]] 989 | name = "wit-bindgen-rust-impl" 990 | version = "0.2.0" 991 | source = "git+https://github.com/bytecodealliance/wit-bindgen?rev=cb871cfa1ee460b51eb1d144b175b9aab9c50aba#cb871cfa1ee460b51eb1d144b175b9aab9c50aba" 992 | dependencies = [ 993 | "proc-macro2", 994 | "syn", 995 | "wit-bindgen-gen-core", 996 | "wit-bindgen-gen-rust-wasm", 997 | ] 998 | 999 | [[package]] 1000 | name = "wit-parser" 1001 | version = "0.2.0" 1002 | source = "git+https://github.com/bytecodealliance/wit-bindgen?rev=cb871cfa1ee460b51eb1d144b175b9aab9c50aba#cb871cfa1ee460b51eb1d144b175b9aab9c50aba" 1003 | dependencies = [ 1004 | "anyhow", 1005 | "id-arena", 1006 | "pulldown-cmark", 1007 | "unicode-normalization", 1008 | "unicode-xid", 1009 | ] 1010 | 1011 | [[package]] 1012 | name = "zeroize" 1013 | version = "1.5.7" 1014 | source = "registry+https://github.com/rust-lang/crates.io-index" 1015 | checksum = "c394b5bd0c6f669e7275d9c20aa90ae064cb22e75a1cad54e1b34088034b149f" 1016 | -------------------------------------------------------------------------------- /api/profile/Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "profile-api" 3 | authors = ["Justin Pflueger "] 4 | description = "Profile REST API" 5 | version = "0.1.0" 6 | edition = "2021" 7 | 8 | [lib] 9 | crate-type = [ "cdylib" ] 10 | 11 | [dependencies] 12 | anyhow = "1" 13 | bytes = "1" 14 | http = "0.2" 15 | spin-sdk = { git = "https://github.com/fermyon/spin", tag = "v1.0.0" } 16 | wit-bindgen-rust = { git = "https://github.com/bytecodealliance/wit-bindgen", rev = "cb871cfa1ee460b51eb1d144b175b9aab9c50aba" } 17 | serde = { version = "1.0", features = ["derive"] } 18 | serde_json = "1.0" 19 | jwt-simple = "0.11.3" 20 | base64 = "0.21.0" 21 | 22 | [workspace] 23 | -------------------------------------------------------------------------------- /api/profile/src/auth.rs: -------------------------------------------------------------------------------- 1 | use anyhow::{bail, Result}; 2 | use base64::{alphabet, engine, Engine as _}; 3 | use jwt_simple::prelude::*; 4 | use serde::{Deserialize, Serialize}; 5 | use spin_sdk::outbound_http; 6 | 7 | // base64 decoding should support URL safe with no padding and allow trailing bits for JWT tokens 8 | const BASE64_CONFIG: engine::GeneralPurposeConfig = engine::GeneralPurposeConfig::new() 9 | .with_decode_allow_trailing_bits(true) 10 | .with_decode_padding_mode(engine::DecodePaddingMode::RequireNone); 11 | const BASE64_ENGINE: engine::GeneralPurpose = 12 | engine::GeneralPurpose::new(&alphabet::URL_SAFE, BASE64_CONFIG); 13 | 14 | #[derive(Serialize, Deserialize, Debug)] 15 | pub(crate) struct JsonWebKey { 16 | #[serde(rename = "alg")] 17 | algorithm: String, 18 | #[serde(rename = "kty")] 19 | key_type: String, 20 | #[serde(rename = "use")] 21 | public_key_use: String, 22 | #[serde(rename = "n")] 23 | modulus: String, 24 | #[serde(rename = "e")] 25 | exponent: String, 26 | #[serde(rename = "kid")] 27 | identifier: String, 28 | #[serde(rename = "x5t")] 29 | thumbprint: String, 30 | #[serde(rename = "x5c")] 31 | chain: Vec, 32 | } 33 | 34 | impl JsonWebKey { 35 | //TODO: cache the public key after it's been computed 36 | pub fn to_rsa256_public_key(self) -> Result { 37 | let n = BASE64_ENGINE.decode(self.modulus)?; 38 | let e = BASE64_ENGINE.decode(self.exponent)?; 39 | Ok(RS256PublicKey::from_components(&n, &e)?.with_key_id(self.identifier.as_str())) 40 | } 41 | } 42 | 43 | #[derive(Serialize, Deserialize, Debug)] 44 | pub(crate) struct JsonWebKeySet { 45 | keys: Vec, 46 | } 47 | 48 | impl JsonWebKeySet { 49 | pub fn get(url: String) -> Result { 50 | let res = outbound_http::send_request( 51 | http::Request::builder().method("GET").uri(url).body(None)?, 52 | )?; 53 | let res_body = match res.body().as_ref() { 54 | Some(bytes) => bytes.slice(..), 55 | None => bytes::Bytes::default(), 56 | }; 57 | Ok(serde_json::from_slice::(&res_body)?) 58 | } 59 | 60 | pub fn verify( 61 | self, 62 | token: &str, 63 | options: Option, 64 | ) -> Result> { 65 | for key in self.keys { 66 | let key = key.to_rsa256_public_key()?; 67 | 68 | // add a required key id verification to options 69 | let options = options.clone().map(|o| VerificationOptions { 70 | // ensure the token is validated by this key specifically 71 | required_key_id: key.key_id().to_owned(), 72 | ..o 73 | }); 74 | 75 | let claims = key.verify_token::(token, options); 76 | if claims.is_ok() { 77 | return claims; 78 | } 79 | } 80 | bail!("No key in the set was able to verify the token.") 81 | } 82 | } 83 | -------------------------------------------------------------------------------- /api/profile/src/config.rs: -------------------------------------------------------------------------------- 1 | use std::collections::HashSet; 2 | 3 | use jwt_simple::prelude::Duration; 4 | use spin_sdk::config::get; 5 | 6 | const KEY_DB_URL: &str = "db_url"; 7 | const KEY_AUTH_DOMAIN: &str = "auth_domain"; 8 | const KEY_AUTH_AUDIENCE: &str = "auth_audience"; 9 | const KEY_AUTH_MAX_VALIDITY_SECS: &str = "auth_max_validity_secs"; 10 | 11 | #[derive(Debug)] 12 | pub(crate) struct Config { 13 | pub db_url: String, 14 | pub auth_audiences: HashSet, 15 | pub auth_issuers: HashSet, 16 | pub auth_max_validity: Option, 17 | pub jwks_url: String, 18 | } 19 | 20 | impl Default for Config { 21 | fn default() -> Self { 22 | let db_url = get(KEY_DB_URL).expect("Missing config item 'db_url'"); 23 | let auth_domain = get(KEY_AUTH_DOMAIN).expect("Missing config item 'auth_domain'"); 24 | let auth_max_validity: Option = get(KEY_AUTH_MAX_VALIDITY_SECS) 25 | .ok() 26 | .map(|s| { 27 | s.parse::() 28 | .expect("Value provided must parse into an integer") 29 | }) 30 | .map(Duration::from_secs); 31 | 32 | let auth_audiences = HashSet::from([ 33 | get(KEY_AUTH_AUDIENCE).expect("Missing configuration item 'auth_audience'") 34 | ]); 35 | let auth_issuers = HashSet::from([format!("https://{0}/", auth_domain)]); 36 | let jwks_url = format!("https://{0}/.well-known/jwks.json", auth_domain); 37 | 38 | Self { 39 | db_url, 40 | auth_audiences, 41 | auth_issuers, 42 | auth_max_validity, 43 | jwks_url, 44 | } 45 | } 46 | } 47 | -------------------------------------------------------------------------------- /api/profile/src/lib.rs: -------------------------------------------------------------------------------- 1 | mod auth; 2 | mod config; 3 | mod model; 4 | 5 | use anyhow::{anyhow, Context, Result}; 6 | use bytes::Bytes; 7 | use config::Config; 8 | use jwt_simple::{ 9 | claims::{JWTClaims, NoCustomClaims}, 10 | prelude::VerificationOptions, 11 | }; 12 | use model::Profile; 13 | use spin_sdk::{ 14 | http::{Request, Response}, 15 | http_component, 16 | }; 17 | 18 | enum Api { 19 | Create(model::Profile), 20 | ReadById(String), 21 | Update(model::Profile), 22 | DeleteById(String), 23 | MethodNotAllowed, 24 | NotFound, 25 | } 26 | 27 | #[http_component] 28 | fn profile_api(req: Request) -> Result { 29 | let cfg = Config::default(); 30 | 31 | // parse the profile from the request 32 | let method = req.method(); 33 | let profile = match parse_profile(method, &req) { 34 | Ok(profile) => profile, 35 | Err(e) => return bad_request(e), 36 | }; 37 | 38 | // guard against unauthenticated requests 39 | let claims = match claims_from_request(&cfg, &req, &profile.id) { 40 | Ok(claims) if claims.subject.is_some() => claims, 41 | Ok(_) => return forbidden("Token is missing 'sub'.".to_string()), 42 | Err(e) => return forbidden(e.to_string()), 43 | }; 44 | 45 | // add the subject to the profile 46 | let profile = profile.with_id(claims.subject); 47 | 48 | // match api action to handler 49 | let result = match api_from_profile(method, profile) { 50 | Api::Create(profile) => handle_create(&cfg.db_url, profile), 51 | Api::Update(profile) => handle_update(&cfg.db_url, profile), 52 | Api::ReadById(id) => handle_read_by_id(&cfg.db_url, id), 53 | Api::DeleteById(id) => handle_delete_by_id(&cfg.db_url, id), 54 | Api::MethodNotAllowed => method_not_allowed(), 55 | Api::NotFound => not_found(), 56 | }; 57 | 58 | match result { 59 | Ok(response) => Ok(response), 60 | Err(e) => internal_server_error(e.to_string()), 61 | } 62 | } 63 | 64 | fn claims_from_request( 65 | cfg: &Config, 66 | req: &Request, 67 | subject: &Option, 68 | ) -> Result> { 69 | let keys = auth::JsonWebKeySet::get(cfg.jwks_url.to_owned()) 70 | .context(format!("Failed to retrieve JWKS from {:?}", cfg.jwks_url))?; 71 | 72 | let token = get_access_token(req.headers()).ok_or(anyhow!( 73 | "Failed to get access token from Authorization header" 74 | ))?; 75 | 76 | let options = VerificationOptions { 77 | max_validity: cfg.auth_max_validity, 78 | allowed_audiences: Some(cfg.auth_audiences.to_owned()), 79 | allowed_issuers: Some(cfg.auth_issuers.to_owned()), 80 | required_subject: subject.to_owned(), 81 | ..Default::default() 82 | }; 83 | 84 | let claims = keys 85 | .verify(token, Some(options)) 86 | .context("Failed to verify token")?; 87 | 88 | Ok(claims) 89 | } 90 | 91 | fn parse_profile(method: &http::Method, req: &Request) -> Result { 92 | // parse the data model from body or url 93 | let profile = match method { 94 | &http::Method::GET | &http::Method::DELETE => Profile::from_path(&req.headers()), 95 | &http::Method::PUT | &http::Method::POST => { 96 | Profile::from_bytes(req.body().as_ref().unwrap_or(&Bytes::new())) 97 | } 98 | _ => Err(anyhow!("Unsupported Http Method")), 99 | }?; 100 | Ok(profile) 101 | } 102 | 103 | fn api_from_profile(method: &http::Method, profile: Profile) -> Api { 104 | match (method, profile) { 105 | (&http::Method::POST, profile) => Api::Create(profile), 106 | (&http::Method::GET, profile) if profile.id.is_some() => Api::ReadById(profile.id.unwrap()), 107 | (&http::Method::GET, _) => Api::NotFound, 108 | (&http::Method::PUT, profile) => Api::Update(profile), 109 | (&http::Method::DELETE, profile) if profile.id.is_some() => { 110 | Api::DeleteById(profile.id.unwrap()) 111 | } 112 | (&http::Method::DELETE, _) => Api::NotFound, 113 | _ => Api::MethodNotAllowed, 114 | } 115 | } 116 | 117 | fn handle_create(db_url: &str, model: Profile) -> Result { 118 | model.insert(db_url)?; 119 | Ok(http::Response::builder() 120 | .status(http::StatusCode::CREATED) 121 | .header( 122 | http::header::LOCATION, 123 | format!("/api/profile/{}", model.handle), 124 | ) 125 | .body(None)?) 126 | } 127 | 128 | fn handle_read_by_id(db_url: &str, id: String) -> Result { 129 | match Profile::get_by_id(id.as_str(), &db_url) { 130 | Ok(model) => ok(serde_json::to_string(&model)?), 131 | Err(_) => not_found(), 132 | } 133 | } 134 | 135 | fn handle_update(db_url: &str, model: Profile) -> Result { 136 | model.update(&db_url)?; 137 | handle_read_by_id(&db_url, model.id.expect("Profile id is required")) 138 | } 139 | 140 | fn handle_delete_by_id(db_url: &str, id: String) -> Result { 141 | match Profile::delete_by_id(&id, &db_url) { 142 | Ok(_) => no_content(), 143 | Err(_) => internal_server_error(String::from("Error while deleting profile")), 144 | } 145 | } 146 | 147 | fn get_access_token(headers: &http::HeaderMap) -> Option<&str> { 148 | headers 149 | .get("Authorization")? 150 | .to_str() 151 | .unwrap() 152 | .strip_prefix("Bearer ") 153 | } 154 | 155 | fn internal_server_error(err: String) -> Result { 156 | Ok(http::Response::builder() 157 | .status(http::StatusCode::INTERNAL_SERVER_ERROR) 158 | .header(http::header::CONTENT_TYPE, "text/plain") 159 | .body(Some(err.into()))?) 160 | } 161 | 162 | fn ok(payload: String) -> Result { 163 | Ok(http::Response::builder() 164 | .status(http::StatusCode::OK) 165 | .header(http::header::CONTENT_TYPE, "application/json") 166 | .body(Some(payload.into()))?) 167 | } 168 | 169 | fn method_not_allowed() -> Result { 170 | quick_response(http::StatusCode::METHOD_NOT_ALLOWED) 171 | } 172 | 173 | fn bad_request(err: anyhow::Error) -> Result { 174 | Ok(http::Response::builder() 175 | .status(http::StatusCode::BAD_REQUEST) 176 | .body(Some(err.to_string().into()))?) 177 | } 178 | 179 | fn not_found() -> Result { 180 | quick_response(http::StatusCode::NOT_FOUND) 181 | } 182 | 183 | fn no_content() -> Result { 184 | quick_response(http::StatusCode::NO_CONTENT) 185 | } 186 | 187 | fn forbidden(reason: String) -> Result { 188 | Ok(http::Response::builder() 189 | .status(http::StatusCode::FORBIDDEN) 190 | .header(http::header::CONTENT_TYPE, "text/plain") 191 | .body(Some(reason.into()))?) 192 | } 193 | 194 | fn quick_response(s: http::StatusCode) -> Result { 195 | Ok(http::Response::builder().status(s).body(None)?) 196 | } 197 | -------------------------------------------------------------------------------- /api/profile/src/model.rs: -------------------------------------------------------------------------------- 1 | use std::collections::HashMap; 2 | 3 | use anyhow::{anyhow, Result}; 4 | use bytes::Bytes; 5 | use http::HeaderMap; 6 | use serde::{Deserialize, Serialize}; 7 | 8 | use spin_sdk::pg::{self as db, Column, Decode, ParameterValue, Row}; 9 | 10 | fn as_param<'a>(value: &'a Option) -> Option> { 11 | match value { 12 | Some(value) => Some(ParameterValue::Str(value.as_str())), 13 | None => None, 14 | } 15 | } 16 | 17 | fn as_nullable_param<'a>(value: &'a Option) -> ParameterValue<'a> { 18 | match as_param(value) { 19 | Some(value) => value, 20 | None => ParameterValue::DbNull, 21 | } 22 | } 23 | 24 | fn get_column_lookup<'a>(columns: &'a Vec) -> HashMap<&'a str, usize> { 25 | columns 26 | .iter() 27 | .enumerate() 28 | .map(|(i, c)| (c.name.as_str(), i)) 29 | .collect::>() 30 | } 31 | 32 | fn get_params_from_route(route: &str) -> Vec { 33 | route 34 | .split('/') 35 | .flat_map(|s| if s == "" { None } else { Some(s.to_string()) }) 36 | .collect::>() 37 | } 38 | 39 | fn get_last_param_from_route(route: &str) -> Option { 40 | get_params_from_route(route).last().cloned() 41 | } 42 | 43 | #[derive(Serialize, Deserialize, Debug)] 44 | pub(crate) struct Profile { 45 | pub id: Option, 46 | pub handle: String, 47 | pub avatar: Option, 48 | } 49 | 50 | impl Profile { 51 | pub(crate) fn from_path(headers: &HeaderMap) -> Result { 52 | let header = headers 53 | .get("spin-path-info") 54 | .ok_or(anyhow!("Error: Failed to discover path"))?; 55 | let path = header.to_str()?; 56 | match get_last_param_from_route(path) { 57 | Some(handle) => Ok(Profile { 58 | id: None, 59 | handle: handle, 60 | avatar: None, 61 | }), 62 | None => Err(anyhow!("Failed to parse handle from path")), 63 | } 64 | } 65 | 66 | pub(crate) fn from_bytes(b: &Bytes) -> Result { 67 | Ok(serde_json::from_slice(&b)?) 68 | } 69 | 70 | pub(crate) fn with_id(mut self, id: Option) -> Self { 71 | self.id = id; 72 | self 73 | } 74 | 75 | fn from_row(row: &Row, columns: &HashMap<&str, usize>) -> Result { 76 | let id = String::decode(&row[columns["id"]]).ok(); 77 | let handle = String::decode(&row[columns["handle"]])?; 78 | let avatar = String::decode(&row[columns["avatar"]]).ok(); 79 | Ok(Profile { id, handle, avatar }) 80 | } 81 | 82 | pub(crate) fn insert(&self, db_url: &str) -> Result<()> { 83 | let params = vec![ 84 | as_param(&self.id).ok_or(anyhow!("The id field is currently required for insert"))?, 85 | ParameterValue::Str(&self.handle), 86 | match as_param(&self.avatar) { 87 | Some(p) => p, 88 | None => ParameterValue::DbNull, 89 | }, 90 | ]; 91 | match db::execute( 92 | db_url, 93 | "INSERT INTO profiles (id, handle, avatar) VALUES ($1, $2, $3)", 94 | ¶ms, 95 | ) { 96 | Ok(_) => Ok(()), 97 | Err(e) => Err(anyhow!("Inserting profile failed: {:?}", e)), 98 | } 99 | } 100 | 101 | pub(crate) fn get_by_id(id: &str, db_url: &str) -> Result { 102 | let params = vec![ParameterValue::Str(id)]; 103 | let row_set = match db::query( 104 | db_url, 105 | "SELECT id, handle, avatar from profiles WHERE id = $1", 106 | ¶ms, 107 | ) { 108 | Ok(row_set) => row_set, 109 | Err(e) => return Err(anyhow!("Failed to get profile by id '{:?}': {:?}", id, e)), 110 | }; 111 | 112 | let columns = get_column_lookup(&row_set.columns); 113 | 114 | match row_set.rows.first() { 115 | Some(row) => Profile::from_row(row, &columns), 116 | None => Err(anyhow!("Profile not found for id '{:?}'", id)), 117 | } 118 | } 119 | 120 | pub(crate) fn update(&self, db_url: &str) -> Result<()> { 121 | let params = vec![ 122 | ParameterValue::Str(&self.handle), 123 | as_nullable_param(&self.avatar), 124 | as_param(&self.id).ok_or(anyhow!("The id field is currently required for update"))?, 125 | ]; 126 | match db::execute( 127 | db_url, 128 | "UPDATE profiles SET handle=$1, avatar=$2 WHERE id=$3", 129 | ¶ms, 130 | ) { 131 | Ok(_) => Ok(()), 132 | Err(e) => Err(anyhow!("Updating profile failed: {:?}", e)), 133 | } 134 | } 135 | 136 | pub(crate) fn delete_by_id(id: &str, db_url: &str) -> Result<()> { 137 | let params = vec![ParameterValue::Str(id)]; 138 | match db::execute(db_url, "DELETE FROM profiles WHERE id=$1", ¶ms) { 139 | Ok(_) => Ok(()), 140 | Err(e) => Err(anyhow!("Deleting profile failed: {:?}", e)), 141 | } 142 | } 143 | } 144 | -------------------------------------------------------------------------------- /modules/spin_static_fs.wasm: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/fermyon/code-things/23aa40fa1943d03c0c9dfb3a338c0b6471892bd5/modules/spin_static_fs.wasm -------------------------------------------------------------------------------- /scripts/create-profile.json: -------------------------------------------------------------------------------- 1 | { 2 | "id": "A4916AC2-AB0D-45A9-ADEA-959F8DEB2A14", 3 | "handle": "justin", 4 | "avatar": "https://avatars.githubusercontent.com/u/3060890?v=4" 5 | } -------------------------------------------------------------------------------- /scripts/db/initdb.d/1_create_table_profiles.sql: -------------------------------------------------------------------------------- 1 | CREATE TABLE profiles ( 2 | id varchar(64) NOT NULL, 3 | handle varchar(32) NOT NULL, 4 | avatar varchar(256), 5 | UNIQUE(handle), 6 | PRIMARY KEY (id) 7 | ); -------------------------------------------------------------------------------- /scripts/db/initdb.d/2_create_table_posts.sql: -------------------------------------------------------------------------------- 1 | CREATE TABLE posts ( 2 | id serial4 NOT NULL, 3 | author_id varchar(64) NOT NULL, 4 | "content" text NOT NULL, 5 | "type" varchar(64) NOT NULL, 6 | "data" text NOT NULL, 7 | visibility varchar(32) NOT NULL DEFAULT 'public', 8 | PRIMARY KEY (id), 9 | FOREIGN KEY (author_id) REFERENCES profiles(id) 10 | ); -------------------------------------------------------------------------------- /scripts/db/up.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | 3 | name=code-things-pg 4 | image=postgres 5 | username=code-things 6 | password=password 7 | dbname=code_things 8 | port=5432 9 | 10 | [[ $(docker ps -f "name=$name" --format '{{.Names}}') == $name ]] || docker run -d \ 11 | --name "$name" \ 12 | -p $port:$port \ 13 | -e POSTGRES_USER=$username \ 14 | -e POSTGRES_PASSWORD=$password \ 15 | -e POSTGRES_DB=$dbname \ 16 | -v "$(pwd)/scripts/db/data:/var/lib/postgresql/data" \ 17 | -v "$(pwd)/scripts/db/initdb.d:/docker-entrypoint-initdb.d" \ 18 | $image 19 | -------------------------------------------------------------------------------- /scripts/update-profile.json: -------------------------------------------------------------------------------- 1 | { 2 | "id": "A4916AC2-AB0D-45A9-ADEA-959F8DEB2A14", 3 | "handle": "justin", 4 | "avatar": "https://avatars.githubusercontent.com/u/3060890" 5 | } -------------------------------------------------------------------------------- /scripts/validate.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | 3 | scheme="http" 4 | host="127.0.0.1:3000" 5 | 6 | echo "Creating the profile" 7 | curl -v -X POST $scheme://$host/api/profile \ 8 | -H 'Content-Type: application/json' \ 9 | -d @scripts/create-profile.json 10 | echo "------------------------------------------------------------"; echo 11 | 12 | echo "Fetching the profile" 13 | curl -v -X GET $scheme://$host/api/profile/justin 14 | echo "------------------------------------------------------------"; echo 15 | 16 | echo "Updating the avatar" 17 | curl -v -X PUT $scheme://$host/api/profile/justin \ 18 | -H 'Content-Type: application/json' \ 19 | -d @scripts/update-profile.json 20 | echo "------------------------------------------------------------"; echo 21 | 22 | echo "Deleting profile" 23 | curl -v -X DELETE $scheme://$host/api/profile/justin 24 | echo "------------------------------------------------------------"; echo 25 | 26 | echo "Fetching after delete should be 404" 27 | curl -v -X GET $scheme://$host/api/profile/justin 28 | echo "------------------------------------------------------------"; echo 29 | -------------------------------------------------------------------------------- /spin.toml: -------------------------------------------------------------------------------- 1 | spin_version = "1" 2 | authors = ["Justin Pflueger "] 3 | description = "Social media app for code snippets" 4 | name = "code-things" 5 | trigger = { type = "http", base = "/" } 6 | version = "0.1.0" 7 | 8 | [variables] 9 | db_url = { default = "host=127.0.0.1 user=code-things password=password dbname=code_things" } 10 | auth_domain = { default = "dev-czhnnl8ikcojc040.us.auth0.com" } 11 | auth_audience = { default = "https://code-things.fermyon.app/api" } 12 | auth_max_validity_secs = { default = "86400" } 13 | 14 | [[component]] 15 | id = "web" 16 | source = "modules/spin_static_fs.wasm" 17 | environment = { FALLBACK_PATH = "index.html" } 18 | [[component.files]] 19 | source = "web/dist" 20 | destination = "/" 21 | [component.trigger] 22 | route = "/..." 23 | [component.build] 24 | command = "npm run build" 25 | workdir = "web" 26 | 27 | [[component]] 28 | id = "profile-api" 29 | source = "api/profile/target/wasm32-wasi/release/profile_api.wasm" 30 | allowed_http_hosts = [ "dev-czhnnl8ikcojc040.us.auth0.com", "code-things.us.auth0.com" ] 31 | key_value_stores = ["default"] 32 | [component.trigger] 33 | route = "/api/profile/..." 34 | [component.build] 35 | command = "cargo build --target wasm32-wasi --release" 36 | workdir = "api/profile" 37 | watch = ["api/profile/src/**/*.rs", "api/profile/Cargo.toml", "spin.toml"] 38 | [component.config] 39 | db_url = "{{ db_url }}" 40 | auth_domain = "{{ auth_domain }}" 41 | auth_audience = "{{ auth_audience }}" 42 | auth_max_validity_secs = "{{ auth_max_validity_secs }}" 43 | 44 | [[component]] 45 | id = "post" 46 | source = "api/post/main.wasm" 47 | allowed_http_hosts = [ "dev-czhnnl8ikcojc040.us.auth0.com", "code-things.us.auth0.com" ] 48 | key_value_stores = ["default"] 49 | [component.trigger] 50 | route = "/api/post/..." 51 | [component.build] 52 | command = "tinygo build -target=wasi -gc=leaking -no-debug -o main.wasm ." 53 | workdir = "./api/post" 54 | watch = ["api/post/*.go", "api/post/go.mod", "spin.toml"] 55 | [component.config] 56 | db_url = "{{ db_url }}" 57 | auth_domain = "{{ auth_domain }}" 58 | auth_audience = "{{ auth_audience }}" 59 | auth_max_validity_secs = "{{ auth_max_validity_secs }}" -------------------------------------------------------------------------------- /web/.eslintrc.cjs: -------------------------------------------------------------------------------- 1 | /* eslint-env node */ 2 | require("@rushstack/eslint-patch/modern-module-resolution"); 3 | 4 | module.exports = { 5 | root: true, 6 | extends: [ 7 | "plugin:vue/vue3-essential", 8 | "eslint:recommended", 9 | "@vue/eslint-config-typescript", 10 | "@vue/eslint-config-prettier", 11 | ], 12 | parserOptions: { 13 | ecmaVersion: "latest", 14 | }, 15 | }; 16 | -------------------------------------------------------------------------------- /web/.gitignore: -------------------------------------------------------------------------------- 1 | # Logs 2 | logs 3 | *.log 4 | npm-debug.log* 5 | yarn-debug.log* 6 | yarn-error.log* 7 | pnpm-debug.log* 8 | lerna-debug.log* 9 | 10 | node_modules 11 | .DS_Store 12 | dist 13 | dist-ssr 14 | coverage 15 | *.local 16 | 17 | /cypress/videos/ 18 | /cypress/screenshots/ 19 | 20 | # Editor directories and files 21 | .vscode/* 22 | !.vscode/extensions.json 23 | .idea 24 | *.suo 25 | *.ntvs* 26 | *.njsproj 27 | *.sln 28 | *.sw? 29 | 30 | test-results/ 31 | playwright-report/ 32 | -------------------------------------------------------------------------------- /web/.prettierrc.json: -------------------------------------------------------------------------------- 1 | {} -------------------------------------------------------------------------------- /web/README.md: -------------------------------------------------------------------------------- 1 | # web 2 | 3 | This template should help get you started developing with Vue 3 in Vite. 4 | 5 | ## Recommended IDE Setup 6 | 7 | [VSCode](https://code.visualstudio.com/) + [Volar](https://marketplace.visualstudio.com/items?itemName=Vue.volar) (and disable Vetur) + [TypeScript Vue Plugin (Volar)](https://marketplace.visualstudio.com/items?itemName=Vue.vscode-typescript-vue-plugin). 8 | 9 | ## Type Support for `.vue` Imports in TS 10 | 11 | TypeScript cannot handle type information for `.vue` imports by default, so we replace the `tsc` CLI with `vue-tsc` for type checking. In editors, we need [TypeScript Vue Plugin (Volar)](https://marketplace.visualstudio.com/items?itemName=Vue.vscode-typescript-vue-plugin) to make the TypeScript language service aware of `.vue` types. 12 | 13 | If the standalone TypeScript plugin doesn't feel fast enough to you, Volar has also implemented a [Take Over Mode](https://github.com/johnsoncodehk/volar/discussions/471#discussioncomment-1361669) that is more performant. You can enable it by the following steps: 14 | 15 | 1. Disable the built-in TypeScript Extension 16 | 1) Run `Extensions: Show Built-in Extensions` from VSCode's command palette 17 | 2) Find `TypeScript and JavaScript Language Features`, right click and select `Disable (Workspace)` 18 | 2. Reload the VSCode window by running `Developer: Reload Window` from the command palette. 19 | 20 | ## Customize configuration 21 | 22 | See [Vite Configuration Reference](https://vitejs.dev/config/). 23 | 24 | ## Project Setup 25 | 26 | ```sh 27 | npm install 28 | ``` 29 | 30 | ### Compile and Hot-Reload for Development 31 | 32 | ```sh 33 | npm run dev 34 | ``` 35 | 36 | ### Type-Check, Compile and Minify for Production 37 | 38 | ```sh 39 | npm run build 40 | ``` 41 | 42 | ### Run Unit Tests with [Vitest](https://vitest.dev/) 43 | 44 | ```sh 45 | npm run test:unit 46 | ``` 47 | 48 | ### Run End-to-End Tests with [Playwright](https://playwright.dev) 49 | 50 | ```sh 51 | # Install browsers for the first run 52 | npx playwright install 53 | 54 | # When testing on CI, must build the project first 55 | npm run build 56 | 57 | # Runs the end-to-end tests 58 | npm run test:e2e 59 | # Runs the tests only on Chromium 60 | npm run test:e2e -- --project=chromium 61 | # Runs the tests of a specific file 62 | npm run test:e2e -- tests/example.spec.ts 63 | # Runs the tests in debug mode 64 | npm run test:e2e -- --debug 65 | ``` 66 | 67 | ### Lint with [ESLint](https://eslint.org/) 68 | 69 | ```sh 70 | npm run lint 71 | ``` 72 | -------------------------------------------------------------------------------- /web/e2e/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "@vue/tsconfig/tsconfig.node.json", 3 | "include": ["./**/*"] 4 | } 5 | -------------------------------------------------------------------------------- /web/e2e/vue.spec.ts: -------------------------------------------------------------------------------- 1 | import { test, expect } from "@playwright/test"; 2 | 3 | // See here how to get started: 4 | // https://playwright.dev/docs/intro 5 | test("visits the app root url", async ({ page }) => { 6 | await page.goto("/"); 7 | await expect(page.locator("div.greetings > h1")).toHaveText("You did it!"); 8 | }); 9 | -------------------------------------------------------------------------------- /web/env.d.ts: -------------------------------------------------------------------------------- 1 | /// 2 | -------------------------------------------------------------------------------- /web/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | Code Things 8 | 9 | 10 | 11 | 12 | 13 |
14 | 15 | 16 | 17 | -------------------------------------------------------------------------------- /web/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "code-things-web", 3 | "version": "0.0.0", 4 | "private": true, 5 | "scripts": { 6 | "dev": "vite --open --host 127.0.0.1 --port 3000", 7 | "build": "run-p type-check build-only", 8 | "preview": "vite preview", 9 | "test:unit": "vitest --environment jsdom --root src/", 10 | "test:e2e": "playwright test", 11 | "build-only": "vite build", 12 | "type-check": "vue-tsc --noEmit -p tsconfig.vitest.json --composite false", 13 | "lint": "eslint . --ext .vue,.js,.jsx,.cjs,.mjs,.ts,.tsx,.cts,.mts --fix --ignore-path .gitignore" 14 | }, 15 | "dependencies": { 16 | "@auth0/auth0-vue": "^2.0.1", 17 | "@headlessui/vue": "^1.7.7", 18 | "@heroicons/vue": "^2.0.13", 19 | "pinia": "^2.0.28", 20 | "pinia-plugin-persistedstate": "^3.0.2", 21 | "vue": "^3.2.45", 22 | "vue-router": "^4.1.6" 23 | }, 24 | "devDependencies": { 25 | "@playwright/test": "^1.28.1", 26 | "@rushstack/eslint-patch": "^1.1.4", 27 | "@tailwindcss/forms": "^0.5.3", 28 | "@types/jsdom": "^20.0.1", 29 | "@types/node": "^18.11.12", 30 | "@vitejs/plugin-vue": "^4.0.0", 31 | "@vitejs/plugin-vue-jsx": "^3.0.0", 32 | "@vue/eslint-config-prettier": "^7.0.0", 33 | "@vue/eslint-config-typescript": "^11.0.0", 34 | "@vue/test-utils": "^2.2.6", 35 | "@vue/tsconfig": "^0.1.3", 36 | "autoprefixer": "^10.4.13", 37 | "eslint": "^8.22.0", 38 | "eslint-plugin-vue": "^9.3.0", 39 | "jsdom": "^20.0.3", 40 | "npm-run-all": "^4.1.5", 41 | "postcss": "^8.4.21", 42 | "prettier": "^2.7.1", 43 | "prettier-plugin-tailwindcss": "^0.2.2", 44 | "tailwindcss": "^3.2.4", 45 | "typescript": "~4.7.4", 46 | "vite": "^4.0.0", 47 | "vitest": "^0.25.6", 48 | "vue-tsc": "^1.0.12" 49 | } 50 | } 51 | -------------------------------------------------------------------------------- /web/playwright.config.ts: -------------------------------------------------------------------------------- 1 | import type { PlaywrightTestConfig } from "@playwright/test"; 2 | import { devices } from "@playwright/test"; 3 | 4 | /** 5 | * Read environment variables from file. 6 | * https://github.com/motdotla/dotenv 7 | */ 8 | // require('dotenv').config(); 9 | 10 | /** 11 | * See https://playwright.dev/docs/test-configuration. 12 | */ 13 | const config: PlaywrightTestConfig = { 14 | testDir: "./e2e", 15 | /* Maximum time one test can run for. */ 16 | timeout: 30 * 1000, 17 | expect: { 18 | /** 19 | * Maximum time expect() should wait for the condition to be met. 20 | * For example in `await expect(locator).toHaveText();` 21 | */ 22 | timeout: 5000, 23 | }, 24 | /* Fail the build on CI if you accidentally left test.only in the source code. */ 25 | forbidOnly: !!process.env.CI, 26 | /* Retry on CI only */ 27 | retries: process.env.CI ? 2 : 0, 28 | /* Opt out of parallel tests on CI. */ 29 | workers: process.env.CI ? 1 : undefined, 30 | /* Reporter to use. See https://playwright.dev/docs/test-reporters */ 31 | reporter: "html", 32 | /* Shared settings for all the projects below. See https://playwright.dev/docs/api/class-testoptions. */ 33 | use: { 34 | /* Maximum time each action such as `click()` can take. Defaults to 0 (no limit). */ 35 | actionTimeout: 0, 36 | /* Base URL to use in actions like `await page.goto('/')`. */ 37 | baseURL: "http://localhost:5173", 38 | 39 | /* Collect trace when retrying the failed test. See https://playwright.dev/docs/trace-viewer */ 40 | trace: "on-first-retry", 41 | 42 | /* Only on CI systems run the tests headless */ 43 | headless: !!process.env.CI, 44 | }, 45 | 46 | /* Configure projects for major browsers */ 47 | projects: [ 48 | { 49 | name: "chromium", 50 | use: { 51 | ...devices["Desktop Chrome"], 52 | }, 53 | }, 54 | { 55 | name: "firefox", 56 | use: { 57 | ...devices["Desktop Firefox"], 58 | }, 59 | }, 60 | { 61 | name: "webkit", 62 | use: { 63 | ...devices["Desktop Safari"], 64 | }, 65 | }, 66 | 67 | /* Test against mobile viewports. */ 68 | // { 69 | // name: 'Mobile Chrome', 70 | // use: { 71 | // ...devices['Pixel 5'], 72 | // }, 73 | // }, 74 | // { 75 | // name: 'Mobile Safari', 76 | // use: { 77 | // ...devices['iPhone 12'], 78 | // }, 79 | // }, 80 | 81 | /* Test against branded browsers. */ 82 | // { 83 | // name: 'Microsoft Edge', 84 | // use: { 85 | // channel: 'msedge', 86 | // }, 87 | // }, 88 | // { 89 | // name: 'Google Chrome', 90 | // use: { 91 | // channel: 'chrome', 92 | // }, 93 | // }, 94 | ], 95 | 96 | /* Folder for test artifacts such as screenshots, videos, traces, etc. */ 97 | // outputDir: 'test-results/', 98 | 99 | /* Run your local dev server before starting the tests */ 100 | webServer: { 101 | /** 102 | * Use the dev server by default for faster feedback loop. 103 | * Use the preview server on CI for more realistic testing. 104 | Playwright will re-use the local server if there is already a dev-server running. 105 | */ 106 | command: process.env.CI ? "vite preview --port 5173" : "vite dev", 107 | port: 5173, 108 | reuseExistingServer: !process.env.CI, 109 | }, 110 | }; 111 | 112 | export default config; 113 | -------------------------------------------------------------------------------- /web/postcss.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | plugins: [ 3 | require("postcss-import"), 4 | require("tailwindcss/nesting"), 5 | require("tailwindcss"), 6 | require("autoprefixer"), 7 | ], 8 | }; 9 | -------------------------------------------------------------------------------- /web/public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/fermyon/code-things/23aa40fa1943d03c0c9dfb3a338c0b6471892bd5/web/public/favicon.ico -------------------------------------------------------------------------------- /web/src/App.vue: -------------------------------------------------------------------------------- 1 | 34 | 35 | 58 | -------------------------------------------------------------------------------- /web/src/api/index.ts: -------------------------------------------------------------------------------- 1 | export interface Profile { 2 | id?: string; 3 | handle: string; 4 | avatar: string; 5 | } 6 | 7 | export const profile = { 8 | get: (token: string, id: string) => 9 | fetch(`/api/profile/${id}`, { 10 | headers: { 11 | Authorization: `Bearer ${token}`, 12 | }, 13 | }), 14 | create: (token: string, profile: Profile) => 15 | fetch("/api/profile", { 16 | method: "POST", 17 | headers: { 18 | Authorization: `Bearer ${token}`, 19 | }, 20 | body: JSON.stringify(profile), 21 | }), 22 | update: (token: string, profile: Profile) => 23 | fetch(`/api/profile`, { 24 | method: "PUT", 25 | headers: { 26 | Authorization: `Bearer ${token}`, 27 | }, 28 | body: JSON.stringify(profile), 29 | }), 30 | delete: (token: string, id: string) => 31 | fetch(`/api/profile/${id}`, { 32 | method: "DELETE", 33 | headers: { 34 | Authorization: `Bearer ${token}`, 35 | }, 36 | }), 37 | }; 38 | 39 | export interface Post { 40 | id?: number; 41 | author_id: string; 42 | content: string; 43 | type: string; 44 | data: string; 45 | visibility: string; 46 | } 47 | 48 | export const posts = { 49 | get: (token: string, id: string) => 50 | fetch(`/api/posts/${id}`, { 51 | headers: { 52 | Authorization: `Bearer ${token}`, 53 | }, 54 | }), 55 | list: (token: string, limit: number, offset: number) => 56 | fetch(`/api/posts?limit=${limit}&offset=${offset}`, { 57 | headers: { 58 | Authorization: `Bearer ${token}`, 59 | }, 60 | }), 61 | create: (token: string, post: Post) => 62 | fetch("/api/posts", { 63 | method: "POST", 64 | headers: { 65 | Authorization: `Bearer ${token}`, 66 | }, 67 | body: JSON.stringify(post), 68 | }), 69 | update: (token: string, post: Post) => 70 | fetch("/api/posts", { 71 | method: "PUT", 72 | headers: { 73 | Authorization: `Bearer ${token}`, 74 | }, 75 | body: JSON.stringify(post), 76 | }), 77 | delete: (token: string, id: string) => 78 | fetch(`/api/posts/${id}`, { 79 | method: "DELETE", 80 | headers: { 81 | Authorization: `Bearer ${token}`, 82 | }, 83 | }), 84 | }; 85 | -------------------------------------------------------------------------------- /web/src/assets/logo-black.svg: -------------------------------------------------------------------------------- 1 | 2 | 4 | 7 | 8 | 10 | 15 | 19 | 25 | 30 | 34 | 36 | 42 | 46 | 52 | 55 | 57 | 59 | 60 | 61 | -------------------------------------------------------------------------------- /web/src/assets/logo-white.svg: -------------------------------------------------------------------------------- 1 | 2 | 4 | 7 | 8 | 10 | 15 | 19 | 25 | 30 | 34 | 36 | 42 | 46 | 52 | 55 | 57 | 59 | 60 | 61 | -------------------------------------------------------------------------------- /web/src/assets/main.css: -------------------------------------------------------------------------------- 1 | @tailwind base; 2 | @layer base { 3 | html { 4 | font-family: Sen, Europa, Avenir, system, -apple-system, ".SFNSText-Regular", San Francisco, Segoe UI, Helvetica Neue, Lucida Grande, sans-serif; 5 | } 6 | } 7 | 8 | @tailwind components; 9 | @tailwind utilities; 10 | -------------------------------------------------------------------------------- /web/src/components/Avatar.vue: -------------------------------------------------------------------------------- 1 | 6 | 7 | -------------------------------------------------------------------------------- /web/src/components/CodePreview.vue: -------------------------------------------------------------------------------- 1 | 43 | 44 | 63 | -------------------------------------------------------------------------------- /web/src/components/DropdownMenu.vue: -------------------------------------------------------------------------------- 1 | 30 | 31 | 67 | -------------------------------------------------------------------------------- /web/src/components/UserProfileImage.vue: -------------------------------------------------------------------------------- 1 | 8 | 9 | -------------------------------------------------------------------------------- /web/src/components/nav/NavBar.vue: -------------------------------------------------------------------------------- 1 | 22 | -------------------------------------------------------------------------------- /web/src/components/nav/NavLink.vue: -------------------------------------------------------------------------------- 1 | 18 | 19 | 29 | -------------------------------------------------------------------------------- /web/src/components/nav/index.ts: -------------------------------------------------------------------------------- 1 | export { default as NavBar } from "./NavBar.vue"; 2 | export { default as NavLink } from "./NavLink.vue"; 3 | -------------------------------------------------------------------------------- /web/src/main.ts: -------------------------------------------------------------------------------- 1 | import { createApp } from "vue"; 2 | import { createPinia } from "pinia"; 3 | import { createAuth0 } from "@auth0/auth0-vue"; 4 | import piniaPluginPersistedState from 'pinia-plugin-persistedstate'; 5 | 6 | import "@/assets/main.css"; 7 | 8 | import App from "./App.vue"; 9 | import router from "./router"; 10 | 11 | const app = createApp(App); 12 | 13 | // setup application state 14 | const pinia = createPinia(); 15 | pinia.use(piniaPluginPersistedState) 16 | app.use(pinia); 17 | 18 | // router must come after store 19 | app.use(router); 20 | 21 | const auth0 = createAuth0({ 22 | domain: import.meta.env.VITE_AUTH0_DOMAIN, 23 | clientId: import.meta.env.VITE_AUTH0_CLIENT_ID, 24 | authorizationParams: { 25 | audience: import.meta.env.VITE_AUTH0_AUDIENCE, 26 | redirect_uri: window.location.origin, 27 | }, 28 | cacheLocation: "localstorage", 29 | }); 30 | app.use(auth0); 31 | 32 | app.mount("#app"); 33 | -------------------------------------------------------------------------------- /web/src/router/index.ts: -------------------------------------------------------------------------------- 1 | import { createRouter, createWebHistory } from "vue-router"; 2 | import HomeView from "../views/HomeView.vue"; 3 | 4 | const router = createRouter({ 5 | history: createWebHistory(import.meta.env.BASE_URL), 6 | routes: [ 7 | { 8 | path: "/", 9 | name: "home", 10 | component: HomeView, 11 | }, 12 | { 13 | path: "/profile", 14 | name: "profile", 15 | // route level code-splitting 16 | // this generates a separate chunk (About.[hash].js) for this route 17 | // which is lazy-loaded when the route is visited. 18 | component: () => import("../views/ProfileView.vue"), 19 | }, 20 | { 21 | path: "/posts/new", 22 | name: "posts-new", 23 | // route level code-splitting 24 | // this generates a separate chunk (About.[hash].js) for this route 25 | // which is lazy-loaded when the route is visited. 26 | component: () => import("../views/posts/NewPostView.vue"), 27 | }, 28 | { 29 | path: "/posts", 30 | name: "my-posts", 31 | // route level code-splitting 32 | // this generates a separate chunk (About.[hash].js) for this route 33 | // which is lazy-loaded when the route is visited. 34 | component: () => import("../views/posts/MyPostsView.vue"), 35 | }, 36 | ], 37 | }); 38 | 39 | export default router; 40 | -------------------------------------------------------------------------------- /web/src/stores/counter.ts: -------------------------------------------------------------------------------- 1 | import { ref, computed } from "vue"; 2 | import { defineStore } from "pinia"; 3 | 4 | export const useCounterStore = defineStore("counter", () => { 5 | const count = ref(0); 6 | const doubleCount = computed(() => count.value * 2); 7 | function increment() { 8 | count.value++; 9 | } 10 | 11 | return { count, doubleCount, increment }; 12 | }); 13 | -------------------------------------------------------------------------------- /web/src/utils/index.ts: -------------------------------------------------------------------------------- 1 | // regular expression to validate permalink with 2 | const permalinkRegex = 3 | /https:\/\/github\.com\/[a-zA-Z0-9-_\.]+\/[a-zA-Z0-9-_\.]+\/blob\/[a-z0-9]{40}(\/[a-zA-Z0-9-_\.]+)+#L[0-9]+-L[0-9]+/; 4 | 5 | // function to get the permalink 6 | export const getPermalinkPreview = async ( 7 | permalink: string 8 | ): Promise => { 9 | try { 10 | // test the input returning null if not valid 11 | if (!permalinkRegex.test(permalink)) { 12 | return null; 13 | } 14 | 15 | // parse the permalink 16 | const permalinkUrl = new URL(permalink); 17 | 18 | // get the range start/end from the hash 19 | const [rangeStart, rangeEnd] = permalinkUrl.hash 20 | .slice(1) // remove the '#' 21 | .split("-") // separate start/end 22 | .map((v) => parseInt(v.slice(1))); // remove the 'L' from start/end & parse as int 23 | permalinkUrl.hash = ""; 24 | 25 | // change the host from github.com to raw.githubusercontent.com 26 | permalinkUrl.host = "raw.githubusercontent.com"; 27 | 28 | // remove the /blob segment from the url 29 | permalinkUrl.pathname = permalinkUrl.pathname 30 | .split("/") 31 | .filter((part) => part != "blob") 32 | .join("/"); 33 | 34 | const response = await fetch(permalinkUrl); 35 | const contents = await response.text(); 36 | const contentRange = contents 37 | .split(/\r\n|\n|\r/) 38 | .slice(rangeStart - 1, rangeEnd) 39 | .join("\n"); 40 | return contentRange; 41 | } catch (e: any) { 42 | //TODO: better error handling 43 | console.error("Failed to fetch the code preview", e); 44 | return null; 45 | } 46 | }; 47 | -------------------------------------------------------------------------------- /web/src/views/HomeView.vue: -------------------------------------------------------------------------------- 1 | 4 | 5 | -------------------------------------------------------------------------------- /web/src/views/ProfileView.vue: -------------------------------------------------------------------------------- 1 | 55 | 56 | 125 | -------------------------------------------------------------------------------- /web/src/views/posts/MyPostsView.vue: -------------------------------------------------------------------------------- 1 | 78 | 79 | 103 | -------------------------------------------------------------------------------- /web/src/views/posts/NewPostView.vue: -------------------------------------------------------------------------------- 1 | 42 | 43 |