├── .dockerignore ├── .env.example ├── .gitignore ├── .sqlx ├── query-076e1562ebde1e5f0feb02eec26900d652b9a0dd3733072e035606bbf0738de0.json ├── query-114b5e103943f1ee25f492131841b3bec6df847d4bc1cb1c9e22c6d415238c3b.json ├── query-16193564215940ab3863850459f2f0b1601692d6ad83854fa43045d7483bba7b.json ├── query-1be3896565547150fcaca294a4415a9986c364d3e4706f209ef89d39870f275c.json ├── query-1e8a6cf21890127a071dab6e2cc9c7b5ea6b3c7724be5d3f40594abe2eb0da5c.json ├── query-509ab0ee98ae1e804a3ac6a78dd62bfd18c221049162b34953f16fb8bb98e9dd.json ├── query-5d537ebae9141f6dcd5c7fd397f4b8aee2737bd8754f1764171ec81c20ae5509.json ├── query-627e62b1a68eaad23ee97d98d39ea08b4a59f1cfb4986da1a981531cbe90a3f8.json ├── query-77396dceeed414ae8789f8ec409271110cac715ade1bd36921f2c8021c9db0d4.json ├── query-892337f9c289d3a54782f85af6f60eb2c346afb9ebfabb21b5f988b58328582f.json ├── query-9a498ed98a10f033ca1d41c2004d7fe7b9a9ebec821a5194c20ef552a529b02f.json ├── query-a3e3e0e4f80ae83ac4e775c9177a4e99eac041f5d734d44f329f5f243e5a41ca.json ├── query-a464fff5ad4fa7b7526d063c1d8681a2929e9fd657e4303b0af80a357d97fef9.json ├── query-b08930e76e94814ab091011fec4e351f845858e3b8631795eddea763eb932b5a.json ├── query-cfe53c0aa11dc6361eee6913819c73761ab0c8355b6b2b73ed840322e565cd08.json ├── query-d0bff2016f68fa8b5bb258c63b60b053afb8c02f530cf18fe4b8da2c0c7956bc.json ├── query-d995d93fce28f1b120ae95fba1b8cc338a2771f19fd555ccbbf4eb0f4ea08ae7.json ├── query-dafe0ca4b92e7ee20a60d8b1da46d647b2368dffc2778a6c8136d6bb23011cb3.json ├── query-def266669f865c31c9aedc3c441c851ad5dce104d9e8ad3866266fecb7540520.json ├── query-ee1de2c8f0a9e4c47be3bc5ad3df0d45c95b12a84244eaeae6f09975e9d4f80c.json ├── query-f413bc30f880e4bf725a5cec7d35cd1f39689f56caf0c79c85caa39096754bf8.json ├── query-f4be6a4f478a95d56fec6627d6dcb034b74386c56145aab74c661d36805e004f.json └── query-f5ec9c2e3b68c2e6c42fc6ce8b71c67c47e82587575777b197919a9843aa7bb5.json ├── .vscode └── settings.json ├── Cargo.lock ├── Cargo.toml ├── Dockerfile ├── LICENSE ├── README.md ├── assets └── favicon.ico ├── docker-compose.yml ├── end2end ├── package-lock.json ├── package.json ├── playwright.config.ts └── tests │ └── example.spec.ts ├── js └── utils.js ├── migrations ├── 20221207194615_init.down.sql └── 20221207194615_init.up.sql ├── sqlx-data.json ├── src ├── app.rs ├── auth │ ├── api.rs │ ├── mod.rs │ └── server.rs ├── components │ ├── article_preview.rs │ ├── buttons.rs │ ├── mod.rs │ └── navitems.rs ├── database.rs ├── lib.rs ├── main.rs ├── models │ ├── article.rs │ ├── comment.rs │ ├── mod.rs │ ├── pagination.rs │ └── user.rs ├── routes │ ├── article.rs │ ├── editor.rs │ ├── home.rs │ ├── login.rs │ ├── mod.rs │ ├── profile.rs │ ├── reset_password.rs │ ├── settings.rs │ └── signup.rs └── setup.rs └── style └── main.scss /.dockerignore: -------------------------------------------------------------------------------- 1 | # Generated by Cargo 2 | # will have compiled files and executables 3 | /target/ 4 | pkg 5 | 6 | # These are backup files generated by rustfmt 7 | **/*.rs.bk 8 | 9 | # node e2e test tools and outputs 10 | node_modules/ 11 | test-results/ 12 | end2end/playwright-report/ 13 | playwright/.cache/ 14 | -------------------------------------------------------------------------------- /.env.example: -------------------------------------------------------------------------------- 1 | export DATABASE_URL="postgres://postgres:postgres@localhost/postgres" 2 | export JWT_SECRET="hello darkness my old friend" 3 | export MAILER_EMAIL="m@example.com" 4 | export MAILER_PASSWD="yourpassword" 5 | export MAILER_SMTP_SERVER="smtp-mail.outlook.com" 6 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Generated by Cargo 2 | # will have compiled files and executables 3 | /target/ 4 | pkg 5 | 6 | # These are backup files generated by rustfmt 7 | **/*.rs.bk 8 | 9 | # node e2e test tools and outputs 10 | node_modules/ 11 | test-results/ 12 | end2end/playwright-report/ 13 | playwright/.cache/ 14 | 15 | .env -------------------------------------------------------------------------------- /.sqlx/query-076e1562ebde1e5f0feb02eec26900d652b9a0dd3733072e035606bbf0738de0.json: -------------------------------------------------------------------------------- 1 | { 2 | "db_name": "PostgreSQL", 3 | "query": "DELETE FROM Comments WHERE id=$1 and username=$2", 4 | "describe": { 5 | "columns": [], 6 | "parameters": { 7 | "Left": [ 8 | "Int4", 9 | "Text" 10 | ] 11 | }, 12 | "nullable": [] 13 | }, 14 | "hash": "076e1562ebde1e5f0feb02eec26900d652b9a0dd3733072e035606bbf0738de0" 15 | } 16 | -------------------------------------------------------------------------------- /.sqlx/query-114b5e103943f1ee25f492131841b3bec6df847d4bc1cb1c9e22c6d415238c3b.json: -------------------------------------------------------------------------------- 1 | { 2 | "db_name": "PostgreSQL", 3 | "query": "SELECT * FROM FavArticles WHERE article=$1 and username=$2", 4 | "describe": { 5 | "columns": [ 6 | { 7 | "ordinal": 0, 8 | "name": "article", 9 | "type_info": "Text" 10 | }, 11 | { 12 | "ordinal": 1, 13 | "name": "username", 14 | "type_info": "Text" 15 | } 16 | ], 17 | "parameters": { 18 | "Left": [ 19 | "Text", 20 | "Text" 21 | ] 22 | }, 23 | "nullable": [ 24 | false, 25 | false 26 | ] 27 | }, 28 | "hash": "114b5e103943f1ee25f492131841b3bec6df847d4bc1cb1c9e22c6d415238c3b" 29 | } 30 | -------------------------------------------------------------------------------- /.sqlx/query-16193564215940ab3863850459f2f0b1601692d6ad83854fa43045d7483bba7b.json: -------------------------------------------------------------------------------- 1 | { 2 | "db_name": "PostgreSQL", 3 | "query": "DELETE FROM ArticleTags WHERE article=$1", 4 | "describe": { 5 | "columns": [], 6 | "parameters": { 7 | "Left": [ 8 | "Text" 9 | ] 10 | }, 11 | "nullable": [] 12 | }, 13 | "hash": "16193564215940ab3863850459f2f0b1601692d6ad83854fa43045d7483bba7b" 14 | } 15 | -------------------------------------------------------------------------------- /.sqlx/query-1be3896565547150fcaca294a4415a9986c364d3e4706f209ef89d39870f275c.json: -------------------------------------------------------------------------------- 1 | { 2 | "db_name": "PostgreSQL", 3 | "query": "\n SELECT c.*, u.image FROM Comments as c\n JOIN Users as u ON u.username=c.username\n WHERE c.article=$1\n ORDER BY c.created_at", 4 | "describe": { 5 | "columns": [ 6 | { 7 | "ordinal": 0, 8 | "name": "id", 9 | "type_info": "Int4" 10 | }, 11 | { 12 | "ordinal": 1, 13 | "name": "article", 14 | "type_info": "Text" 15 | }, 16 | { 17 | "ordinal": 2, 18 | "name": "username", 19 | "type_info": "Text" 20 | }, 21 | { 22 | "ordinal": 3, 23 | "name": "body", 24 | "type_info": "Text" 25 | }, 26 | { 27 | "ordinal": 4, 28 | "name": "created_at", 29 | "type_info": "Timestamptz" 30 | }, 31 | { 32 | "ordinal": 5, 33 | "name": "image", 34 | "type_info": "Text" 35 | } 36 | ], 37 | "parameters": { 38 | "Left": [ 39 | "Text" 40 | ] 41 | }, 42 | "nullable": [ 43 | false, 44 | false, 45 | false, 46 | false, 47 | false, 48 | true 49 | ] 50 | }, 51 | "hash": "1be3896565547150fcaca294a4415a9986c364d3e4706f209ef89d39870f275c" 52 | } 53 | -------------------------------------------------------------------------------- /.sqlx/query-1e8a6cf21890127a071dab6e2cc9c7b5ea6b3c7724be5d3f40594abe2eb0da5c.json: -------------------------------------------------------------------------------- 1 | { 2 | "db_name": "PostgreSQL", 3 | "query": "\nSELECT \n a.slug,\n a.title,\n a.description,\n a.created_at,\n (SELECT COUNT(*) FROM FavArticles WHERE article=a.slug) as favorites_count,\n u.username, u.image,\n EXISTS(SELECT 1 FROM FavArticles WHERE article=a.slug and username=$5) as fav,\n EXISTS(SELECT 1 FROM Follows WHERE follower=$5 and influencer=u.username) as following,\n (SELECT string_agg(tag, ' ') FROM ArticleTags WHERE article = a.slug) as tag_list\nFROM Articles as a\n JOIN Users as u ON a.author = u.username\nWHERE\n CASE WHEN $3!='' THEN a.slug in (SELECT distinct article FROM ArticleTags WHERE tag=$3)\n ELSE 1=1\n END\n AND\n CASE WHEN $4 THEN u.username in (SELECT influencer FROM Follows WHERE follower=$5)\n ELSE 1=1\n END\nORDER BY a.created_at desc\nLIMIT $1 OFFSET $2", 4 | "describe": { 5 | "columns": [ 6 | { 7 | "ordinal": 0, 8 | "name": "slug", 9 | "type_info": "Text" 10 | }, 11 | { 12 | "ordinal": 1, 13 | "name": "title", 14 | "type_info": "Text" 15 | }, 16 | { 17 | "ordinal": 2, 18 | "name": "description", 19 | "type_info": "Text" 20 | }, 21 | { 22 | "ordinal": 3, 23 | "name": "created_at", 24 | "type_info": "Timestamptz" 25 | }, 26 | { 27 | "ordinal": 4, 28 | "name": "favorites_count", 29 | "type_info": "Int8" 30 | }, 31 | { 32 | "ordinal": 5, 33 | "name": "username", 34 | "type_info": "Text" 35 | }, 36 | { 37 | "ordinal": 6, 38 | "name": "image", 39 | "type_info": "Text" 40 | }, 41 | { 42 | "ordinal": 7, 43 | "name": "fav", 44 | "type_info": "Bool" 45 | }, 46 | { 47 | "ordinal": 8, 48 | "name": "following", 49 | "type_info": "Bool" 50 | }, 51 | { 52 | "ordinal": 9, 53 | "name": "tag_list", 54 | "type_info": "Text" 55 | } 56 | ], 57 | "parameters": { 58 | "Left": [ 59 | "Int8", 60 | "Int8", 61 | "Text", 62 | "Bool", 63 | "Text" 64 | ] 65 | }, 66 | "nullable": [ 67 | false, 68 | false, 69 | false, 70 | false, 71 | null, 72 | false, 73 | true, 74 | null, 75 | null, 76 | null 77 | ] 78 | }, 79 | "hash": "1e8a6cf21890127a071dab6e2cc9c7b5ea6b3c7724be5d3f40594abe2eb0da5c" 80 | } 81 | -------------------------------------------------------------------------------- /.sqlx/query-509ab0ee98ae1e804a3ac6a78dd62bfd18c221049162b34953f16fb8bb98e9dd.json: -------------------------------------------------------------------------------- 1 | { 2 | "db_name": "PostgreSQL", 3 | "query": "\n SELECT\n a.*,\n (SELECT string_agg(tag, ' ') FROM ArticleTags WHERE article = a.slug) as tag_list,\n (SELECT COUNT(*) FROM FavArticles WHERE article = a.slug) as fav_count,\n u.*,\n EXISTS(SELECT 1 FROM FavArticles WHERE article=a.slug and username=$2) as fav,\n EXISTS(SELECT 1 FROM Follows WHERE follower=$2 and influencer=a.author) as following\n FROM Articles a\n JOIN Users u ON a.author = u.username\n WHERE slug = $1\n ", 4 | "describe": { 5 | "columns": [ 6 | { 7 | "ordinal": 0, 8 | "name": "slug", 9 | "type_info": "Text" 10 | }, 11 | { 12 | "ordinal": 1, 13 | "name": "author", 14 | "type_info": "Text" 15 | }, 16 | { 17 | "ordinal": 2, 18 | "name": "title", 19 | "type_info": "Text" 20 | }, 21 | { 22 | "ordinal": 3, 23 | "name": "description", 24 | "type_info": "Text" 25 | }, 26 | { 27 | "ordinal": 4, 28 | "name": "body", 29 | "type_info": "Text" 30 | }, 31 | { 32 | "ordinal": 5, 33 | "name": "created_at", 34 | "type_info": "Timestamptz" 35 | }, 36 | { 37 | "ordinal": 6, 38 | "name": "updated_at", 39 | "type_info": "Timestamptz" 40 | }, 41 | { 42 | "ordinal": 7, 43 | "name": "tag_list", 44 | "type_info": "Text" 45 | }, 46 | { 47 | "ordinal": 8, 48 | "name": "fav_count", 49 | "type_info": "Int8" 50 | }, 51 | { 52 | "ordinal": 9, 53 | "name": "username", 54 | "type_info": "Text" 55 | }, 56 | { 57 | "ordinal": 10, 58 | "name": "email", 59 | "type_info": "Text" 60 | }, 61 | { 62 | "ordinal": 11, 63 | "name": "password", 64 | "type_info": "Text" 65 | }, 66 | { 67 | "ordinal": 12, 68 | "name": "bio", 69 | "type_info": "Text" 70 | }, 71 | { 72 | "ordinal": 13, 73 | "name": "image", 74 | "type_info": "Text" 75 | }, 76 | { 77 | "ordinal": 14, 78 | "name": "fav", 79 | "type_info": "Bool" 80 | }, 81 | { 82 | "ordinal": 15, 83 | "name": "following", 84 | "type_info": "Bool" 85 | } 86 | ], 87 | "parameters": { 88 | "Left": [ 89 | "Text", 90 | "Text" 91 | ] 92 | }, 93 | "nullable": [ 94 | false, 95 | false, 96 | false, 97 | false, 98 | false, 99 | false, 100 | false, 101 | null, 102 | null, 103 | false, 104 | false, 105 | false, 106 | true, 107 | true, 108 | null, 109 | null 110 | ] 111 | }, 112 | "hash": "509ab0ee98ae1e804a3ac6a78dd62bfd18c221049162b34953f16fb8bb98e9dd" 113 | } 114 | -------------------------------------------------------------------------------- /.sqlx/query-5d537ebae9141f6dcd5c7fd397f4b8aee2737bd8754f1764171ec81c20ae5509.json: -------------------------------------------------------------------------------- 1 | { 2 | "db_name": "PostgreSQL", 3 | "query": "SELECT * FROM Follows WHERE follower=$1 and influencer=$2", 4 | "describe": { 5 | "columns": [ 6 | { 7 | "ordinal": 0, 8 | "name": "follower", 9 | "type_info": "Text" 10 | }, 11 | { 12 | "ordinal": 1, 13 | "name": "influencer", 14 | "type_info": "Text" 15 | } 16 | ], 17 | "parameters": { 18 | "Left": [ 19 | "Text", 20 | "Text" 21 | ] 22 | }, 23 | "nullable": [ 24 | false, 25 | false 26 | ] 27 | }, 28 | "hash": "5d537ebae9141f6dcd5c7fd397f4b8aee2737bd8754f1764171ec81c20ae5509" 29 | } 30 | -------------------------------------------------------------------------------- /.sqlx/query-627e62b1a68eaad23ee97d98d39ea08b4a59f1cfb4986da1a981531cbe90a3f8.json: -------------------------------------------------------------------------------- 1 | { 2 | "db_name": "PostgreSQL", 3 | "query": "INSERT INTO Follows(follower, influencer) VALUES ($1, $2)", 4 | "describe": { 5 | "columns": [], 6 | "parameters": { 7 | "Left": [ 8 | "Text", 9 | "Text" 10 | ] 11 | }, 12 | "nullable": [] 13 | }, 14 | "hash": "627e62b1a68eaad23ee97d98d39ea08b4a59f1cfb4986da1a981531cbe90a3f8" 15 | } 16 | -------------------------------------------------------------------------------- /.sqlx/query-77396dceeed414ae8789f8ec409271110cac715ade1bd36921f2c8021c9db0d4.json: -------------------------------------------------------------------------------- 1 | { 2 | "db_name": "PostgreSQL", 3 | "query": "\nUPDATE Users SET\n image=$2,\n bio=$3,\n email=$4,\n password=CASE WHEN $5 THEN crypt($6, gen_salt('bf')) ELSE password END\nWHERE username=$1", 4 | "describe": { 5 | "columns": [], 6 | "parameters": { 7 | "Left": [ 8 | "Text", 9 | "Text", 10 | "Text", 11 | "Text", 12 | "Bool", 13 | "Text" 14 | ] 15 | }, 16 | "nullable": [] 17 | }, 18 | "hash": "77396dceeed414ae8789f8ec409271110cac715ade1bd36921f2c8021c9db0d4" 19 | } 20 | -------------------------------------------------------------------------------- /.sqlx/query-892337f9c289d3a54782f85af6f60eb2c346afb9ebfabb21b5f988b58328582f.json: -------------------------------------------------------------------------------- 1 | { 2 | "db_name": "PostgreSQL", 3 | "query": "INSERT INTO FavArticles(article, username) VALUES ($1, $2)", 4 | "describe": { 5 | "columns": [], 6 | "parameters": { 7 | "Left": [ 8 | "Text", 9 | "Text" 10 | ] 11 | }, 12 | "nullable": [] 13 | }, 14 | "hash": "892337f9c289d3a54782f85af6f60eb2c346afb9ebfabb21b5f988b58328582f" 15 | } 16 | -------------------------------------------------------------------------------- /.sqlx/query-9a498ed98a10f033ca1d41c2004d7fe7b9a9ebec821a5194c20ef552a529b02f.json: -------------------------------------------------------------------------------- 1 | { 2 | "db_name": "PostgreSQL", 3 | "query": "SELECT username FROM Users where username=$1 and password=crypt($2, password)", 4 | "describe": { 5 | "columns": [ 6 | { 7 | "ordinal": 0, 8 | "name": "username", 9 | "type_info": "Text" 10 | } 11 | ], 12 | "parameters": { 13 | "Left": [ 14 | "Text", 15 | "Text" 16 | ] 17 | }, 18 | "nullable": [ 19 | false 20 | ] 21 | }, 22 | "hash": "9a498ed98a10f033ca1d41c2004d7fe7b9a9ebec821a5194c20ef552a529b02f" 23 | } 24 | -------------------------------------------------------------------------------- /.sqlx/query-a3e3e0e4f80ae83ac4e775c9177a4e99eac041f5d734d44f329f5f243e5a41ca.json: -------------------------------------------------------------------------------- 1 | { 2 | "db_name": "PostgreSQL", 3 | "query": "DELETE FROM FavArticles WHERE article=$1 and username=$2", 4 | "describe": { 5 | "columns": [], 6 | "parameters": { 7 | "Left": [ 8 | "Text", 9 | "Text" 10 | ] 11 | }, 12 | "nullable": [] 13 | }, 14 | "hash": "a3e3e0e4f80ae83ac4e775c9177a4e99eac041f5d734d44f329f5f243e5a41ca" 15 | } 16 | -------------------------------------------------------------------------------- /.sqlx/query-a464fff5ad4fa7b7526d063c1d8681a2929e9fd657e4303b0af80a357d97fef9.json: -------------------------------------------------------------------------------- 1 | { 2 | "db_name": "PostgreSQL", 3 | "query": "INSERT INTO Comments(article, username, body) VALUES ($1, $2, $3)", 4 | "describe": { 5 | "columns": [], 6 | "parameters": { 7 | "Left": [ 8 | "Text", 9 | "Text", 10 | "Text" 11 | ] 12 | }, 13 | "nullable": [] 14 | }, 15 | "hash": "a464fff5ad4fa7b7526d063c1d8681a2929e9fd657e4303b0af80a357d97fef9" 16 | } 17 | -------------------------------------------------------------------------------- /.sqlx/query-b08930e76e94814ab091011fec4e351f845858e3b8631795eddea763eb932b5a.json: -------------------------------------------------------------------------------- 1 | { 2 | "db_name": "PostgreSQL", 3 | "query": "DELETE FROM Articles WHERE slug=$1 and author=$2", 4 | "describe": { 5 | "columns": [], 6 | "parameters": { 7 | "Left": [ 8 | "Text", 9 | "Text" 10 | ] 11 | }, 12 | "nullable": [] 13 | }, 14 | "hash": "b08930e76e94814ab091011fec4e351f845858e3b8631795eddea763eb932b5a" 15 | } 16 | -------------------------------------------------------------------------------- /.sqlx/query-cfe53c0aa11dc6361eee6913819c73761ab0c8355b6b2b73ed840322e565cd08.json: -------------------------------------------------------------------------------- 1 | { 2 | "db_name": "PostgreSQL", 3 | "query": "INSERT INTO Articles(slug, title, description, body, author) VALUES ($1, $2, $3, $4, $5)", 4 | "describe": { 5 | "columns": [], 6 | "parameters": { 7 | "Left": [ 8 | "Text", 9 | "Text", 10 | "Text", 11 | "Text", 12 | "Text" 13 | ] 14 | }, 15 | "nullable": [] 16 | }, 17 | "hash": "cfe53c0aa11dc6361eee6913819c73761ab0c8355b6b2b73ed840322e565cd08" 18 | } 19 | -------------------------------------------------------------------------------- /.sqlx/query-d0bff2016f68fa8b5bb258c63b60b053afb8c02f530cf18fe4b8da2c0c7956bc.json: -------------------------------------------------------------------------------- 1 | { 2 | "db_name": "PostgreSQL", 3 | "query": "DELETE FROM Follows WHERE follower=$1 and influencer=$2", 4 | "describe": { 5 | "columns": [], 6 | "parameters": { 7 | "Left": [ 8 | "Text", 9 | "Text" 10 | ] 11 | }, 12 | "nullable": [] 13 | }, 14 | "hash": "d0bff2016f68fa8b5bb258c63b60b053afb8c02f530cf18fe4b8da2c0c7956bc" 15 | } 16 | -------------------------------------------------------------------------------- /.sqlx/query-d995d93fce28f1b120ae95fba1b8cc338a2771f19fd555ccbbf4eb0f4ea08ae7.json: -------------------------------------------------------------------------------- 1 | { 2 | "db_name": "PostgreSQL", 3 | "query": "SELECT DISTINCT tag FROM ArticleTags", 4 | "describe": { 5 | "columns": [ 6 | { 7 | "ordinal": 0, 8 | "name": "tag", 9 | "type_info": "Text" 10 | } 11 | ], 12 | "parameters": { 13 | "Left": [] 14 | }, 15 | "nullable": [ 16 | false 17 | ] 18 | }, 19 | "hash": "d995d93fce28f1b120ae95fba1b8cc338a2771f19fd555ccbbf4eb0f4ea08ae7" 20 | } 21 | -------------------------------------------------------------------------------- /.sqlx/query-dafe0ca4b92e7ee20a60d8b1da46d647b2368dffc2778a6c8136d6bb23011cb3.json: -------------------------------------------------------------------------------- 1 | { 2 | "db_name": "PostgreSQL", 3 | "query": "SELECT username, email, bio, image, NULL as password FROM users WHERE email=$1", 4 | "describe": { 5 | "columns": [ 6 | { 7 | "ordinal": 0, 8 | "name": "username", 9 | "type_info": "Text" 10 | }, 11 | { 12 | "ordinal": 1, 13 | "name": "email", 14 | "type_info": "Text" 15 | }, 16 | { 17 | "ordinal": 2, 18 | "name": "bio", 19 | "type_info": "Text" 20 | }, 21 | { 22 | "ordinal": 3, 23 | "name": "image", 24 | "type_info": "Text" 25 | }, 26 | { 27 | "ordinal": 4, 28 | "name": "password", 29 | "type_info": "Text" 30 | } 31 | ], 32 | "parameters": { 33 | "Left": [ 34 | "Text" 35 | ] 36 | }, 37 | "nullable": [ 38 | false, 39 | false, 40 | true, 41 | true, 42 | null 43 | ] 44 | }, 45 | "hash": "dafe0ca4b92e7ee20a60d8b1da46d647b2368dffc2778a6c8136d6bb23011cb3" 46 | } 47 | -------------------------------------------------------------------------------- /.sqlx/query-def266669f865c31c9aedc3c441c851ad5dce104d9e8ad3866266fecb7540520.json: -------------------------------------------------------------------------------- 1 | { 2 | "db_name": "PostgreSQL", 3 | "query": "INSERT INTO Users(username, email, password) VALUES ($1, $2, crypt($3, gen_salt('bf')))", 4 | "describe": { 5 | "columns": [], 6 | "parameters": { 7 | "Left": [ 8 | "Text", 9 | "Text", 10 | "Text" 11 | ] 12 | }, 13 | "nullable": [] 14 | }, 15 | "hash": "def266669f865c31c9aedc3c441c851ad5dce104d9e8ad3866266fecb7540520" 16 | } 17 | -------------------------------------------------------------------------------- /.sqlx/query-ee1de2c8f0a9e4c47be3bc5ad3df0d45c95b12a84244eaeae6f09975e9d4f80c.json: -------------------------------------------------------------------------------- 1 | { 2 | "db_name": "PostgreSQL", 3 | "query": "UPDATE Articles SET title=$1, description=$2, body=$3 WHERE slug=$4 and author=$5", 4 | "describe": { 5 | "columns": [], 6 | "parameters": { 7 | "Left": [ 8 | "Text", 9 | "Text", 10 | "Text", 11 | "Text", 12 | "Text" 13 | ] 14 | }, 15 | "nullable": [] 16 | }, 17 | "hash": "ee1de2c8f0a9e4c47be3bc5ad3df0d45c95b12a84244eaeae6f09975e9d4f80c" 18 | } 19 | -------------------------------------------------------------------------------- /.sqlx/query-f413bc30f880e4bf725a5cec7d35cd1f39689f56caf0c79c85caa39096754bf8.json: -------------------------------------------------------------------------------- 1 | { 2 | "db_name": "PostgreSQL", 3 | "query": "SELECT username, email, bio, image, NULL as password FROM users WHERE username=$1", 4 | "describe": { 5 | "columns": [ 6 | { 7 | "ordinal": 0, 8 | "name": "username", 9 | "type_info": "Text" 10 | }, 11 | { 12 | "ordinal": 1, 13 | "name": "email", 14 | "type_info": "Text" 15 | }, 16 | { 17 | "ordinal": 2, 18 | "name": "bio", 19 | "type_info": "Text" 20 | }, 21 | { 22 | "ordinal": 3, 23 | "name": "image", 24 | "type_info": "Text" 25 | }, 26 | { 27 | "ordinal": 4, 28 | "name": "password", 29 | "type_info": "Text" 30 | } 31 | ], 32 | "parameters": { 33 | "Left": [ 34 | "Text" 35 | ] 36 | }, 37 | "nullable": [ 38 | false, 39 | false, 40 | true, 41 | true, 42 | null 43 | ] 44 | }, 45 | "hash": "f413bc30f880e4bf725a5cec7d35cd1f39689f56caf0c79c85caa39096754bf8" 46 | } 47 | -------------------------------------------------------------------------------- /.sqlx/query-f4be6a4f478a95d56fec6627d6dcb034b74386c56145aab74c661d36805e004f.json: -------------------------------------------------------------------------------- 1 | { 2 | "db_name": "PostgreSQL", 3 | "query": "SELECT EXISTS(SELECT * FROM Follows WHERE follower=$2 and influencer=$1)", 4 | "describe": { 5 | "columns": [ 6 | { 7 | "ordinal": 0, 8 | "name": "exists", 9 | "type_info": "Bool" 10 | } 11 | ], 12 | "parameters": { 13 | "Left": [ 14 | "Text", 15 | "Text" 16 | ] 17 | }, 18 | "nullable": [ 19 | null 20 | ] 21 | }, 22 | "hash": "f4be6a4f478a95d56fec6627d6dcb034b74386c56145aab74c661d36805e004f" 23 | } 24 | -------------------------------------------------------------------------------- /.sqlx/query-f5ec9c2e3b68c2e6c42fc6ce8b71c67c47e82587575777b197919a9843aa7bb5.json: -------------------------------------------------------------------------------- 1 | { 2 | "db_name": "PostgreSQL", 3 | "query": "\nSELECT \n a.slug,\n a.title,\n a.description,\n a.created_at,\n u.username,\n u.image,\n (SELECT COUNT(*) FROM FavArticles WHERE article=a.slug) as favorites_count,\n EXISTS(SELECT 1 FROM FavArticles WHERE article=a.slug and username=$2) as fav,\n EXISTS(SELECT 1 FROM Follows WHERE follower=$2 and influencer=a.author) as following,\n (SELECT string_agg(tag, ' ') FROM ArticleTags WHERE article = a.slug) as tag_list\nFROM Articles as a\n JOIN Users as u ON u.username = a.author\nWHERE\n CASE WHEN $3 THEN\n EXISTS(SELECT fa.article, fa.username FROM FavArticles as fa WHERE fa.article=a.slug AND fa.username=$1)\n ELSE a.author = $1\n END", 4 | "describe": { 5 | "columns": [ 6 | { 7 | "ordinal": 0, 8 | "name": "slug", 9 | "type_info": "Text" 10 | }, 11 | { 12 | "ordinal": 1, 13 | "name": "title", 14 | "type_info": "Text" 15 | }, 16 | { 17 | "ordinal": 2, 18 | "name": "description", 19 | "type_info": "Text" 20 | }, 21 | { 22 | "ordinal": 3, 23 | "name": "created_at", 24 | "type_info": "Timestamptz" 25 | }, 26 | { 27 | "ordinal": 4, 28 | "name": "username", 29 | "type_info": "Text" 30 | }, 31 | { 32 | "ordinal": 5, 33 | "name": "image", 34 | "type_info": "Text" 35 | }, 36 | { 37 | "ordinal": 6, 38 | "name": "favorites_count", 39 | "type_info": "Int8" 40 | }, 41 | { 42 | "ordinal": 7, 43 | "name": "fav", 44 | "type_info": "Bool" 45 | }, 46 | { 47 | "ordinal": 8, 48 | "name": "following", 49 | "type_info": "Bool" 50 | }, 51 | { 52 | "ordinal": 9, 53 | "name": "tag_list", 54 | "type_info": "Text" 55 | } 56 | ], 57 | "parameters": { 58 | "Left": [ 59 | "Text", 60 | "Text", 61 | "Bool" 62 | ] 63 | }, 64 | "nullable": [ 65 | false, 66 | false, 67 | false, 68 | false, 69 | false, 70 | true, 71 | null, 72 | null, 73 | null, 74 | null 75 | ] 76 | }, 77 | "hash": "f5ec9c2e3b68c2e6c42fc6ce8b71c67c47e82587575777b197919a9843aa7bb5" 78 | } 79 | -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "rust-analyzer.cargo.features": [ 3 | "ssr", "hydrate" 4 | ], 5 | "rust-analyzer.assist.emitMustUse": true, 6 | "rust-analyzer.check.command": "clippy" 7 | } 8 | -------------------------------------------------------------------------------- /Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "realworld-leptos" 3 | version = "0.1.0" 4 | edition = "2021" 5 | 6 | [lib] 7 | crate-type = ["cdylib", "rlib"] 8 | 9 | [dependencies] 10 | axum = { version = "0.8.4", optional = true } 11 | jsonwebtoken = { version = "9.3.1", optional = true } 12 | tokio = { version = "1.45.1", features = ["rt-multi-thread"], optional = true } 13 | tower = { version = "0.5.2", optional = true } 14 | tower-http = { version = "0.6.4", features = ["fs", "trace"], optional = true } 15 | sqlx = { version = "0.8.6", features = [ 16 | "runtime-tokio-rustls", 17 | "postgres", 18 | "chrono", 19 | ], optional = true } 20 | mail-send = { version = "0.5.1", optional = true } 21 | regex = { version = "1.11.1", optional = true } 22 | 23 | serde = { version = "1.0.219", features = ["derive"] } 24 | 25 | console_error_panic_hook = { version = "0.1.7", optional = true } 26 | 27 | leptos = "0.8.2" 28 | leptos_meta = "0.8.2" 29 | leptos_axum = { version = "0.8.2", optional = true } 30 | leptos_router = "0.8.2" 31 | 32 | tracing = "0.1.41" 33 | tracing-subscriber = { version = "0.3.19", features = ["fmt"] } 34 | tracing-wasm = { version = "0.2.1", optional = true } 35 | 36 | wasm-bindgen = "0.2.100" 37 | axum-extra = "0.10.1" 38 | 39 | [features] 40 | default = ["ssr", "hydrate"] 41 | hydrate = [ 42 | "leptos/hydrate", 43 | # "leptos_meta/hydrate", 44 | # "leptos_router/hydrate", 45 | "dep:tracing-wasm", 46 | "dep:console_error_panic_hook", 47 | ] 48 | ssr = [ 49 | "leptos/ssr", 50 | "leptos_meta/ssr", 51 | "leptos_router/ssr", 52 | "dep:leptos_axum", 53 | "dep:axum", 54 | "dep:jsonwebtoken", 55 | "dep:tokio", 56 | "dep:tower", 57 | "dep:tower-http", 58 | "dep:sqlx", 59 | "dep:regex", 60 | "dep:mail-send" 61 | ] 62 | 63 | [package.metadata.cargo-all-features] 64 | denylist = [ 65 | "axum", 66 | "tower", 67 | "tower-http", 68 | "tokio", 69 | "sqlx", 70 | "leptos_axum", 71 | "jsonwebtoken", 72 | "regex", 73 | "mail-send", 74 | ] 75 | skip_feature_sets = [["csr", "ssr"], ["csr", "hydrate"], ["ssr", "hydrate"]] 76 | 77 | [package.metadata.leptos] 78 | # The site root folder is where cargo-leptos generate all output. WARNING: all content of this folder will be erased on a rebuild. Use it in your server setup. 79 | site-root = "target/site" 80 | 81 | # The site-root relative folder where all compiled output (JS, WASM and CSS) is written 82 | # Defaults to pkg 83 | site-pkg-dir = "pkg" 84 | 85 | # [Optional] The source CSS file. If it ends with .sass or .scss then it will be compiled by dart-sass into CSS. The CSS is optimized by Lightning CSS before being written to //app.css 86 | style-file = "style/main.scss" 87 | 88 | # [Optional] Files in the asset-dir will be copied to the site-root directory 89 | assets-dir = "assets" 90 | 91 | # JS source dir. `wasm-bindgen` has the option to include JS snippets from JS files 92 | # with `#[wasm_bindgen(module = "/js/foo.js")]`. A change in any JS file in this dir 93 | # will trigger a rebuild. 94 | js-dir = "js" 95 | 96 | # The IP and port (ex: 127.0.0.1:3000) where the server serves the content. Use it in your server setup. 97 | site-address = "127.0.0.1:3000" 98 | # The port to use for automatic reload monitoring 99 | reload-port = 3001 100 | # [Optional] Command to use when running end2end tests. It will run in the end2end dir. 101 | # [Windows] for non-WSL use "npx.cmd playwright test" 102 | # This binary name can be checked in Powershell with Get-Command npx 103 | end2end-cmd = "npx playwright test" 104 | end2end-dir = "end2end" 105 | # The browserlist query used for optimizing the CSS. 106 | browserquery = "defaults" 107 | # Set by cargo-leptos watch when building with that tool. Controls whether autoreload JS will be included in the head 108 | watch = true 109 | 110 | # The features to use when compiling the bin target 111 | # 112 | # Optional. Can be over-ridden with the command line parameter --bin-features 113 | bin-features = ["ssr"] 114 | 115 | # The features to use when compiling the lib target 116 | # 117 | # Optional. Can be over-ridden with the command line parameter --lib-features 118 | lib-features = ["hydrate"] 119 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM rust:1.85-bookworm as builder 2 | 3 | RUN cargo install cargo-leptos &&\ 4 | rustup target add wasm32-unknown-unknown &&\ 5 | mkdir -p /app 6 | 7 | WORKDIR /app 8 | ENV JWT_SECRET="replaceme when ran in prod" 9 | COPY . . 10 | 11 | RUN cargo leptos build -r -vv 12 | 13 | FROM debian:bookworm-slim as runner 14 | 15 | WORKDIR /app 16 | 17 | COPY --from=builder /app/target/release/realworld-leptos /app/realworld-leptos 18 | COPY --from=builder /app/target/site /app/site 19 | 20 | ENV LEPTOS_OUTPUT_NAME="realworld-leptos" 21 | ENV LEPTOS_SITE_ADDR="0.0.0.0:8080" 22 | ENV LEPTOS_SITE_ROOT="site" 23 | ENV LEPTOS_SITE_PKG_DIR="pkg" 24 | 25 | EXPOSE 8080 26 | 27 | # Remember to set JWT_SECRET and DATABASE_URL environmental variables 28 | CMD ["/app/realworld-leptos"] 29 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2022 henrik 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Realworld app with Leptos + Axum + Postgres 2 | 3 | You can check it online in https://realworld-leptos.onrender.com (it might take a couple of seconds to startup). 4 | 5 | # Requirements 6 | 7 | ## Rust with Webassembly support 8 | 9 | `curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh` 10 | 11 | Once finished, add webassembly target to rust: 12 | 13 | `rustup target add wasm32-unknown-unknown` 14 | 15 | ## cargo-leptos 16 | 17 | This is an utility to easily compile either backend and frontend at the same time: 18 | 19 | `cargo install cargo-leptos` 20 | 21 | # sqlx 22 | 23 | We need to run migrations before compiling, otherwise the query! macros will fail and abort the compilation: 24 | 25 | `cargo install sqlx-cli` 26 | 27 | # How to run this project locally 28 | 29 | First, deploy a local postgres database, maybe docker is the fastest solution: 30 | 31 | `docker run --name postgres -e POSTGRES_PASSWORD=postgres -p 5432:5432 -d postgres` 32 | 33 | Clone it into your machine and run it with cargo-leptos: 34 | 35 | ``` 36 | git clone https://github.com/Bechma/realworld-leptos.git 37 | cd realworld-leptos 38 | cp .env.example .env 39 | source .env 40 | cargo sqlx migrate run 41 | cargo leptos watch 42 | ``` 43 | 44 | Change the placeholder in .env for `JWT_SECRET` for security reasons. 45 | 46 | Also, there's a password reset functionality that works with a sending email. If you want 47 | to use that feature you can set MAILER_EMAIL and MAILER_PASSWD with your email creds 48 | and MAILER_SMTP_SERVER with your SMTP service. 49 | 50 | # How to test this project 51 | 52 | You will need to have a local database, in order to execute end2end testing. 53 | 54 | ``` 55 | cd end2end/ 56 | npm i 57 | npx playwright install 58 | cd ../ 59 | cargo leptos end-to-end 60 | ``` 61 | 62 | You will need to install the playright dependency in the end2end directory and 63 | install the playwright drivers. With cargo-leptos the tests will be executed. 64 | 65 | # Run it with docker compose 66 | 67 | You can also run the application in release mode using docker compose: 68 | 69 | `docker compose up` 70 | 71 | And navigate to http://localhost:8080/ 72 | 73 | # Details about deployment 74 | 75 | The deployment has been done thanks to the free tier of: 76 | 77 | - https://render.com/ for the fullstack application 78 | - https://neon.tech/ for the database (render has a 90 days expiration in the db) 79 | 80 | Previously, I deployed the db in https://www.elephantsql.com/ , 81 | but [It will reach EOL soon](https://www.elephantsql.com/blog/end-of-life-announcement.html) -------------------------------------------------------------------------------- /assets/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Bechma/realworld-leptos/1251daa49c4b1b0b0cded087141d06294b1edeb9/assets/favicon.ico -------------------------------------------------------------------------------- /docker-compose.yml: -------------------------------------------------------------------------------- 1 | services: 2 | web: 3 | image: realworld-leptos:0.1.0 4 | build: . 5 | environment: 6 | DATABASE_URL: "postgres://postgres:postgres@db/realworld" 7 | JWT_SECRET: "changeme when deploy to production" 8 | ports: 9 | - '8080:8080' 10 | depends_on: 11 | - db 12 | db: 13 | image: postgres:14-alpine 14 | restart: always 15 | environment: 16 | - POSTGRES_DB=realworld 17 | - POSTGRES_USER=postgres 18 | - POSTGRES_PASSWORD=postgres 19 | # ports: 20 | # - '5432:5432' 21 | -------------------------------------------------------------------------------- /end2end/package-lock.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "end2end", 3 | "version": "1.0.0", 4 | "lockfileVersion": 3, 5 | "requires": true, 6 | "packages": { 7 | "": { 8 | "name": "end2end", 9 | "version": "1.0.0", 10 | "license": "ISC", 11 | "devDependencies": { 12 | "@playwright/test": "^1.36.0" 13 | } 14 | }, 15 | "node_modules/@playwright/test": { 16 | "version": "1.36.2", 17 | "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.36.2.tgz", 18 | "integrity": "sha512-2rVZeyPRjxfPH6J0oGJqE8YxiM1IBRyM8hyrXYK7eSiAqmbNhxwcLa7dZ7fy9Kj26V7FYia5fh9XJRq4Dqme+g==", 19 | "dev": true, 20 | "dependencies": { 21 | "@types/node": "*", 22 | "playwright-core": "1.36.2" 23 | }, 24 | "bin": { 25 | "playwright": "cli.js" 26 | }, 27 | "engines": { 28 | "node": ">=16" 29 | }, 30 | "optionalDependencies": { 31 | "fsevents": "2.3.2" 32 | } 33 | }, 34 | "node_modules/@types/node": { 35 | "version": "18.11.18", 36 | "resolved": "https://registry.npmjs.org/@types/node/-/node-18.11.18.tgz", 37 | "integrity": "sha512-DHQpWGjyQKSHj3ebjFI/wRKcqQcdR+MoFBygntYOZytCqNfkd2ZC4ARDJ2DQqhjH5p85Nnd3jhUJIXrszFX/JA==", 38 | "dev": true 39 | }, 40 | "node_modules/fsevents": { 41 | "version": "2.3.2", 42 | "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz", 43 | "integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==", 44 | "dev": true, 45 | "hasInstallScript": true, 46 | "optional": true, 47 | "os": [ 48 | "darwin" 49 | ], 50 | "engines": { 51 | "node": "^8.16.0 || ^10.6.0 || >=11.0.0" 52 | } 53 | }, 54 | "node_modules/playwright-core": { 55 | "version": "1.36.2", 56 | "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.36.2.tgz", 57 | "integrity": "sha512-sQYZt31dwkqxOrP7xy2ggDfEzUxM1lodjhsQ3NMMv5uGTRDsLxU0e4xf4wwMkF2gplIxf17QMBCodSFgm6bFVQ==", 58 | "dev": true, 59 | "bin": { 60 | "playwright-core": "cli.js" 61 | }, 62 | "engines": { 63 | "node": ">=16" 64 | } 65 | } 66 | } 67 | } 68 | -------------------------------------------------------------------------------- /end2end/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "end2end", 3 | "version": "1.0.0", 4 | "description": "", 5 | "main": "index.js", 6 | "scripts": {}, 7 | "keywords": [], 8 | "author": "", 9 | "license": "ISC", 10 | "devDependencies": { 11 | "@playwright/test": "^1.36.0" 12 | } 13 | } -------------------------------------------------------------------------------- /end2end/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: "./tests", 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 | /* Run tests in files in parallel */ 25 | fullyParallel: true, 26 | /* Fail the build on CI if you accidentally left test.only in the source code. */ 27 | forbidOnly: !!process.env.CI, 28 | /* Retry on CI only */ 29 | retries: process.env.CI ? 2 : 0, 30 | /* Opt out of parallel tests on CI. */ 31 | workers: process.env.CI ? 1 : undefined, 32 | /* Reporter to use. See https://playwright.dev/docs/test-reporters */ 33 | reporter: "html", 34 | /* Shared settings for all the projects below. See https://playwright.dev/docs/api/class-testoptions. */ 35 | use: { 36 | /* Maximum time each action such as `click()` can take. Defaults to 0 (no limit). */ 37 | actionTimeout: 0, 38 | /* Base URL to use in actions like `await page.goto('/')`. */ 39 | // baseURL: 'http://localhost:3000', 40 | 41 | /* Collect trace when retrying the failed test. See https://playwright.dev/docs/trace-viewer */ 42 | trace: "on-first-retry", 43 | }, 44 | 45 | /* Configure projects for major browsers */ 46 | projects: [ 47 | { 48 | name: "chromium", 49 | use: { 50 | ...devices["Desktop Chrome"], 51 | }, 52 | }, 53 | 54 | { 55 | name: "firefox", 56 | use: { 57 | ...devices["Desktop Firefox"], 58 | }, 59 | }, 60 | 61 | { 62 | name: "webkit", 63 | use: { 64 | ...devices["Desktop Safari"], 65 | }, 66 | }, 67 | 68 | /* Test against mobile viewports. */ 69 | // { 70 | // name: 'Mobile Chrome', 71 | // use: { 72 | // ...devices['Pixel 5'], 73 | // }, 74 | // }, 75 | // { 76 | // name: 'Mobile Safari', 77 | // use: { 78 | // ...devices['iPhone 12'], 79 | // }, 80 | // }, 81 | 82 | /* Test against branded browsers. */ 83 | // { 84 | // name: 'Microsoft Edge', 85 | // use: { 86 | // channel: 'msedge', 87 | // }, 88 | // }, 89 | // { 90 | // name: 'Google Chrome', 91 | // use: { 92 | // channel: 'chrome', 93 | // }, 94 | // }, 95 | ], 96 | 97 | /* Folder for test artifacts such as screenshots, videos, traces, etc. */ 98 | // outputDir: 'test-results/', 99 | 100 | /* Run your local dev server before starting the tests */ 101 | // webServer: { 102 | // command: 'npm run start', 103 | // port: 3000, 104 | // }, 105 | }; 106 | 107 | export default config; 108 | -------------------------------------------------------------------------------- /end2end/tests/example.spec.ts: -------------------------------------------------------------------------------- 1 | import { test, expect } from "@playwright/test"; 2 | 3 | const addr = "http://localhost:3000/"; 4 | 5 | test("homepage has title and subtitle", async ({ page }) => { 6 | await page.goto(addr); 7 | 8 | await expect(page).toHaveTitle("Home"); 9 | 10 | await expect(page.locator("h1.logo-font")).toHaveText("conduit"); 11 | await expect(page.locator("body > main > div > div.banner > div > p")).toHaveText("A place to share your knowledge.") 12 | }); 13 | 14 | test("signup, logout and login works", async ({ page }) => { 15 | await page.goto(addr + "signup"); 16 | 17 | const username = (Math.random() + 1).toString(36).substring(7); 18 | const password = (Math.random() + 1).toString(36).substring(7); 19 | 20 | // Create user 21 | await page.getByPlaceholder("Your Username").fill(username); 22 | await page.getByPlaceholder("Password").fill(password); 23 | await page.getByPlaceholder("Email").fill(username + "@" + username + ".com"); 24 | await page.getByRole("button", { name: "Sign up" }).click() 25 | 26 | // Logout user 27 | await page.waitForURL(addr); 28 | const logout = page.locator("body > nav > div > ul > li:nth-child(5) > form > button"); 29 | await expect(logout).toBeVisible(); 30 | await logout.click(); 31 | 32 | // Login user 33 | await page.waitForURL(addr + "login") 34 | await page.getByPlaceholder("Your Username").fill(username); 35 | await page.getByPlaceholder("Password").fill(password); 36 | await page.getByRole("button", { name: "Sign in" }).click() 37 | 38 | await page.waitForURL(addr) 39 | await expect(page.locator("a.nav-link > i.ion-person")).toBeVisible() 40 | }); 41 | -------------------------------------------------------------------------------- /js/utils.js: -------------------------------------------------------------------------------- 1 | /** 2 | * https://programadorwebvalencia.com/descodificar-jwt-en-javascript-o-node/ 3 | * 4 | * Decode JWT (JSON Web Token - ) to it's subject 5 | * @param {string} token 6 | * @returns {object} 7 | */ 8 | export function decodeJWT(token) { 9 | const base64Url = token.split('.')[1]; 10 | const base64 = base64Url.replace(/-/g, '+').replace(/_/g, '/'); 11 | const jsonPayload = decodeURIComponent(window.atob(base64).split('').map(function (c) { 12 | return '%' + ('00' + c.charCodeAt(0).toString(16)).slice(-2); 13 | }).join('')); 14 | return JSON.parse(jsonPayload).sub; 15 | } 16 | 17 | /** 18 | * 19 | * @param {string} email 20 | * @returns {boolean} 21 | */ 22 | export function emailRegex(email) { 23 | return /^[\w\-\.]+@([\w-]+\.)+\w{2,4}$/.test(email) 24 | } -------------------------------------------------------------------------------- /migrations/20221207194615_init.down.sql: -------------------------------------------------------------------------------- 1 | DROP TABLE IF EXISTS Users CASCADE; 2 | DROP TABLE IF EXISTS Follows; 3 | DROP TABLE IF EXISTS Articles CASCADE; 4 | DROP TABLE IF EXISTS ArticleTags; 5 | DROP TABLE IF EXISTS FavArticles; 6 | DROP TABLE IF EXISTS Comments; 7 | -------------------------------------------------------------------------------- /migrations/20221207194615_init.up.sql: -------------------------------------------------------------------------------- 1 | CREATE EXTENSION IF NOT EXISTS pgcrypto; 2 | 3 | CREATE TABLE IF NOT EXISTS Users ( 4 | username text NOT NULL PRIMARY KEY, 5 | email text NOT NULL UNIQUE, 6 | password text NOT NULL, 7 | bio text NULL, 8 | image text NULL 9 | ); 10 | 11 | CREATE TABLE IF NOT EXISTS Follows ( 12 | follower text NOT NULL REFERENCES Users(username) ON DELETE CASCADE ON UPDATE CASCADE, 13 | influencer text NOT NULL REFERENCES Users(username) ON DELETE CASCADE ON UPDATE CASCADE, 14 | PRIMARY KEY (follower, influencer) 15 | ); 16 | 17 | CREATE TABLE IF NOT EXISTS Articles ( 18 | slug text NOT NULL PRIMARY KEY, 19 | author text NOT NULL REFERENCES Users(username) ON DELETE CASCADE ON UPDATE CASCADE, 20 | title text NOT NULL, 21 | description text NOT NULL, 22 | body text NOT NULL, 23 | created_at TIMESTAMPTZ NOT NULL default NOW(), 24 | updated_at TIMESTAMPTZ NOT NULL default NOW() 25 | ); 26 | 27 | CREATE TABLE IF NOT EXISTS ArticleTags ( 28 | article text NOT NULL REFERENCES Articles(slug) ON DELETE CASCADE ON UPDATE CASCADE, 29 | tag text NOT NULL, 30 | PRIMARY KEY (article, tag) 31 | ); 32 | 33 | CREATE INDEX IF NOT EXISTS tags ON ArticleTags (tag); 34 | 35 | CREATE TABLE IF NOT EXISTS FavArticles ( 36 | article text NOT NULL REFERENCES Articles(slug) ON DELETE CASCADE ON UPDATE CASCADE, 37 | username text NOT NULL REFERENCES Users(username) ON DELETE CASCADE ON UPDATE CASCADE, 38 | PRIMARY KEY (article, username) 39 | ); 40 | 41 | CREATE TABLE IF NOT EXISTS Comments ( 42 | id int PRIMARY KEY GENERATED ALWAYS AS IDENTITY, 43 | article text NOT NULL REFERENCES Articles(slug) ON DELETE CASCADE ON UPDATE CASCADE, 44 | username text NOT NULL REFERENCES Users(username) ON DELETE CASCADE ON UPDATE CASCADE, 45 | body text NOT NULL, 46 | created_at TIMESTAMPTZ NOT NULL default NOW() 47 | ); 48 | -------------------------------------------------------------------------------- /sqlx-data.json: -------------------------------------------------------------------------------- 1 | { 2 | "db": "PostgreSQL" 3 | } -------------------------------------------------------------------------------- /src/app.rs: -------------------------------------------------------------------------------- 1 | use leptos::prelude::*; 2 | use leptos_meta::*; 3 | use leptos_router::components::{Route, Router, Routes, A}; 4 | use leptos_router::*; 5 | 6 | use crate::components::NavItems; 7 | use crate::routes::*; 8 | 9 | #[tracing::instrument] 10 | #[component] 11 | pub fn App() -> impl IntoView { 12 | // Provides context that manages stylesheets, titles, meta tags, etc. 13 | provide_meta_context(); 14 | 15 | let username: crate::auth::UsernameSignal = RwSignal::new(None); 16 | 17 | let logout: crate::auth::LogoutSignal = ServerAction::::new(); 18 | let login: crate::auth::LoginSignal = ServerAction::::new(); 19 | let signup: crate::auth::SignupSignal = ServerAction::::new(); 20 | 21 | let (logout_version, login_version, signup_version) = 22 | (logout.version(), login.version(), signup.version()); 23 | 24 | let user = Resource::new( 25 | move || { 26 | ( 27 | logout_version.get(), 28 | login_version.get(), 29 | signup_version.get(), 30 | ) 31 | }, 32 | move |_| { 33 | tracing::debug!("fetch user"); 34 | crate::auth::current_user() 35 | }, 36 | ); 37 | 38 | view! { 39 | 40 | 41 | // injects a stylesheet into the document 42 | // id=leptos means cargo-leptos will hot-reload this stylesheet 43 | 44 | 45 | 46 | 47 | 48 | // sets the document title 49 | 50 | 51 | </head> 52 | <body> 53 | <Router> 54 | <nav class="navbar navbar-light"> 55 | <div class="container"> 56 | <A href="/" exact=true><span class="navbar-brand">"conduit"</span></A> 57 | <ul class="nav navbar-nav pull-xs-right"> 58 | <Transition fallback=|| view!{<p>"Loading Navigation bar"</p>}> 59 | {move || user.get().map(move |x| { 60 | username.set(x.map(|y| y.username()).ok()); 61 | view! { 62 | <NavItems logout username /> 63 | } 64 | })} 65 | </Transition> 66 | </ul> 67 | </div> 68 | </nav> 69 | <main> 70 | <Routes fallback=|| ()> 71 | <Route path=path!("/") view=move || view! { 72 | <Transition fallback=|| view!{<p>"Loading HomePage"</p>}> 73 | {move || user.get().map(move |x| { 74 | username.set(x.map(|y| y.username()).ok()); 75 | view! { 76 | <HomePage username/> 77 | } 78 | })} 79 | </Transition> 80 | }/> 81 | <Route path=path!("/login") view=move || view! { <Login login/> }/> 82 | <Route path=path!("/reset_password") view=move || view! { <ResetPassword/> }/> 83 | <Route path=path!("/signup") view=move || view! { <Signup signup/> }/> 84 | <Route path=path!("/settings") view=move || view! { <Settings logout /> }/> 85 | <Route path=path!("/editor/:slug?") view=|| view! { <Editor/> }/> 86 | <Route path=path!("/article/:slug") view=move || view! { 87 | <Transition fallback=|| view!{<p>"Loading Article"</p>}> 88 | {move || user.get().map(move |x| { 89 | username.set(x.map(|y| y.username()).ok()); 90 | view! { 91 | <Article username/> 92 | } 93 | })} 94 | </Transition> 95 | }/> 96 | <Route path=path!("/profile/:user") view=move || view! { 97 | <Transition fallback=|| view!{<p>"Loading Profile"</p>}> 98 | {move || user.get().map(move |x| { 99 | username.set(x.map(|y| y.username()).ok()); 100 | view! { 101 | <Profile username/> 102 | } 103 | })} 104 | </Transition> 105 | }/> 106 | </Routes> 107 | </main> 108 | <footer> 109 | <div class="container"> 110 | <A href="/"><span class="logo-font">"conduit"</span></A> 111 | <span class="attribution"> 112 | "An interactive learning project from " 113 | <a href="https://thinkster.io">"Thinkster"</a> 114 | ". Code & design licensed under MIT." 115 | </span> 116 | </div> 117 | </footer> 118 | </Router> 119 | </body> 120 | </html> 121 | } 122 | } 123 | -------------------------------------------------------------------------------- /src/auth/api.rs: -------------------------------------------------------------------------------- 1 | use leptos::prelude::*; 2 | 3 | #[derive(serde::Deserialize, Clone, serde::Serialize)] 4 | pub enum SignupResponse { 5 | ValidationError(String), 6 | CreateUserError(String), 7 | Success, 8 | } 9 | 10 | #[tracing::instrument] 11 | pub fn validate_signup( 12 | username: String, 13 | email: String, 14 | password: String, 15 | ) -> Result<crate::models::User, String> { 16 | crate::models::User::default() 17 | .set_username(username)? 18 | .set_password(password)? 19 | .set_email(email) 20 | } 21 | 22 | #[tracing::instrument] 23 | #[server(SignupAction, "/api")] 24 | pub async fn signup_action( 25 | username: String, 26 | email: String, 27 | password: String, 28 | ) -> Result<SignupResponse, ServerFnError> { 29 | match validate_signup(username.clone(), email, password) { 30 | Ok(user) => match user.insert().await { 31 | Ok(_) => { 32 | crate::auth::set_username(username).await; 33 | leptos_axum::redirect("/"); 34 | Ok(SignupResponse::Success) 35 | } 36 | Err(x) => { 37 | let x = x.to_string(); 38 | Ok(if x.contains("users_email_key") { 39 | SignupResponse::CreateUserError("Duplicated email".to_string()) 40 | } else if x.contains("users_pkey") { 41 | SignupResponse::CreateUserError("Duplicated user".to_string()) 42 | } else { 43 | tracing::error!("error from DB: {}", x); 44 | SignupResponse::CreateUserError( 45 | "There is an unknown problem, try again later".to_string(), 46 | ) 47 | }) 48 | } 49 | }, 50 | Err(x) => Ok(SignupResponse::ValidationError(x)), 51 | } 52 | } 53 | 54 | #[derive(Debug, serde::Deserialize, serde::Serialize, Clone)] 55 | pub enum LoginMessages { 56 | Successful, 57 | Unsuccessful, 58 | } 59 | 60 | #[server(LoginAction, "/api")] 61 | #[tracing::instrument] 62 | pub async fn login_action( 63 | username: String, 64 | password: String, 65 | ) -> Result<LoginMessages, ServerFnError> { 66 | if sqlx::query_scalar!( 67 | "SELECT username FROM Users where username=$1 and password=crypt($2, password)", 68 | username, 69 | password, 70 | ) 71 | .fetch_one(crate::database::get_db()) 72 | .await 73 | .unwrap_or_default() 74 | == username 75 | { 76 | crate::auth::set_username(username).await; 77 | leptos_axum::redirect("/"); 78 | Ok(LoginMessages::Successful) 79 | } else { 80 | let response_options = use_context::<leptos_axum::ResponseOptions>().unwrap(); 81 | response_options.set_status(axum::http::StatusCode::FORBIDDEN); 82 | Ok(LoginMessages::Unsuccessful) 83 | } 84 | } 85 | 86 | #[server(LogoutAction, "/api")] 87 | #[tracing::instrument] 88 | pub async fn logout_action() -> Result<(), ServerFnError> { 89 | let response_options = use_context::<leptos_axum::ResponseOptions>().unwrap(); 90 | response_options.insert_header( 91 | axum::http::header::SET_COOKIE, 92 | axum::http::HeaderValue::from_str(crate::auth::REMOVE_COOKIE) 93 | .expect("header value couldn't be set"), 94 | ); 95 | leptos_axum::redirect("/login"); 96 | Ok(()) 97 | } 98 | 99 | #[server(CurrentUserAction, "/api")] 100 | #[tracing::instrument] 101 | pub async fn current_user() -> Result<crate::models::User, ServerFnError> { 102 | let Some(logged_user) = super::get_username() else { 103 | return Err(ServerFnError::ServerError("you must be logged in".into())); 104 | }; 105 | crate::models::User::get(logged_user).await.map_err(|err| { 106 | tracing::error!("problem while retrieving current_user: {err:?}"); 107 | ServerFnError::ServerError("you must be logged in".into()) 108 | }) 109 | } 110 | -------------------------------------------------------------------------------- /src/auth/mod.rs: -------------------------------------------------------------------------------- 1 | use leptos::prelude::*; 2 | mod api; 3 | #[cfg(feature = "ssr")] 4 | mod server; 5 | pub use api::*; 6 | #[cfg(feature = "ssr")] 7 | pub use server::*; 8 | 9 | pub type LogoutSignal = ServerAction<LogoutAction>; 10 | pub type LoginSignal = ServerAction<LoginAction>; 11 | pub type SignupSignal = ServerAction<SignupAction>; 12 | pub type UsernameSignal = RwSignal<Option<String>>; 13 | -------------------------------------------------------------------------------- /src/auth/server.rs: -------------------------------------------------------------------------------- 1 | use axum::{ 2 | http::{header, Request, StatusCode}, 3 | response::Response, 4 | }; 5 | use jsonwebtoken::{decode, DecodingKey, Validation}; 6 | use serde::{Deserialize, Serialize}; 7 | 8 | static AUTH_COOKIE: &str = "token"; 9 | 10 | #[derive(Debug, Serialize, Deserialize)] 11 | pub struct TokenClaims { 12 | pub sub: String, // Optional. Subject (whom token refers to) 13 | pub exp: usize, // Required (validate_exp defaults to true in validation). Expiration time (as UTC timestamp) 14 | // aud: String, // Optional. Audience 15 | // iat: usize, // Optional. Issued at (as UTC timestamp) 16 | // iss: String, // Optional. Issuer 17 | // nbf: usize, // Optional. Not Before (as UTC timestamp) 18 | // sub: String, // Optional. Subject (whom token refers to) 19 | } 20 | 21 | pub(crate) static REMOVE_COOKIE: &str = "token=; path=/; expires=Thu, 01 Jan 1970 00:00:00 GMT"; 22 | 23 | pub async fn auth_middleware( 24 | req: Request<axum::body::Body>, 25 | next: axum::middleware::Next, 26 | ) -> Response { 27 | match get_username_from_headers(req.headers()) { 28 | Some(username) => { 29 | let Ok(_) = crate::models::User::get(username).await else { 30 | tracing::info!("no user associated with this token"); 31 | return redirect(req, next).await; 32 | }; 33 | 34 | let path = req.uri().path(); 35 | if path.starts_with("/login") || path.starts_with("/signup") { 36 | // If the user is authenticated, we don't want to show the login or signup pages 37 | return Response::builder() 38 | .status(StatusCode::FOUND) 39 | .header(header::LOCATION, "/") 40 | .body(axum::body::Body::empty()) 41 | .unwrap(); 42 | } 43 | next.run(req).await 44 | } 45 | None => redirect(req, next).await, 46 | } 47 | } 48 | 49 | async fn redirect(req: Request<axum::body::Body>, next: axum::middleware::Next) -> Response { 50 | let path = req.uri().path(); 51 | 52 | if path.starts_with("/settings") || path.starts_with("/editor") { 53 | // authenticated routes 54 | Response::builder() 55 | .status(StatusCode::FOUND) 56 | .header(header::LOCATION, "/login") 57 | .header(header::SET_COOKIE, REMOVE_COOKIE) 58 | .body(axum::body::Body::empty()) 59 | .unwrap() 60 | } else { 61 | next.run(req).await 62 | } 63 | } 64 | 65 | pub(crate) fn decode_token( 66 | token: &str, 67 | ) -> Result<jsonwebtoken::TokenData<TokenClaims>, jsonwebtoken::errors::Error> { 68 | let secret = env!("JWT_SECRET"); 69 | decode::<TokenClaims>( 70 | token, 71 | &DecodingKey::from_secret(secret.as_bytes()), 72 | &Validation::default(), 73 | ) 74 | } 75 | 76 | pub(crate) fn encode_token(token_claims: TokenClaims) -> jsonwebtoken::errors::Result<String> { 77 | let secret = env!("JWT_SECRET"); 78 | jsonwebtoken::encode( 79 | &jsonwebtoken::Header::default(), 80 | &token_claims, 81 | &jsonwebtoken::EncodingKey::from_secret(secret.as_bytes()), 82 | ) 83 | } 84 | 85 | #[tracing::instrument] 86 | pub(crate) fn get_username_from_headers(headers: &axum::http::HeaderMap) -> Option<String> { 87 | headers.get(header::COOKIE).and_then(|x| { 88 | x.to_str() 89 | .unwrap() 90 | .split("; ") 91 | .find(|&x| x.starts_with(AUTH_COOKIE)) 92 | .and_then(|x| x.split('=').next_back()) 93 | .and_then(|x| decode_token(x).map(|jwt| jwt.claims.sub).ok()) 94 | }) 95 | } 96 | 97 | #[tracing::instrument] 98 | pub fn get_username() -> Option<String> { 99 | if let Some(req) = leptos::prelude::use_context::<axum::http::request::Parts>() { 100 | get_username_from_headers(&req.headers) 101 | } else { 102 | None 103 | } 104 | } 105 | 106 | #[tracing::instrument] 107 | pub async fn set_username(username: String) -> bool { 108 | if let Some(res) = leptos::prelude::use_context::<leptos_axum::ResponseOptions>() { 109 | let token = encode_token(TokenClaims { 110 | sub: username, 111 | exp: (sqlx::types::chrono::Utc::now().timestamp() as usize) + 3_600_000, 112 | }) 113 | .unwrap(); 114 | res.insert_header( 115 | header::SET_COOKIE, 116 | header::HeaderValue::from_str(&format!("{AUTH_COOKIE}={token}; path=/; HttpOnly")) 117 | .expect("header value couldn't be set"), 118 | ); 119 | true 120 | } else { 121 | false 122 | } 123 | } 124 | -------------------------------------------------------------------------------- /src/components/article_preview.rs: -------------------------------------------------------------------------------- 1 | use leptos::prelude::*; 2 | use leptos_router::components::A; 3 | 4 | use super::buttons::{ButtonFav, ButtonFollow}; 5 | 6 | pub type ArticleSignal = RwSignal<crate::models::Article>; 7 | 8 | #[component] 9 | pub fn ArticlePreviewList( 10 | username: crate::auth::UsernameSignal, 11 | articles: Resource<Vec<crate::models::Article>>, 12 | ) -> impl IntoView { 13 | let articles_view = move || { 14 | articles.with(move |x| { 15 | x.clone().map(move |res| { 16 | view! { 17 | <For 18 | each=move || res.clone().into_iter().enumerate() 19 | key=|(i, _)| *i 20 | children=move |(_, article): (usize, crate::models::Article)| { 21 | let article = RwSignal::new(article); 22 | view! { 23 | <ArticlePreview article=article username=username /> 24 | } 25 | } 26 | /> 27 | } 28 | }) 29 | }) 30 | }; 31 | 32 | view! { 33 | <Suspense fallback=move || view! {<p>"Loading Articles"</p> }> 34 | <ErrorBoundary fallback=|_| { 35 | view! { <p class="error-messages text-xs-center">"Something went wrong."</p>} 36 | }> 37 | {articles_view} 38 | </ErrorBoundary> 39 | </Suspense> 40 | } 41 | } 42 | 43 | #[component] 44 | fn ArticlePreview(username: crate::auth::UsernameSignal, article: ArticleSignal) -> impl IntoView { 45 | view! { 46 | <div class="article-preview"> 47 | <ArticleMeta username=username article=article is_preview=true /> 48 | <span class="preview-link"> 49 | <A href=move || format!("/article/{}", article.with(|x| x.slug.clone()))> 50 | <h1>{move || article.with(|x| x.title.to_string())}</h1> 51 | <p>{move || article.with(|x| x.description.to_string())}</p> 52 | <span class="btn">"Read more..."</span> 53 | <Show 54 | when=move || article.with(|x| !x.tag_list.is_empty()) 55 | fallback=|| view! {<span>"No tags"</span>} 56 | > 57 | <ul class="tag-list"> 58 | <i class="ion-pound"></i> 59 | <For 60 | each=move || article.with(|x| x.tag_list.clone().into_iter().enumerate()) 61 | key=|(i, _)| *i 62 | children=move |(_, tag): (usize, String)| { 63 | view!{<li class="tag-default tag-pill tag-outline">{tag}</li>} 64 | } 65 | /> 66 | </ul> 67 | </Show> 68 | </A> 69 | </span> 70 | </div> 71 | } 72 | } 73 | 74 | #[component] 75 | pub fn ArticleMeta( 76 | username: crate::auth::UsernameSignal, 77 | article: ArticleSignal, 78 | is_preview: bool, 79 | ) -> impl IntoView { 80 | let editor_ref = move || format!("/editor/{}", article.with(|x| x.slug.to_string())); 81 | let profile_ref = move || { 82 | format!( 83 | "/profile/{}", 84 | article.with(|x| x.author.username.to_string()) 85 | ) 86 | }; 87 | 88 | let delete_a = ServerAction::<DeleteArticleAction>::new(); 89 | 90 | view! { 91 | <div class="article-meta"> 92 | <A href=profile_ref><img src=move || article.with(|x| x.author.image.clone().unwrap_or_default()) /></A> 93 | <div class="info"> 94 | <A href=profile_ref><span class="author">{move || article.with(|x| x.author.username.to_string())}</span></A> 95 | <span class="date">{move || article.with(|x| x.created_at.to_string())}</span> 96 | </div> 97 | <Show 98 | when=move || is_preview 99 | fallback=move || { 100 | view! { 101 | <Show 102 | when=move || {username.get().unwrap_or_default() == article.with(|x| x.author.username.to_string())} 103 | fallback=move || { 104 | let following = article.with(|x| x.author.following); 105 | let (author, _) = signal(article.with(|x| x.author.username.to_string())); 106 | view!{ 107 | <Show when=move || username.with(Option::is_some) fallback=|| ()> 108 | <ButtonFav username=username article=article /> 109 | <ButtonFollow logged_user=username author following /> 110 | </Show> 111 | }} 112 | > 113 | <A href=editor_ref> 114 | <span class="btn btn-sm btn-outline-secondary"><i class="ion-compose"></i>" Edit article"</span> 115 | </A> 116 | <div class="inline"> 117 | <ActionForm action=delete_a> 118 | <input type="hidden" name="slug" value=move || article.with(|x| x.slug.to_string()) /> 119 | <button type="submit" class="btn btn-sm btn-outline-secondary"> 120 | <i class="ion-trash-a"></i>" Delete article" 121 | </button> 122 | </ActionForm> 123 | </div> 124 | </Show> 125 | } 126 | } 127 | > 128 | <ButtonFav username=username article=article /> 129 | </Show> 130 | </div> 131 | } 132 | } 133 | 134 | #[server(DeleteArticleAction, "/api")] 135 | #[tracing::instrument] 136 | pub async fn delete_article(slug: String) -> Result<(), ServerFnError> { 137 | let Some(logged_user) = crate::auth::get_username() else { 138 | return Err(ServerFnError::ServerError("you must be logged in".into())); 139 | }; 140 | let redirect_profile = format!("/profile/{logged_user}"); 141 | 142 | crate::models::Article::delete(slug, logged_user) 143 | .await 144 | .map(move |_| { 145 | leptos_axum::redirect(&redirect_profile); 146 | }) 147 | .map_err(|x| { 148 | let err = format!("Error while deleting an article: {x:?}"); 149 | tracing::error!("{err}"); 150 | ServerFnError::ServerError("Could not delete the article, try again later".into()) 151 | }) 152 | } 153 | -------------------------------------------------------------------------------- /src/components/buttons.rs: -------------------------------------------------------------------------------- 1 | use leptos::prelude::*; 2 | 3 | #[server(FollowAction, "/api")] 4 | #[tracing::instrument] 5 | pub async fn follow_action(other_user: String) -> Result<bool, ServerFnError> { 6 | let Some(username) = crate::auth::get_username() else { 7 | return Err(ServerFnError::ServerError( 8 | "You need to be authenticated".into(), 9 | )); 10 | }; 11 | toggle_follow(username, other_user).await.map_err(|x| { 12 | tracing::error!("problem while updating the database: {x:?}"); 13 | ServerFnError::ServerError("error while updating the follow".into()) 14 | }) 15 | } 16 | 17 | #[cfg(feature = "ssr")] 18 | #[tracing::instrument] 19 | async fn toggle_follow(current: String, other: String) -> Result<bool, sqlx::Error> { 20 | let db = crate::database::get_db(); 21 | match sqlx::query!( 22 | "SELECT * FROM Follows WHERE follower=$1 and influencer=$2", 23 | current, 24 | other 25 | ) 26 | .fetch_one(db) 27 | .await 28 | { 29 | Ok(_) => sqlx::query!( 30 | "DELETE FROM Follows WHERE follower=$1 and influencer=$2", 31 | current, 32 | other 33 | ) 34 | .execute(db) 35 | .await 36 | .map(|_| false), 37 | Err(sqlx::error::Error::RowNotFound) => sqlx::query!( 38 | "INSERT INTO Follows(follower, influencer) VALUES ($1, $2)", 39 | current, 40 | other 41 | ) 42 | .execute(db) 43 | .await 44 | .map(|_| true), 45 | Err(x) => Err(x), 46 | } 47 | } 48 | 49 | #[component] 50 | pub fn ButtonFollow( 51 | logged_user: crate::auth::UsernameSignal, 52 | author: ReadSignal<String>, 53 | following: bool, 54 | ) -> impl IntoView { 55 | let follow = ServerAction::<FollowAction>::new(); 56 | let result_call = follow.value(); 57 | let follow_cond = move || { 58 | if let Some(x) = result_call.get() { 59 | match x { 60 | Ok(x) => x, 61 | Err(err) => { 62 | tracing::error!("problem while following {err:?}"); 63 | following 64 | } 65 | } 66 | } else { 67 | following 68 | } 69 | }; 70 | 71 | view! { 72 | <Show 73 | when=move || logged_user.get().unwrap_or_default() != author.get() 74 | fallback=|| () 75 | > 76 | <div class="inline pull-xs-right"> 77 | <ActionForm action=follow > 78 | <input type="hidden" name="other_user" value=move || author.get() /> 79 | <button type="submit" class="btn btn-sm btn-outline-secondary"> 80 | <Show 81 | when=follow_cond 82 | fallback=|| view!{<i class="ion-plus-round"></i>" Follow "} 83 | > 84 | <i class="ion-close-round"></i>" Unfollow " 85 | </Show> 86 | {move || author.get()} 87 | </button> 88 | </ActionForm> 89 | </div> 90 | </Show> 91 | } 92 | } 93 | 94 | #[server(FavAction, "/api")] 95 | #[tracing::instrument] 96 | pub async fn fav_action(slug: String) -> Result<bool, ServerFnError> { 97 | let Some(username) = crate::auth::get_username() else { 98 | return Err(ServerFnError::ServerError( 99 | "You need to be authenticated".into(), 100 | )); 101 | }; 102 | toggle_fav(slug, username).await.map_err(|x| { 103 | tracing::error!("problem while updating the database: {x:?}"); 104 | ServerFnError::ServerError("error while updating the follow".into()) 105 | }) 106 | } 107 | 108 | #[cfg(feature = "ssr")] 109 | #[tracing::instrument] 110 | async fn toggle_fav(slug: String, username: String) -> Result<bool, sqlx::Error> { 111 | let db = crate::database::get_db(); 112 | match sqlx::query!( 113 | "SELECT * FROM FavArticles WHERE article=$1 and username=$2", 114 | slug, 115 | username 116 | ) 117 | .fetch_one(db) 118 | .await 119 | { 120 | Ok(_) => sqlx::query!( 121 | "DELETE FROM FavArticles WHERE article=$1 and username=$2", 122 | slug, 123 | username 124 | ) 125 | .execute(db) 126 | .await 127 | .map(|_| false), 128 | Err(sqlx::error::Error::RowNotFound) => sqlx::query!( 129 | "INSERT INTO FavArticles(article, username) VALUES ($1, $2)", 130 | slug, 131 | username 132 | ) 133 | .execute(db) 134 | .await 135 | .map(|_| true), 136 | Err(x) => Err(x), 137 | } 138 | } 139 | 140 | #[component] 141 | pub fn ButtonFav( 142 | username: crate::auth::UsernameSignal, 143 | article: super::article_preview::ArticleSignal, 144 | ) -> impl IntoView { 145 | let make_fav = ServerAction::<FavAction>::new(); 146 | let result_make_fav = make_fav.value(); 147 | let fav_count = move || { 148 | if let Some(x) = result_make_fav.get() { 149 | match x { 150 | Ok(result) => { 151 | article.update(move |x| { 152 | x.fav = !x.fav; 153 | x.favorites_count = 154 | (x.favorites_count + if result { 1 } else { -1 }).max(0); 155 | }); 156 | } 157 | Err(err) => { 158 | tracing::error!("problem while fav {err:?}"); 159 | } 160 | } 161 | } 162 | article.with(|x| x.favorites_count) 163 | }; 164 | 165 | view! { 166 | <Show 167 | when=move || username.with(Option::is_some) 168 | fallback=move || view!{ 169 | <button class="btn btn-sm btn-outline-primary pull-xs-right"> 170 | <i class="ion-heart"></i> 171 | <span class="counter">" ("{fav_count}")"</span> 172 | </button> 173 | } 174 | > 175 | <div class="inline pull-xs-right"> 176 | <ActionForm action=make_fav> 177 | <input type="hidden" name="slug" value=move || article.with(|x| x.slug.to_string()) /> 178 | <button type="submit" class="btn btn-sm btn-outline-primary"> 179 | <Show 180 | when=move || article.with(|x| x.fav) 181 | fallback=move || {view!{<i class="ion-heart"></i>" Fav "}} 182 | > 183 | <i class="ion-heart-broken"></i>" Unfav " 184 | </Show> 185 | <span class="counter">"("{fav_count}")"</span></button> 186 | </ActionForm> 187 | </div> 188 | </Show> 189 | } 190 | } 191 | -------------------------------------------------------------------------------- /src/components/mod.rs: -------------------------------------------------------------------------------- 1 | mod article_preview; 2 | mod buttons; 3 | mod navitems; 4 | pub(crate) use article_preview::{ArticleMeta, ArticlePreviewList, ArticleSignal}; 5 | pub(crate) use buttons::ButtonFollow; 6 | pub(crate) use navitems::NavItems; 7 | -------------------------------------------------------------------------------- /src/components/navitems.rs: -------------------------------------------------------------------------------- 1 | use crate::auth::*; 2 | use leptos::prelude::*; 3 | use leptos_router::components::A; 4 | 5 | #[component] 6 | pub(crate) fn NavItems(logout: LogoutSignal, username: UsernameSignal) -> impl IntoView { 7 | let profile_label = move || username.get().unwrap_or_default(); 8 | let profile_href = move || format!("/profile/{}", profile_label()); 9 | 10 | view! { 11 | <li class="nav-item"> 12 | <A href="/" exact=true><span class="nav-link"><i class="ion-home"></i>" Home"</span></A> 13 | </li> 14 | <Show when=move || username.with(Option::is_none) fallback=move || { 15 | view!{ 16 | <li class="nav-item"> 17 | <A href="/editor"><span class="nav-link"><i class="ion-compose"></i>" New Article"</span></A> 18 | </li> 19 | <li class="nav-item"> 20 | <A href="/settings"><span class="nav-link"><i class="ion-gear-a"></i>" Settings"</span></A> 21 | </li> 22 | <li class="nav-item"> 23 | <A href=profile_href><span class="nav-link"><i class="ion-person"></i>" "{profile_label}</span></A> 24 | </li> 25 | <li class="nav-item"> 26 | <ActionForm action=logout> 27 | <button class="nav-link" style="background: none; border: none;"> 28 | <i class="ion-log-out"></i>" Logout" 29 | </button> 30 | </ActionForm> 31 | </li> 32 | } 33 | }> 34 | <li class="nav-item"> 35 | <A href="/signup"><span class="nav-link"><i class="ion-plus-round"></i>" Sign up"</span></A> 36 | </li> 37 | <li class="nav-item"> 38 | <A href="/login"><span class="nav-link"><i class="ion-log-in"></i>" Login"</span></A> 39 | </li> 40 | </Show> 41 | } 42 | } 43 | -------------------------------------------------------------------------------- /src/database.rs: -------------------------------------------------------------------------------- 1 | static DB: std::sync::OnceLock<sqlx::PgPool> = std::sync::OnceLock::new(); 2 | 3 | async fn create_pool() -> sqlx::PgPool { 4 | let database_url = std::env::var("DATABASE_URL").expect("no database url specify"); 5 | let pool = sqlx::postgres::PgPoolOptions::new() 6 | .max_connections(4) 7 | .connect(database_url.as_str()) 8 | .await 9 | .expect("could not connect to database_url"); 10 | 11 | sqlx::migrate!() 12 | .run(&pool) 13 | .await 14 | .expect("migrations failed"); 15 | 16 | pool 17 | } 18 | 19 | pub async fn init_db() -> Result<(), sqlx::Pool<sqlx::Postgres>> { 20 | DB.set(create_pool().await) 21 | } 22 | 23 | pub fn get_db<'a>() -> &'a sqlx::PgPool { 24 | DB.get().expect("database unitialized") 25 | } 26 | -------------------------------------------------------------------------------- /src/lib.rs: -------------------------------------------------------------------------------- 1 | #![recursion_limit = "256"] 2 | 3 | pub mod app; 4 | pub(crate) mod auth; 5 | pub(crate) mod components; 6 | #[cfg(feature = "ssr")] 7 | pub(crate) mod database; 8 | pub(crate) mod models; 9 | pub(crate) mod routes; 10 | #[cfg(feature = "ssr")] 11 | pub mod setup; 12 | 13 | #[cfg(feature = "hydrate")] 14 | #[wasm_bindgen::prelude::wasm_bindgen] 15 | pub fn hydrate() { 16 | use app::App; 17 | use leptos::prelude::*; 18 | 19 | tracing_wasm::set_as_global_default(); 20 | console_error_panic_hook::set_once(); 21 | 22 | mount_to_body(move || view! { <App/> }); 23 | } 24 | 25 | #[cfg(feature = "hydrate")] 26 | #[wasm_bindgen::prelude::wasm_bindgen(module = "/js/utils.js")] 27 | extern "C" { 28 | fn decodeJWT(token: String) -> String; 29 | fn emailRegex(email: &str) -> bool; 30 | } 31 | -------------------------------------------------------------------------------- /src/main.rs: -------------------------------------------------------------------------------- 1 | #![recursion_limit = "256"] 2 | 3 | #[cfg(feature = "ssr")] 4 | #[tokio::main] 5 | async fn main() { 6 | realworld_leptos::setup::init_app(None).await; 7 | } 8 | 9 | #[cfg(not(feature = "ssr"))] 10 | pub fn main() { 11 | // no client-side main function 12 | // unless we want this to work with e.g., Trunk for pure client-side testing 13 | // see lib.rs for hydration function instead 14 | } 15 | -------------------------------------------------------------------------------- /src/models/article.rs: -------------------------------------------------------------------------------- 1 | use super::UserPreview; 2 | use serde::{Deserialize, Serialize}; 3 | 4 | #[derive(Serialize, Deserialize, Clone, Default)] 5 | pub struct Article { 6 | pub slug: String, 7 | pub title: String, 8 | #[serde(skip_serializing_if = "Option::is_none")] 9 | pub body: Option<String>, 10 | pub description: String, 11 | pub created_at: String, 12 | pub favorites_count: i64, 13 | pub tag_list: Vec<String>, 14 | pub author: UserPreview, 15 | pub fav: bool, 16 | } 17 | 18 | impl Article { 19 | #[cfg(feature = "ssr")] 20 | pub async fn for_home_page( 21 | page: i64, 22 | amount: i64, 23 | tag: String, 24 | my_feed: bool, 25 | ) -> Result<Vec<Self>, sqlx::Error> { 26 | let username = crate::auth::get_username(); 27 | sqlx::query!( 28 | " 29 | SELECT 30 | a.slug, 31 | a.title, 32 | a.description, 33 | a.created_at, 34 | (SELECT COUNT(*) FROM FavArticles WHERE article=a.slug) as favorites_count, 35 | u.username, u.image, 36 | EXISTS(SELECT 1 FROM FavArticles WHERE article=a.slug and username=$5) as fav, 37 | EXISTS(SELECT 1 FROM Follows WHERE follower=$5 and influencer=u.username) as following, 38 | (SELECT string_agg(tag, ' ') FROM ArticleTags WHERE article = a.slug) as tag_list 39 | FROM Articles as a 40 | JOIN Users as u ON a.author = u.username 41 | WHERE 42 | CASE WHEN $3!='' THEN a.slug in (SELECT distinct article FROM ArticleTags WHERE tag=$3) 43 | ELSE 1=1 44 | END 45 | AND 46 | CASE WHEN $4 THEN u.username in (SELECT influencer FROM Follows WHERE follower=$5) 47 | ELSE 1=1 48 | END 49 | ORDER BY a.created_at desc 50 | LIMIT $1 OFFSET $2", 51 | amount, 52 | page * amount, 53 | tag, 54 | my_feed, 55 | username, 56 | ) 57 | .map(|x| Self { 58 | slug: x.slug, 59 | title: x.title, 60 | body: None, // no need 61 | fav: x.fav.unwrap_or_default(), 62 | description: x.description, 63 | created_at: x.created_at.format(super::DATE_FORMAT).to_string(), 64 | favorites_count: x.favorites_count.unwrap_or_default(), 65 | author: UserPreview { 66 | username: x.username, 67 | image: x.image, 68 | following: x.following.unwrap_or_default(), 69 | }, 70 | tag_list: x 71 | .tag_list 72 | .unwrap_or_default() 73 | .split(' ') 74 | .map(ToString::to_string) 75 | .collect::<Vec<String>>(), 76 | }) 77 | .fetch_all(crate::database::get_db()) 78 | .await 79 | } 80 | 81 | #[cfg(feature = "ssr")] 82 | pub async fn for_user_profile( 83 | username: String, 84 | favourites: bool, 85 | ) -> Result<Vec<Self>, sqlx::Error> { 86 | let logged_user = crate::auth::get_username(); 87 | sqlx::query!( 88 | " 89 | SELECT 90 | a.slug, 91 | a.title, 92 | a.description, 93 | a.created_at, 94 | u.username, 95 | u.image, 96 | (SELECT COUNT(*) FROM FavArticles WHERE article=a.slug) as favorites_count, 97 | EXISTS(SELECT 1 FROM FavArticles WHERE article=a.slug and username=$2) as fav, 98 | EXISTS(SELECT 1 FROM Follows WHERE follower=$2 and influencer=a.author) as following, 99 | (SELECT string_agg(tag, ' ') FROM ArticleTags WHERE article = a.slug) as tag_list 100 | FROM Articles as a 101 | JOIN Users as u ON u.username = a.author 102 | WHERE 103 | CASE WHEN $3 THEN 104 | EXISTS(SELECT fa.article, fa.username FROM FavArticles as fa WHERE fa.article=a.slug AND fa.username=$1) 105 | ELSE a.author = $1 106 | END", 107 | username, 108 | logged_user, 109 | favourites, 110 | ) 111 | .map(|x| Self { 112 | slug: x.slug, 113 | title: x.title, 114 | body: None, // no need 115 | fav: x.fav.unwrap_or_default(), 116 | description: x.description, 117 | created_at: x.created_at.format(super::DATE_FORMAT).to_string(), 118 | favorites_count: x.favorites_count.unwrap_or_default(), 119 | tag_list: x 120 | .tag_list 121 | .map(|x| x.split(' ').map(ToString::to_string).collect::<Vec<_>>()) 122 | .unwrap_or_default(), 123 | author: UserPreview { 124 | username: x.username, 125 | image: x.image, 126 | following: x.following.unwrap_or_default(), 127 | }, 128 | }) 129 | .fetch_all(crate::database::get_db()) 130 | .await 131 | } 132 | 133 | #[cfg(feature = "ssr")] 134 | pub async fn for_article(slug: String) -> Result<Self, sqlx::Error> { 135 | let username = crate::auth::get_username(); 136 | sqlx::query!( 137 | " 138 | SELECT 139 | a.*, 140 | (SELECT string_agg(tag, ' ') FROM ArticleTags WHERE article = a.slug) as tag_list, 141 | (SELECT COUNT(*) FROM FavArticles WHERE article = a.slug) as fav_count, 142 | u.*, 143 | EXISTS(SELECT 1 FROM FavArticles WHERE article=a.slug and username=$2) as fav, 144 | EXISTS(SELECT 1 FROM Follows WHERE follower=$2 and influencer=a.author) as following 145 | FROM Articles a 146 | JOIN Users u ON a.author = u.username 147 | WHERE slug = $1 148 | ", 149 | slug, 150 | username, 151 | ) 152 | .map(|x| Self { 153 | slug: x.slug, 154 | title: x.title, 155 | description: x.description, 156 | body: Some(x.body), 157 | tag_list: x 158 | .tag_list 159 | .unwrap_or_default() 160 | .split_ascii_whitespace() 161 | .map(str::to_string) 162 | .collect::<Vec<_>>(), 163 | favorites_count: x.fav_count.unwrap_or_default(), 164 | created_at: x.created_at.format(super::DATE_FORMAT).to_string(), 165 | fav: x.fav.unwrap_or_default(), 166 | author: UserPreview { 167 | username: x.username, 168 | image: x.image, 169 | following: x.following.unwrap_or_default(), 170 | }, 171 | }) 172 | .fetch_one(crate::database::get_db()) 173 | .await 174 | } 175 | 176 | #[cfg(feature = "ssr")] 177 | pub async fn delete( 178 | slug: String, 179 | author: String, 180 | ) -> Result<sqlx::postgres::PgQueryResult, sqlx::Error> { 181 | sqlx::query!( 182 | "DELETE FROM Articles WHERE slug=$1 and author=$2", 183 | slug, 184 | author 185 | ) 186 | .execute(crate::database::get_db()) 187 | .await 188 | } 189 | } 190 | -------------------------------------------------------------------------------- /src/models/comment.rs: -------------------------------------------------------------------------------- 1 | #[derive(Debug, serde::Deserialize, serde::Serialize, Clone)] 2 | pub struct Comment { 3 | pub id: i32, 4 | pub article: String, 5 | pub username: String, 6 | pub body: String, 7 | pub created_at: String, 8 | pub user_image: Option<String>, 9 | } 10 | 11 | impl Comment { 12 | #[cfg(feature = "ssr")] 13 | pub async fn insert( 14 | article: String, 15 | username: String, 16 | body: String, 17 | ) -> Result<sqlx::postgres::PgQueryResult, sqlx::Error> { 18 | sqlx::query!( 19 | "INSERT INTO Comments(article, username, body) VALUES ($1, $2, $3)", 20 | article, 21 | username, 22 | body 23 | ) 24 | .execute(crate::database::get_db()) 25 | .await 26 | } 27 | 28 | #[cfg(feature = "ssr")] 29 | pub async fn get_all(article: String) -> Result<Vec<Self>, sqlx::Error> { 30 | sqlx::query!( 31 | " 32 | SELECT c.*, u.image FROM Comments as c 33 | JOIN Users as u ON u.username=c.username 34 | WHERE c.article=$1 35 | ORDER BY c.created_at", 36 | article 37 | ) 38 | .map(|x| Self { 39 | id: x.id, 40 | article: x.article, 41 | username: x.username, 42 | body: x.body, 43 | created_at: x.created_at.format(super::DATE_FORMAT).to_string(), 44 | user_image: x.image, 45 | }) 46 | .fetch_all(crate::database::get_db()) 47 | .await 48 | } 49 | 50 | #[cfg(feature = "ssr")] 51 | pub async fn delete( 52 | id: i32, 53 | user: String, 54 | ) -> Result<sqlx::postgres::PgQueryResult, sqlx::Error> { 55 | sqlx::query!("DELETE FROM Comments WHERE id=$1 and username=$2", id, user) 56 | .execute(crate::database::get_db()) 57 | .await 58 | } 59 | } 60 | -------------------------------------------------------------------------------- /src/models/mod.rs: -------------------------------------------------------------------------------- 1 | mod user; 2 | pub use user::{User, UserPreview}; 3 | mod pagination; 4 | pub use pagination::Pagination; 5 | mod article; 6 | pub use article::Article; 7 | mod comment; 8 | pub use comment::Comment; 9 | 10 | #[cfg(feature = "ssr")] 11 | const DATE_FORMAT: &str = "%d/%m/%Y %H:%M"; 12 | -------------------------------------------------------------------------------- /src/models/pagination.rs: -------------------------------------------------------------------------------- 1 | use leptos::prelude::*; 2 | use leptos_router::params::Params; 3 | 4 | #[derive(Debug, Params, PartialEq, Clone)] 5 | pub struct Pagination { 6 | tag: Option<String>, 7 | my_feed: Option<bool>, 8 | page: Option<u32>, 9 | amount: Option<u32>, 10 | } 11 | 12 | impl Pagination { 13 | #[inline] 14 | pub fn get_tag(&self) -> &str { 15 | self.tag.as_deref().unwrap_or_default() 16 | } 17 | #[inline] 18 | pub fn get_my_feed(&self) -> bool { 19 | self.my_feed.unwrap_or_default() 20 | } 21 | #[inline] 22 | pub fn get_page(&self) -> u32 { 23 | self.page.unwrap_or_default() 24 | } 25 | #[inline] 26 | pub fn get_amount(&self) -> u32 { 27 | self.amount.unwrap_or(10) 28 | } 29 | 30 | #[inline] 31 | pub fn set_tag<T: ToString + ?Sized>(mut self, tag: &T) -> Self { 32 | self.tag = Some(tag.to_string()); 33 | self 34 | } 35 | 36 | #[inline] 37 | pub fn set_amount(mut self, amount: u32) -> Self { 38 | self.amount = Some(amount); 39 | self 40 | } 41 | 42 | #[inline] 43 | pub fn set_my_feed(mut self, feed: bool) -> Self { 44 | self.my_feed = Some(feed); 45 | self 46 | } 47 | 48 | #[inline] 49 | pub fn reset_page(mut self) -> Self { 50 | self.page = Some(0); 51 | self 52 | } 53 | 54 | #[inline] 55 | pub fn next_page(mut self) -> Self { 56 | self.page = Some(self.page.unwrap_or_default().saturating_add(1)); 57 | self 58 | } 59 | 60 | #[inline] 61 | pub fn previous_page(mut self) -> Self { 62 | self.page = Some(self.page.unwrap_or_default().saturating_sub(1)); 63 | self 64 | } 65 | } 66 | 67 | impl Default for Pagination { 68 | fn default() -> Self { 69 | Self { 70 | tag: Some(String::new()), 71 | my_feed: Some(false), 72 | page: Some(0), 73 | amount: Some(10), 74 | } 75 | } 76 | } 77 | 78 | impl std::fmt::Display for Pagination { 79 | fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { 80 | write!( 81 | f, 82 | "/?tag={}&my_feed={}&page={}&amount={}", 83 | self.get_tag(), 84 | self.get_my_feed(), 85 | self.get_page(), 86 | self.get_amount(), 87 | ) 88 | } 89 | } 90 | -------------------------------------------------------------------------------- /src/models/user.rs: -------------------------------------------------------------------------------- 1 | use serde::{Deserialize, Serialize}; 2 | 3 | #[derive(Serialize, Deserialize, Clone, Default)] 4 | pub struct UserPreview { 5 | pub username: String, 6 | pub image: Option<String>, 7 | pub following: bool, 8 | } 9 | 10 | #[derive(Debug, Default, Deserialize, Serialize, Clone)] 11 | pub struct User { 12 | username: String, 13 | #[cfg_attr(feature = "hydrate", allow(dead_code))] 14 | #[serde(skip_serializing)] 15 | password: Option<String>, 16 | email: String, 17 | bio: Option<String>, 18 | image: Option<String>, 19 | } 20 | 21 | #[cfg(feature = "ssr")] 22 | static EMAIL_REGEX: std::sync::OnceLock<regex::Regex> = std::sync::OnceLock::new(); 23 | 24 | impl User { 25 | #[inline] 26 | pub fn username(&self) -> String { 27 | self.username.to_string() 28 | } 29 | #[inline] 30 | pub fn email(&self) -> String { 31 | self.email.to_string() 32 | } 33 | #[inline] 34 | pub fn bio(&self) -> Option<String> { 35 | self.bio.clone() 36 | } 37 | #[inline] 38 | pub fn image(&self) -> Option<String> { 39 | self.image.clone() 40 | } 41 | 42 | pub fn set_password(mut self, password: String) -> Result<Self, String> { 43 | if password.len() < 4 { 44 | return Err("You need to provide a stronger password".into()); 45 | } 46 | self.password = Some(password); 47 | Ok(self) 48 | } 49 | 50 | pub fn set_username(mut self, username: String) -> Result<Self, String> { 51 | if username.len() < 4 { 52 | return Err(format!( 53 | "Username {username} is too short, at least 4 characters" 54 | )); 55 | } 56 | self.username = username; 57 | Ok(self) 58 | } 59 | 60 | #[cfg(feature = "ssr")] 61 | fn validate_email(email: &str) -> bool { 62 | EMAIL_REGEX 63 | .get_or_init(|| regex::Regex::new(r"^[\w\-\.]+@([\w-]+\.)+\w{2,4}$").unwrap()) 64 | .is_match(email) 65 | } 66 | 67 | #[cfg(not(feature = "ssr"))] 68 | fn validate_email(email: &str) -> bool { 69 | crate::emailRegex(email) 70 | } 71 | 72 | pub fn set_email(mut self, email: String) -> Result<Self, String> { 73 | if !Self::validate_email(&email) { 74 | return Err(format!( 75 | "The email {email} is invalid, provide a correct one" 76 | )); 77 | } 78 | self.email = email; 79 | Ok(self) 80 | } 81 | 82 | pub fn set_bio(mut self, bio: String) -> Result<Self, String> { 83 | static BIO_MIN: usize = 10; 84 | if bio.is_empty() { 85 | self.bio = None; 86 | } else if bio.len() < BIO_MIN { 87 | return Err("bio too short, at least 10 characters".into()); 88 | } else { 89 | self.bio = Some(bio); 90 | } 91 | Ok(self) 92 | } 93 | 94 | #[inline] 95 | pub fn set_image(mut self, image: String) -> Result<Self, String> { 96 | if image.is_empty() { 97 | self.image = None; 98 | // TODO: This is incorrect! changeme in the future for a proper validation 99 | } else if !image.starts_with("http") { 100 | return Err("Invalid image!".into()); 101 | } else { 102 | self.image = Some(image); 103 | } 104 | Ok(self) 105 | } 106 | 107 | #[cfg(feature = "ssr")] 108 | pub async fn get(username: String) -> Result<Self, sqlx::Error> { 109 | sqlx::query_as!( 110 | Self, 111 | "SELECT username, email, bio, image, NULL as password FROM users WHERE username=$1", 112 | username 113 | ) 114 | .fetch_one(crate::database::get_db()) 115 | .await 116 | } 117 | 118 | #[cfg(feature = "ssr")] 119 | pub async fn get_email(email: String) -> Result<Self, sqlx::Error> { 120 | sqlx::query_as!( 121 | Self, 122 | "SELECT username, email, bio, image, NULL as password FROM users WHERE email=$1", 123 | email 124 | ) 125 | .fetch_one(crate::database::get_db()) 126 | .await 127 | } 128 | 129 | #[cfg(feature = "ssr")] 130 | pub async fn insert(&self) -> Result<sqlx::postgres::PgQueryResult, sqlx::Error> { 131 | sqlx::query!( 132 | "INSERT INTO Users(username, email, password) VALUES ($1, $2, crypt($3, gen_salt('bf')))", 133 | self.username, 134 | self.email, 135 | self.password, 136 | ) 137 | .execute(crate::database::get_db()) 138 | .await 139 | } 140 | 141 | #[cfg(feature = "ssr")] 142 | pub async fn update(&self) -> Result<sqlx::postgres::PgQueryResult, sqlx::Error> { 143 | sqlx::query!( 144 | " 145 | UPDATE Users SET 146 | image=$2, 147 | bio=$3, 148 | email=$4, 149 | password=CASE WHEN $5 THEN crypt($6, gen_salt('bf')) ELSE password END 150 | WHERE username=$1", 151 | self.username, 152 | self.image, 153 | self.bio, 154 | self.email, 155 | self.password.is_some(), 156 | self.password, 157 | ) 158 | .execute(crate::database::get_db()) 159 | .await 160 | } 161 | } 162 | -------------------------------------------------------------------------------- /src/routes/article.rs: -------------------------------------------------------------------------------- 1 | use leptos::prelude::*; 2 | use leptos_meta::*; 3 | use leptos_router::{components::A, hooks::use_params_map}; 4 | 5 | use crate::components::ArticleMeta; 6 | 7 | #[derive(serde::Deserialize, serde::Serialize, Clone, Default)] 8 | pub struct ArticleResult { 9 | pub(super) article: crate::models::Article, 10 | pub(super) logged_user: Option<crate::models::User>, 11 | } 12 | 13 | #[server(GetArticleAction, "/api", "GetJson")] 14 | #[tracing::instrument] 15 | pub async fn get_article(slug: String) -> Result<ArticleResult, ServerFnError> { 16 | Ok(ArticleResult { 17 | article: crate::models::Article::for_article(slug) 18 | .await 19 | .map_err(|x| { 20 | let err = format!("Error while getting user_profile articles: {x:?}"); 21 | tracing::error!("{err}"); 22 | ServerFnError::new("Could not retrieve articles, try again later") 23 | })?, 24 | logged_user: crate::auth::current_user().await.ok(), 25 | }) 26 | } 27 | 28 | #[tracing::instrument] 29 | #[component] 30 | pub fn Article(username: crate::auth::UsernameSignal) -> impl IntoView { 31 | let params = use_params_map(); 32 | let article = Resource::new( 33 | move || params.get().get("slug").clone().unwrap_or_default(), 34 | |slug| async { get_article(slug).await }, 35 | ); 36 | 37 | let title = RwSignal::new(String::from("Loading")); 38 | 39 | view! { 40 | <Title text=move || title.get()/> 41 | 42 | <Suspense fallback=move || view! { <p>"Loading Article"</p> }> 43 | <ErrorBoundary fallback=|_| { 44 | view! { <p class="error-messages text-xs-center">"Something went wrong, please try again later."</p>} 45 | }> 46 | {move || { 47 | article.get().map(move |x| { 48 | x.map(move |article_result| { 49 | title.set(article_result.article.slug.to_string()); 50 | view! { 51 | <ArticlePage username result=article_result /> 52 | } 53 | }) 54 | }) 55 | }} 56 | </ErrorBoundary> 57 | </Suspense> 58 | } 59 | } 60 | 61 | #[component] 62 | fn ArticlePage(username: crate::auth::UsernameSignal, result: ArticleResult) -> impl IntoView { 63 | let article_signal = RwSignal::new(result.article.clone()); 64 | let user_signal = RwSignal::new(result.logged_user); 65 | let tag_list = result.article.tag_list; 66 | 67 | view! { 68 | <div class="article-page"> 69 | <div class="banner"> 70 | <div class="container"> 71 | <h1>{result.article.title}</h1> 72 | <ArticleMeta username article=article_signal is_preview=false /> 73 | </div> 74 | </div> 75 | 76 | <div class="container page"> 77 | <div class="row article-content"> 78 | <div class="col-md-12"> 79 | <p>{result.article.body}</p> 80 | </div> 81 | </div> 82 | 83 | <ul class="tag-list"> 84 | <For 85 | each=move || tag_list.clone().into_iter().enumerate() 86 | key=|(i, _)| *i 87 | children=|(_, a)| {view!{<li class="tag-default tag-pill tag-outline">{a}</li>}} 88 | /> 89 | </ul> 90 | 91 | <hr /> 92 | 93 | <div class="article-actions"> 94 | <div class="row" style="justify-content: center;"> 95 | <ArticleMeta username article=article_signal is_preview=false /> 96 | </div> 97 | </div> 98 | 99 | <div class="row"> 100 | <CommentSection username article=article_signal user=user_signal /> 101 | </div> 102 | </div> 103 | </div> 104 | } 105 | } 106 | 107 | #[server(PostCommentAction, "/api")] 108 | #[tracing::instrument] 109 | pub async fn post_comment(slug: String, body: String) -> Result<(), ServerFnError> { 110 | let Some(logged_user) = crate::auth::get_username() else { 111 | return Err(ServerFnError::ServerError("you must be logged in".into())); 112 | }; 113 | 114 | crate::models::Comment::insert(slug, logged_user, body) 115 | .await 116 | .map(|_| ()) 117 | .map_err(|x| { 118 | let err = format!("Error while posting a comment: {x:?}"); 119 | tracing::error!("{err}"); 120 | ServerFnError::ServerError("Could not post a comment, try again later".into()) 121 | }) 122 | } 123 | 124 | #[server(GetCommentsAction, "/api", "GetJson")] 125 | #[tracing::instrument] 126 | pub async fn get_comments(slug: String) -> Result<Vec<crate::models::Comment>, ServerFnError> { 127 | crate::models::Comment::get_all(slug).await.map_err(|x| { 128 | let err = format!("Error while posting a comment: {x:?}"); 129 | tracing::error!("{err}"); 130 | ServerFnError::ServerError("Could not post a comment, try again later".into()) 131 | }) 132 | } 133 | 134 | #[server(DeleteCommentsAction, "/api")] 135 | #[tracing::instrument] 136 | pub async fn delete_comment(id: i32) -> Result<(), ServerFnError> { 137 | let Some(logged_user) = crate::auth::get_username() else { 138 | return Err(ServerFnError::ServerError("you must be logged in".into())); 139 | }; 140 | 141 | crate::models::Comment::delete(id, logged_user) 142 | .await 143 | .map(|_| ()) 144 | .map_err(|x| { 145 | let err = format!("Error while posting a comment: {x:?}"); 146 | tracing::error!("{err}"); 147 | ServerFnError::ServerError("Could not post a comment, try again later".into()) 148 | }) 149 | } 150 | 151 | #[component] 152 | fn CommentSection( 153 | username: crate::auth::UsernameSignal, 154 | article: crate::components::ArticleSignal, 155 | user: RwSignal<Option<crate::models::User>>, 156 | ) -> impl IntoView { 157 | let comments_action = ServerAction::<PostCommentAction>::new(); 158 | let result = comments_action.version(); 159 | let reset_comment = RwSignal::new(""); 160 | let comments = Resource::new( 161 | move || (result.get(), article.with(|a| a.slug.to_string())), 162 | move |(_, a)| async move { 163 | reset_comment.set(""); 164 | get_comments(a).await.unwrap_or_else(|_| vec![]) 165 | }, 166 | ); 167 | 168 | view! { 169 | <div class="col-xs-12 col-md-8 offset-md-2"> 170 | <Show when=move || username.with(Option::is_some) fallback=|| ()> 171 | <div class="card comment-form"> 172 | <ActionForm action=comments_action> 173 | <input name="slug" type="hidden" value=move || article.with(|x| x.slug.to_string()) /> 174 | <div class="card-block"> 175 | <textarea name="body" prop:value=move || reset_comment.get() class="form-control" placeholder="Write a comment..." rows="3"></textarea> 176 | </div> 177 | <div class="card-footer"> 178 | <img src=move || user.with(|x| x.as_ref().map(crate::models::User::image).unwrap_or_default()) class="comment-author-img" /> 179 | <button class="btn btn-sm btn-primary" type="submit"> 180 | "Post Comment" 181 | </button> 182 | </div> 183 | </ActionForm> 184 | </div> 185 | </Show> 186 | <Suspense fallback=move || view! {<p>"Loading Comments from the article"</p> }> 187 | <ErrorBoundary fallback=|_| { 188 | view! { <p class="error-messages text-xs-center">"Something went wrong."</p>} 189 | }> 190 | {move || comments.get().map(move |c| { 191 | view! { 192 | <For each=move || c.clone().into_iter().enumerate() 193 | key=|(i, _)| *i 194 | children=move |(_, comment)| { 195 | let comment = RwSignal::new(comment); 196 | view!{<Comment username comment comments />} 197 | }/> 198 | } 199 | })} 200 | </ErrorBoundary> 201 | </Suspense> 202 | </div> 203 | } 204 | } 205 | 206 | #[component] 207 | fn Comment( 208 | username: crate::auth::UsernameSignal, 209 | comment: RwSignal<crate::models::Comment>, 210 | comments: Resource<Vec<crate::models::Comment>>, 211 | ) -> impl IntoView { 212 | let user_link = move || format!("/profile/{}", comment.with(|x| x.username.to_string())); 213 | let user_image = move || comment.with(|x| x.user_image.clone().unwrap_or_default()); 214 | let delete_c = ServerAction::<DeleteCommentsAction>::new(); 215 | let delete_result = delete_c.value(); 216 | 217 | Effect::new(move |_| { 218 | if let Some(Ok(())) = delete_result.get() { 219 | tracing::info!("comment deleted!"); 220 | comments.refetch(); 221 | } 222 | }); 223 | 224 | view! { 225 | <div class="card"> 226 | <div class="card-block"> 227 | <p class="card-text">{move || comment.with(|x| x.body.to_string())}</p> 228 | </div> 229 | <div class="card-footer"> 230 | <A href=user_link><span class="comment-author"> 231 | <img src=user_image class="comment-author-img" /> 232 | </span></A> 233 | " " 234 | <A href=user_link><span class="comment-author">{move || comment.with(|x| x.username.to_string())}</span></A> 235 | <span class="date-posted">{move || comment.with(|x| x.created_at.to_string())}</span> 236 | <Show 237 | when=move || {username.get().unwrap_or_default() == comment.with(|x| x.username.to_string())} 238 | fallback=|| ()> 239 | <div class="comment-author"> 240 | <ActionForm action=delete_c> 241 | <input type="hidden" name="id" value=move || comment.with(|x| x.id) /> 242 | <button class="btn btn-sm" type="submit"><i class="ion-trash-b"></i></button> 243 | </ActionForm> 244 | </div> 245 | </Show> 246 | </div> 247 | </div> 248 | } 249 | } 250 | -------------------------------------------------------------------------------- /src/routes/editor.rs: -------------------------------------------------------------------------------- 1 | use leptos::prelude::*; 2 | use leptos_meta::*; 3 | use leptos_router::hooks::use_params_map; 4 | 5 | #[derive(serde::Deserialize, Clone, serde::Serialize)] 6 | pub enum EditorResponse { 7 | ValidationError(String), 8 | UpdateError, 9 | Successful(String), 10 | } 11 | 12 | #[cfg_attr(feature = "hydrate", allow(dead_code))] 13 | #[derive(Debug)] 14 | struct ArticleUpdate { 15 | title: String, 16 | description: String, 17 | body: String, 18 | tag_list: std::collections::HashSet<String>, 19 | } 20 | 21 | const TITLE_MIN_LENGTH: usize = 4; 22 | const DESCRIPTION_MIN_LENGTH: usize = 4; 23 | const BODY_MIN_LENGTH: usize = 10; 24 | 25 | #[cfg(feature = "ssr")] 26 | #[tracing::instrument] 27 | fn validate_article( 28 | title: String, 29 | description: String, 30 | body: String, 31 | tag_list: String, 32 | ) -> Result<ArticleUpdate, String> { 33 | if title.len() < TITLE_MIN_LENGTH { 34 | return Err("You need to provide a title with at least 4 characters".into()); 35 | } 36 | 37 | if description.len() < DESCRIPTION_MIN_LENGTH { 38 | return Err("You need to provide a description with at least 4 characters".into()); 39 | } 40 | 41 | if body.len() < BODY_MIN_LENGTH { 42 | return Err("You need to provide a body with at least 10 characters".into()); 43 | } 44 | 45 | let tag_list = tag_list 46 | .trim() 47 | .split_ascii_whitespace() 48 | .filter(|x| !x.is_empty()) 49 | .map(str::to_string) 50 | .collect::<std::collections::HashSet<String>>(); 51 | Ok(ArticleUpdate { 52 | title, 53 | description, 54 | body, 55 | tag_list, 56 | }) 57 | } 58 | 59 | #[cfg(feature = "ssr")] 60 | #[tracing::instrument] 61 | async fn update_article( 62 | author: String, 63 | slug: String, 64 | article: ArticleUpdate, 65 | ) -> Result<String, sqlx::Error> { 66 | static BIND_LIMIT: usize = 65535; 67 | let mut transaction = crate::database::get_db().begin().await?; 68 | let (rows_affected, slug) = if !slug.is_empty() { 69 | ( 70 | sqlx::query!( 71 | "UPDATE Articles SET title=$1, description=$2, body=$3 WHERE slug=$4 and author=$5", 72 | article.title, 73 | article.description, 74 | article.body, 75 | slug, 76 | author, 77 | ) 78 | .execute(transaction.as_mut()) 79 | .await? 80 | .rows_affected(), 81 | slug.to_string(), 82 | ) 83 | } else { 84 | // The slug is derived from the title 85 | let slug = article 86 | .title 87 | .chars() 88 | .map(|c| { 89 | let c = c.to_ascii_lowercase(); 90 | if c == ' ' { 91 | '-' 92 | } else { 93 | c 94 | } 95 | }) 96 | .filter(|c| c.is_ascii_alphanumeric() || *c == '-') 97 | .collect::<String>(); 98 | (sqlx::query!( 99 | "INSERT INTO Articles(slug, title, description, body, author) VALUES ($1, $2, $3, $4, $5)", 100 | slug, 101 | article.title, 102 | article.description, 103 | article.body, 104 | author 105 | ) 106 | .execute(transaction.as_mut()) 107 | .await?.rows_affected(), 108 | slug) 109 | }; 110 | if rows_affected != 1 { 111 | // We are going to modify just one row, otherwise something funky is going on 112 | tracing::error!("no rows affected"); 113 | return Err(sqlx::Error::RowNotFound); 114 | } 115 | sqlx::query!("DELETE FROM ArticleTags WHERE article=$1", slug) 116 | .execute(transaction.as_mut()) 117 | .await?; 118 | if !article.tag_list.is_empty() { 119 | let mut qb = sqlx::QueryBuilder::new("INSERT INTO ArticleTags(article, tag) "); 120 | qb.push_values( 121 | article.tag_list.clone().into_iter().take(BIND_LIMIT / 2), 122 | |mut b, tag| { 123 | b.push_bind(slug.clone()).push_bind(tag); 124 | }, 125 | ); 126 | qb.build().execute(transaction.as_mut()).await?; 127 | } 128 | 129 | transaction.commit().await?; 130 | Ok(slug) 131 | } 132 | 133 | #[server(EditorAction, "/api")] 134 | #[tracing::instrument] 135 | pub async fn editor_action( 136 | title: String, 137 | description: String, 138 | body: String, 139 | tag_list: String, 140 | slug: String, 141 | ) -> Result<EditorResponse, ServerFnError> { 142 | let Some(author) = crate::auth::get_username() else { 143 | leptos_axum::redirect("/login"); 144 | return Ok(EditorResponse::ValidationError( 145 | "you should be authenticated".to_string(), 146 | )); 147 | }; 148 | let article = match validate_article(title, description, body, tag_list) { 149 | Ok(x) => x, 150 | Err(x) => return Ok(EditorResponse::ValidationError(x)), 151 | }; 152 | match update_article(author, slug, article).await { 153 | Ok(x) => { 154 | leptos_axum::redirect(&format!("/article/{x}")); 155 | Ok(EditorResponse::Successful(x)) 156 | } 157 | Err(x) => { 158 | tracing::error!("EDITOR ERROR: {}", x.to_string()); 159 | Ok(EditorResponse::UpdateError) 160 | } 161 | } 162 | } 163 | 164 | #[tracing::instrument] 165 | #[component] 166 | pub fn Editor() -> impl IntoView { 167 | let editor_server_action = ServerAction::<EditorAction>::new(); 168 | let result = editor_server_action.value(); 169 | let error = move || { 170 | result.with(|x| { 171 | x.as_ref() 172 | .is_none_or(|y| y.is_err() || !matches!(y, Ok(EditorResponse::Successful(_)))) 173 | }) 174 | }; 175 | 176 | let params = use_params_map(); 177 | let article_res = Resource::new( 178 | move || params.get(), 179 | |slug| async move { 180 | if let Some(s) = slug.get("slug") { 181 | super::get_article(s.to_string()).await 182 | } else { 183 | Ok(super::ArticleResult::default()) 184 | } 185 | }, 186 | ); 187 | 188 | view! { 189 | <Title text="Editor"/> 190 | <div class="editor-page"> 191 | <div class="container page"> 192 | <div class="row"> 193 | <p class="text-xs-center" 194 | class:text-success=move || !error() 195 | class:error-messages=error 196 | > 197 | <strong> 198 | {move || result.with(|x| { 199 | let Some(x) = x else { 200 | return String::new(); 201 | }; 202 | match x { 203 | Ok(EditorResponse::ValidationError(x)) => { 204 | format!("Problem while validating: {x}") 205 | } 206 | Ok(EditorResponse::UpdateError) => { 207 | "Error while updating the article, please, try again later".into() 208 | } 209 | Ok(EditorResponse::Successful(_)) => { 210 | String::new() 211 | } 212 | Err(x) => format!("Unexpected error: {x}"), 213 | } 214 | })} 215 | </strong> 216 | </p> 217 | 218 | <div class="col-md-10 offset-md-1 col-xs-12"> 219 | <ActionForm action=editor_server_action> 220 | <Suspense fallback=move || view! {<p>"Loading Tags"</p> }> 221 | <ErrorBoundary fallback=|_| { 222 | view! { <p class="error-messages text-xs-center">"Something went wrong."</p>} 223 | }> 224 | {move || article_res.get().map(move |x| x.map(move |a| { 225 | view! { 226 | <fieldset> 227 | <fieldset class="form-group"> 228 | <input name="title" type="text" class="form-control form-control-lg" minlength=TITLE_MIN_LENGTH 229 | placeholder="Article Title" value=a.article.title /> 230 | </fieldset> 231 | <fieldset class="form-group"> 232 | <input name="description" type="text" class="form-control" minlength=DESCRIPTION_MIN_LENGTH 233 | placeholder="What's this article about?" value=a.article.description /> 234 | </fieldset> 235 | <fieldset class="form-group"> 236 | <textarea name="body" class="form-control" rows="8" 237 | placeholder="Write your article (in markdown)" minlength=BODY_MIN_LENGTH 238 | prop:value=a.article.body.unwrap_or_default()></textarea> 239 | </fieldset> 240 | <fieldset class="form-group"> 241 | <input name="tag_list" type="text" class="form-control" 242 | placeholder="Enter tags(space separated)" value=a.article.tag_list.join(" ") /> 243 | </fieldset> 244 | <input name="slug" type="hidden" value=a.article.slug /> 245 | <button class="btn btn-lg pull-xs-right btn-primary" type="submit"> 246 | "Publish Article" 247 | </button> 248 | </fieldset> 249 | } 250 | }))} 251 | </ErrorBoundary> 252 | </Suspense> 253 | </ActionForm> 254 | </div> 255 | </div> 256 | </div> 257 | </div> 258 | } 259 | } 260 | -------------------------------------------------------------------------------- /src/routes/home.rs: -------------------------------------------------------------------------------- 1 | use leptos::prelude::*; 2 | use leptos_meta::*; 3 | use leptos_router::hooks::use_query; 4 | 5 | use crate::components::ArticlePreviewList; 6 | 7 | #[server(HomeAction, "/api", "GetJson")] 8 | async fn home_articles( 9 | page: u32, 10 | amount: u32, 11 | tag: String, 12 | my_feed: bool, 13 | ) -> Result<Vec<crate::models::Article>, ServerFnError> { 14 | let page = i64::from(page); 15 | let amount = i64::from(amount); 16 | 17 | crate::models::Article::for_home_page(page, amount, tag, my_feed) 18 | .await 19 | .map_err(|x| { 20 | tracing::error!("problem while fetching home articles: {x:?}"); 21 | ServerFnError::new("Problem while fetching home articles") 22 | }) 23 | } 24 | 25 | #[server(GetTagsAction, "/api", "GetJson")] 26 | async fn get_tags() -> Result<Vec<String>, ServerFnError> { 27 | sqlx::query!("SELECT DISTINCT tag FROM ArticleTags") 28 | .map(|x| x.tag) 29 | .fetch_all(crate::database::get_db()) 30 | .await 31 | .map_err(|x| { 32 | tracing::error!("problem while fetching tags: {x:?}"); 33 | ServerFnError::ServerError("Problem while fetching tags".into()) 34 | }) 35 | } 36 | 37 | /// Renders the home page of your application. 38 | #[component] 39 | pub fn HomePage(username: crate::auth::UsernameSignal) -> impl IntoView { 40 | let pagination = use_query::<crate::models::Pagination>(); 41 | 42 | let articles = Resource::new( 43 | move || pagination.get().unwrap_or_default(), 44 | move |pagination| async move { 45 | tracing::debug!("making another request: {pagination:?}"); 46 | home_articles( 47 | pagination.get_page(), 48 | pagination.get_amount(), 49 | pagination.get_tag().to_string(), 50 | pagination.get_my_feed(), 51 | ) 52 | .await 53 | .unwrap_or_else(|_| vec![]) 54 | }, 55 | ); 56 | 57 | let your_feed_href = move || { 58 | if username.with(Option::is_some) 59 | && !pagination.with(|x| { 60 | x.as_ref() 61 | .map(crate::models::Pagination::get_my_feed) 62 | .unwrap_or_default() 63 | }) 64 | { 65 | pagination 66 | .get() 67 | .unwrap_or_default() 68 | .reset_page() 69 | .set_my_feed(true) 70 | .to_string() 71 | } else { 72 | String::new() 73 | } 74 | }; 75 | let your_feed_class = move || { 76 | tracing::debug!("set class_my_feed"); 77 | format!( 78 | "nav-link {}", 79 | if username.with(Option::is_none) { 80 | "disabled" 81 | } else if pagination.with(|x| x 82 | .as_ref() 83 | .map(crate::models::Pagination::get_my_feed) 84 | .unwrap_or_default()) 85 | { 86 | "active" 87 | } else { 88 | "" 89 | } 90 | ) 91 | }; 92 | 93 | view! { 94 | <Title text="Home"/> 95 | 96 | <div class="home-page"> 97 | <div class="banner"> 98 | <div class="container"> 99 | <h1 class="logo-font">conduit</h1> 100 | <p>"A place to share your knowledge."</p> 101 | </div> 102 | </div> 103 | 104 | <div class="container page"> 105 | <div class="row"> 106 | <div class="col-md-9"> 107 | <div class="feed-toggle"> 108 | <ul class="nav nav-pills outline-active"> 109 | <li class="nav-item"> 110 | <a href=your_feed_href class=your_feed_class> 111 | "Your Feed" 112 | </a> 113 | </li> 114 | <li class="nav-item"> 115 | <a class="nav-link" 116 | class:active=move || !pagination.with(|x| x.as_ref().map(crate::models::Pagination::get_my_feed).unwrap_or_default()) 117 | href=move || pagination.get().unwrap_or_default().reset_page().set_my_feed(false).to_string()> 118 | "Global Feed" 119 | </a> 120 | </li> 121 | <li class="nav-item pull-xs-right"> 122 | <div style="display: inline-block;"> 123 | "Articles to display | " 124 | <a href=move || pagination.get().unwrap_or_default().reset_page().set_amount(1).to_string() class="btn btn-primary">"1"</a> 125 | <a href=move || pagination.get().unwrap_or_default().reset_page().set_amount(20).to_string() class="btn btn-primary">"20"</a> 126 | <a href=move || pagination.get().unwrap_or_default().reset_page().set_amount(50).to_string() class="btn btn-primary">"50"</a> 127 | </div> 128 | </li> 129 | </ul> 130 | </div> 131 | 132 | <ArticlePreviewList username=username articles=articles/> 133 | </div> 134 | 135 | <div class="col-md-3"> 136 | <div class="sidebar"> 137 | <h4>"Popular Tags"</h4> 138 | <TagList /> 139 | </div> 140 | </div> 141 | 142 | <ul class="pagination"> 143 | <Show 144 | when=move || {pagination.with(|x| x.as_ref().map(crate::models::Pagination::get_page).unwrap_or_default()) > 0} 145 | fallback=|| () 146 | > 147 | <li class="page-item"> 148 | <a class="btn btn-primary" href=move || pagination.get().unwrap_or_default().previous_page().to_string()> 149 | "<< Previous page" 150 | </a> 151 | </li> 152 | </Show> 153 | <Suspense fallback=|| ()> 154 | <Show 155 | // TODO: fix this dummy logic 156 | when=move || { 157 | let n_articles = articles.with(|x| x.as_ref().map_or(0, |y| y.len())); 158 | n_articles > 0 && n_articles >= 159 | pagination.with(|x| x.as_ref().map(crate::models::Pagination::get_amount).unwrap_or_default()) as usize 160 | } 161 | fallback=|| () 162 | > 163 | <li class="page-item"> 164 | <a class="btn btn-primary" href=move || pagination.get().unwrap_or_default().next_page().to_string()> 165 | "Next page >>" 166 | </a> 167 | </li> 168 | </Show> 169 | </Suspense> 170 | </ul> 171 | </div> 172 | </div> 173 | </div> 174 | } 175 | } 176 | 177 | #[component] 178 | fn TagList() -> impl IntoView { 179 | let pagination = use_query::<crate::models::Pagination>(); 180 | let tag_list = Resource::new(|| (), |_| async { get_tags().await }); 181 | 182 | // TODO: Wonder if it's possible to reduce reduce the 2x clone 183 | let tag_view = move || { 184 | let tag_elected = pagination.with(|x| { 185 | x.as_ref() 186 | .map(crate::models::Pagination::get_tag) 187 | .unwrap_or_default() 188 | .to_string() 189 | }); 190 | tag_list.get().map(move |ts| { 191 | ts.map(move |tags| { 192 | view! { 193 | <For 194 | each=move || tags.clone().into_iter().enumerate() 195 | key=|(i, _)| *i 196 | children=move |(_, t): (usize, String)| { 197 | let t2 = t.to_string(); 198 | let same = t2 == tag_elected; 199 | view!{ 200 | <a class="tag-pill tag-default" class:tag-primary=same 201 | href=move || pagination.get().unwrap_or_default().set_tag(if same {""} else {&t2}).to_string()> 202 | {t} 203 | </a> 204 | } 205 | } 206 | /> 207 | } 208 | }) 209 | }) 210 | }; 211 | 212 | view! { 213 | <div class="tag-list"> 214 | <Suspense fallback=move || view! {<p>"Loading Tags"</p> }> 215 | <ErrorBoundary fallback=|_| { 216 | view! { <p class="error-messages text-xs-center">"Something went wrong."</p>} 217 | }> 218 | {tag_view} 219 | </ErrorBoundary> 220 | </Suspense> 221 | </div> 222 | } 223 | } 224 | -------------------------------------------------------------------------------- /src/routes/login.rs: -------------------------------------------------------------------------------- 1 | use leptos::prelude::*; 2 | use leptos_meta::*; 3 | use leptos_router::components::A; 4 | 5 | use crate::auth::{LoginMessages, LoginSignal}; 6 | 7 | #[component] 8 | pub fn Login(login: LoginSignal) -> impl IntoView { 9 | let result_of_call = login.value(); 10 | 11 | let error = move || { 12 | result_of_call.with(|msg| { 13 | msg.as_ref() 14 | .map(|inner| match inner { 15 | Ok(LoginMessages::Unsuccessful) => "Incorrect user or password", 16 | Ok(LoginMessages::Successful) => { 17 | tracing::info!("login success!"); 18 | "Done" 19 | } 20 | Err(x) => { 21 | tracing::error!("Problem during login: {x:?}"); 22 | "There was a problem, try again later" 23 | } 24 | }) 25 | .unwrap_or_default() 26 | }) 27 | }; 28 | 29 | view! { 30 | <Title text="Login"/> 31 | <div class="auth-page"> 32 | <div class="container page"> 33 | <div class="row"> 34 | <div class="col-md-6 offset-md-3 col-xs-12"> 35 | <h1 class="text-xs-center">"Login"</h1> 36 | 37 | <p class="error-messages text-xs-center"> 38 | {error} 39 | </p> 40 | 41 | <ActionForm action=login> 42 | <fieldset class="form-group"> 43 | <input name="username" class="form-control form-control-lg" type="text" 44 | placeholder="Your Username" /> 45 | </fieldset> 46 | <fieldset class="form-group"> 47 | <input name="password" class="form-control form-control-lg" type="password" 48 | placeholder="Password" /> 49 | </fieldset> 50 | <A href="/reset_password">Reset password</A> 51 | <button class="btn btn-lg btn-primary pull-xs-right">"Sign in"</button> 52 | </ActionForm> 53 | </div> 54 | </div> 55 | </div> 56 | </div> 57 | } 58 | } 59 | -------------------------------------------------------------------------------- /src/routes/mod.rs: -------------------------------------------------------------------------------- 1 | pub use article::*; 2 | pub use editor::*; 3 | pub use home::*; 4 | pub use login::*; 5 | pub use profile::*; 6 | pub use reset_password::*; 7 | pub use settings::*; 8 | pub use signup::*; 9 | 10 | mod article; 11 | mod editor; 12 | mod home; 13 | mod login; 14 | mod profile; 15 | mod reset_password; 16 | mod settings; 17 | mod signup; 18 | -------------------------------------------------------------------------------- /src/routes/profile.rs: -------------------------------------------------------------------------------- 1 | use leptos::prelude::*; 2 | use leptos_meta::*; 3 | use leptos_router::hooks::{use_params_map, use_query_map}; 4 | 5 | use crate::components::ArticlePreviewList; 6 | use crate::components::ButtonFollow; 7 | 8 | #[server(UserArticlesAction, "/api", "GetJson")] 9 | #[tracing::instrument] 10 | pub async fn profile_articles( 11 | username: String, 12 | favourites: Option<bool>, 13 | ) -> Result<Vec<crate::models::Article>, ServerFnError> { 14 | crate::models::Article::for_user_profile(username, favourites.unwrap_or_default()) 15 | .await 16 | .map_err(|x| { 17 | let err = format!("Error while getting user_profile articles: {x:?}"); 18 | tracing::error!("{err}"); 19 | ServerFnError::ServerError("Could not retrieve articles, try again later".into()) 20 | }) 21 | } 22 | 23 | #[derive(serde::Serialize, serde::Deserialize, Clone)] 24 | pub struct UserProfileModel { 25 | user: crate::models::User, 26 | following: Option<bool>, 27 | } 28 | 29 | #[server(UserProfileAction, "/api", "GetJson")] 30 | #[tracing::instrument] 31 | pub async fn user_profile(username: String) -> Result<UserProfileModel, ServerFnError> { 32 | let user = crate::models::User::get(username.clone()) 33 | .await 34 | .map_err(|x| { 35 | let err = format!("Error while getting user in user_profile: {x:?}"); 36 | tracing::error!("{err}"); 37 | ServerFnError::new("Could not retrieve articles, try again later") 38 | })?; 39 | match crate::auth::get_username() { 40 | Some(lu) => sqlx::query!( 41 | "SELECT EXISTS(SELECT * FROM Follows WHERE follower=$2 and influencer=$1)", 42 | username, 43 | lu, 44 | ) 45 | .fetch_one(crate::database::get_db()) 46 | .await 47 | .map_err(|x| { 48 | let err = format!("Error while getting user in user_profile: {x:?}"); 49 | tracing::error!("{err}"); 50 | ServerFnError::ServerError("Could not retrieve articles, try again later".into()) 51 | }) 52 | .map(|x| UserProfileModel { 53 | user, 54 | following: x.exists, 55 | }), 56 | None => Ok(UserProfileModel { 57 | user, 58 | following: None, 59 | }), 60 | } 61 | } 62 | 63 | #[allow(clippy::redundant_closure)] 64 | #[tracing::instrument] 65 | #[component] 66 | pub fn Profile(username: crate::auth::UsernameSignal) -> impl IntoView { 67 | let params = use_params_map(); 68 | let route_user = move || params.with(|x| x.get("user").clone().unwrap_or_default()); 69 | let query = use_query_map(); 70 | let favourite = move || query.with(|x| x.get("favourites").map(|_| true)); 71 | 72 | let user_article_href = move || format!("/profile/{}", route_user()); 73 | let favourites_href = move || format!("{}?favourites=true", user_article_href()); 74 | 75 | let articles = Resource::new( 76 | move || (favourite(), route_user()), 77 | move |(fav, user)| async move { profile_articles(user, fav).await.unwrap_or_else(|_| vec![]) }, 78 | ); 79 | 80 | view! { 81 | <Title text=move || format!("{}'s profile", route_user()) /> 82 | <div class="profile-page"> 83 | <UserInfo logged_user=username /> 84 | 85 | <div class="container"> 86 | <div class="row"> 87 | <div class="col-xs-12 col-md-10 offset-md-1"> 88 | <div class="articles-toggle"> 89 | <ul class="nav nav-pills outline-active"> 90 | <li class="nav-item"> 91 | <a class="nav-link" 92 | class:active=move || !favourite().unwrap_or_default() href=user_article_href> 93 | {move || route_user()}"'s Articles" 94 | </a> 95 | </li> 96 | <li class="nav-item"> 97 | <a class="nav-link" 98 | class:active=move || favourite().unwrap_or_default() 99 | href=favourites_href>"Favorited Articles"</a> 100 | </li> 101 | </ul> 102 | </div> 103 | 104 | <ArticlePreviewList username=username articles=articles /> 105 | </div> 106 | </div> 107 | </div> 108 | </div> 109 | } 110 | } 111 | 112 | #[component] 113 | pub fn UserInfo(logged_user: crate::auth::UsernameSignal) -> impl IntoView { 114 | let params = use_params_map(); 115 | let resource = Resource::new( 116 | move || (params.with(|x| x.get("user").clone().unwrap_or_default())), 117 | move |user| async move { user_profile(user).await }, 118 | ); 119 | 120 | view! { 121 | <div class="user-info"> 122 | <div class="container"> 123 | <div class="row"> 124 | <div class="col-xs-12 col-md-10 offset-md-1"> 125 | <Suspense 126 | fallback=move || view!{<p>"Loading user profile"</p>} 127 | > 128 | <ErrorBoundary 129 | fallback=|_| { 130 | view!{<p>"There was a problem while fetching the user profile, try again later"</p>} 131 | } 132 | > 133 | {move || { 134 | resource.get().map(move |x| { 135 | x.map(move |u| { 136 | let image = u.user.image(); 137 | let username = u.user.username(); 138 | let bio = u.user.bio(); 139 | let (author, _) = signal(username.to_string()); 140 | 141 | view!{ 142 | <img src=image class="user-img" /> 143 | <h4>{username}</h4> 144 | <p>{bio.unwrap_or("No bio available".into())}</p> 145 | <ButtonFollow logged_user author following=u.following.unwrap_or_default() /> 146 | } 147 | }) 148 | }) 149 | }} 150 | </ErrorBoundary> 151 | </Suspense> 152 | </div> 153 | </div> 154 | </div> 155 | </div> 156 | } 157 | } 158 | -------------------------------------------------------------------------------- /src/routes/reset_password.rs: -------------------------------------------------------------------------------- 1 | use std::env; 2 | 3 | use leptos::prelude::*; 4 | use leptos_meta::*; 5 | use leptos_router::{hooks::use_query, params::Params}; 6 | 7 | #[cfg(feature = "ssr")] 8 | struct EmailCredentials { 9 | email: String, 10 | passwd: String, 11 | smtp_server: String, 12 | } 13 | 14 | #[cfg(feature = "ssr")] 15 | static EMAIL_CREDS: std::sync::OnceLock<EmailCredentials> = std::sync::OnceLock::new(); 16 | 17 | #[tracing::instrument] 18 | #[server(ResetPasswordAction1, "/api")] 19 | pub async fn reset_password_1(email: String) -> Result<String, ServerFnError> { 20 | if let Err(x) = crate::models::User::get_email(email.clone()).await { 21 | let err = format!("Bad email : {x:?}"); 22 | tracing::error!("{err}"); 23 | } else { 24 | let creds = EMAIL_CREDS.get_or_init(|| EmailCredentials { 25 | email: env::var("MAILER_EMAIL").unwrap(), 26 | passwd: env::var("MAILER_PASSWD").unwrap(), 27 | smtp_server: env::var("MAILER_SMTP_SERVER").unwrap(), 28 | }); 29 | let host = leptos_axum::extract::<axum_extra::extract::Host>().await?.0; 30 | let schema = if cfg!(debug_assertions) { 31 | "http" 32 | } else { 33 | "https" 34 | }; 35 | let token = crate::auth::encode_token(crate::auth::TokenClaims { 36 | sub: email.clone(), 37 | exp: (sqlx::types::chrono::Utc::now().timestamp() as usize) + 3_600, 38 | }) 39 | .unwrap(); 40 | let uri = format!("{}://{}/reset_password?token={}", schema, host, token); 41 | // Build a simple multipart message 42 | let message = mail_send::mail_builder::MessageBuilder::new() 43 | .from(("Realworld Leptos", creds.email.as_str())) 44 | .to(vec![("You", email.as_str())]) 45 | .subject("Your password reset from realworld leptos") 46 | .text_body(format!( 47 | "You can reset your password accessing the following link: {uri}" 48 | )); 49 | 50 | // Connect to the SMTP submissions port, upgrade to TLS and 51 | // authenticate using the provided credentials. 52 | mail_send::SmtpClientBuilder::new(creds.smtp_server.as_str(), 587) 53 | .implicit_tls(false) 54 | .credentials((creds.email.as_str(), creds.passwd.as_str())) 55 | .connect() 56 | .await 57 | .unwrap() 58 | .send(message) 59 | .await 60 | .unwrap(); 61 | } 62 | return Ok(String::from("Check your email")); 63 | } 64 | 65 | fn validate_reset(password: String, confirm: String) -> bool { 66 | password == confirm 67 | } 68 | 69 | #[tracing::instrument] 70 | #[server(ResetPasswordAction2, "/api")] 71 | pub async fn reset_password_2( 72 | token: String, 73 | password: String, 74 | confirm: String, 75 | ) -> Result<String, ServerFnError> { 76 | let mut message = String::from("Something went wrong, try again later"); 77 | if !validate_reset(password.clone(), confirm) { 78 | return Ok(message); 79 | } 80 | let Ok(claims) = crate::auth::decode_token(token.as_str()) else { 81 | tracing::info!("Invalid token provided"); 82 | return Ok(message); 83 | }; 84 | let email = claims.claims.sub; 85 | let Ok(user) = crate::models::User::get_email(email.clone()).await else { 86 | tracing::info!("User does not exist"); 87 | return Ok(message); 88 | }; 89 | match user.set_password(password) { 90 | Ok(u) => { 91 | if let Err(error) = u.update().await { 92 | tracing::error!(email, ?error, "error while resetting the password"); 93 | } else { 94 | // A real password reset would have a list of issued tokens and invalidation over 95 | // the used ones. As this would grow much bigger in complexity, I prefer to write 96 | // down this security vulnerability and left it simple :) 97 | message = String::from("Password successfully reset, please, proceed to login"); 98 | } 99 | } 100 | Err(x) => { 101 | message = x; 102 | } 103 | } 104 | Ok(message) 105 | } 106 | 107 | #[derive(Params, PartialEq)] 108 | struct TokenQuery { 109 | token: Option<String>, 110 | } 111 | 112 | #[component] 113 | pub fn ResetPassword() -> impl IntoView { 114 | let q = use_query::<TokenQuery>(); 115 | view! { 116 | <Title text="Reset Password"/> 117 | <div class="auth-page"> 118 | <div class="container page"> 119 | <div class="row"> 120 | {q.with(|x| { 121 | if let Ok(token_query) = x { 122 | if let Some(token) = token_query.token.as_ref() { 123 | return view! {<ConfirmPassword token={token.to_string()}/>}.into_any() 124 | } 125 | } 126 | view! {<AskForEmail/> }.into_any() 127 | })} 128 | </div> 129 | </div> 130 | </div> 131 | } 132 | } 133 | 134 | #[component] 135 | fn AskForEmail() -> impl IntoView { 136 | let reset = ServerAction::<ResetPasswordAction1>::new(); 137 | let result_of_call = reset.value(); 138 | 139 | let error = move || { 140 | result_of_call.with(|msg| { 141 | msg.as_ref() 142 | .map(|inner| match inner { 143 | Ok(x) => x.to_string(), 144 | Err(x) => { 145 | tracing::error!("Problem while sending email: {x:?}"); 146 | String::from("There was a problem, try again later") 147 | } 148 | }) 149 | .unwrap_or_default() 150 | }) 151 | }; 152 | view! { 153 | <div class="col-md-6 offset-md-3 col-xs-12"> 154 | <h1 class="text-xs-center">"Reset password"</h1> 155 | 156 | <p class="text-xs-center"> 157 | {error} 158 | </p> 159 | 160 | <ActionForm action=reset> 161 | <fieldset class="form-group"> 162 | <input name="email" class="form-control form-control-lg" type="email" 163 | placeholder="Your Email" /> 164 | </fieldset> 165 | <button class="btn btn-lg btn-primary pull-xs-right">"Reset Password"</button> 166 | </ActionForm> 167 | </div> 168 | } 169 | } 170 | 171 | #[component] 172 | fn ConfirmPassword(token: String) -> impl IntoView { 173 | let reset = ServerAction::<ResetPasswordAction2>::new(); 174 | let result_of_call = reset.value(); 175 | 176 | let error = move || { 177 | result_of_call.with(|msg| { 178 | msg.as_ref() 179 | .map(|inner| match inner { 180 | Ok(x) => x.to_string(), 181 | Err(x) => { 182 | tracing::error!("Problem during reset: {x:?}"); 183 | String::from("There was a problem, try again later") 184 | } 185 | }) 186 | .unwrap_or_default() 187 | }) 188 | }; 189 | view! { 190 | <div class="col-md-6 offset-md-3 col-xs-12"> 191 | <h1 class="text-xs-center">"Reset password"</h1> 192 | 193 | <p class="text-xs-center"> 194 | {error} 195 | </p> 196 | 197 | <ActionForm action=reset on:submit=move |ev| { 198 | let Ok(data) = ResetPasswordAction2::from_event(&ev) else { 199 | return ev.prevent_default(); 200 | }; 201 | if !validate_reset(data.password, data.confirm) { 202 | result_of_call.set(Some(Ok(String::from("Password is not the same")))); 203 | ev.prevent_default(); 204 | } 205 | }> 206 | <fieldset class="form-group"> 207 | <input name="password" class="form-control form-control-lg" type="password" 208 | placeholder="Your new password" /> 209 | 210 | <input name="confirm" class="form-control form-control-lg" type="password" 211 | placeholder="Confirm your password" /> 212 | 213 | <input name="token" type="hidden" value={token} /> 214 | </fieldset> 215 | <button class="btn btn-lg btn-primary pull-xs-right">"Reset Password"</button> 216 | </ActionForm> 217 | </div> 218 | } 219 | } 220 | -------------------------------------------------------------------------------- /src/routes/settings.rs: -------------------------------------------------------------------------------- 1 | use leptos::prelude::*; 2 | use leptos_meta::*; 3 | 4 | use serde::{Deserialize, Serialize}; 5 | 6 | #[derive(Deserialize, Serialize, Clone, Debug)] 7 | pub enum SettingsUpdateError { 8 | PasswordsNotMatch, 9 | Successful, 10 | ValidationError(String), 11 | } 12 | 13 | #[tracing::instrument] 14 | #[server(SettingsUpdateAction, "/api")] 15 | pub async fn settings_update( 16 | image: String, 17 | bio: String, 18 | email: String, 19 | password: String, 20 | confirm_password: String, 21 | ) -> Result<SettingsUpdateError, ServerFnError> { 22 | let user = get_user().await?; 23 | let username = user.username(); 24 | let user = match update_user_validation(user, image, bio, email, password, &confirm_password) { 25 | Ok(x) => x, 26 | Err(x) => return Ok(x), 27 | }; 28 | user.update() 29 | .await 30 | .map(|_| SettingsUpdateError::Successful) 31 | .map_err(move |x| { 32 | tracing::error!( 33 | "Problem while updating user: {} with error {}", 34 | username, 35 | x.to_string() 36 | ); 37 | ServerFnError::ServerError("Problem while updating user".into()) 38 | }) 39 | } 40 | 41 | fn update_user_validation( 42 | mut user: crate::models::User, 43 | image: String, 44 | bio: String, 45 | email: String, 46 | password: String, 47 | confirm_password: &str, 48 | ) -> Result<crate::models::User, SettingsUpdateError> { 49 | if !password.is_empty() { 50 | if password != confirm_password { 51 | return Err(SettingsUpdateError::PasswordsNotMatch); 52 | } 53 | user = user 54 | .set_password(password) 55 | .map_err(SettingsUpdateError::ValidationError)?; 56 | } 57 | 58 | user.set_email(email) 59 | .map_err(SettingsUpdateError::ValidationError)? 60 | .set_bio(bio) 61 | .map_err(SettingsUpdateError::ValidationError)? 62 | .set_image(image) 63 | .map_err(SettingsUpdateError::ValidationError) 64 | } 65 | 66 | #[cfg(feature = "ssr")] 67 | async fn get_user() -> Result<crate::models::User, ServerFnError> { 68 | let Some(username) = crate::auth::get_username() else { 69 | leptos_axum::redirect("/login"); 70 | return Err(ServerFnError::ServerError( 71 | "You need to be authenticated".to_string(), 72 | )); 73 | }; 74 | 75 | crate::models::User::get(username).await.map_err(|x| { 76 | let err = x.to_string(); 77 | tracing::error!("problem while getting the user {err}"); 78 | ServerFnError::ServerError(err) 79 | }) 80 | } 81 | 82 | #[tracing::instrument] 83 | #[server(SettingsGetAction, "/api", "GetJson")] 84 | pub async fn settings_get() -> Result<crate::models::User, ServerFnError> { 85 | get_user().await 86 | } 87 | 88 | #[derive(Debug, Default, Deserialize, Serialize, Clone)] 89 | pub struct UserGet { 90 | username: String, 91 | email: String, 92 | bio: Option<String>, 93 | image: Option<String>, 94 | } 95 | 96 | #[component] 97 | pub fn Settings(logout: crate::auth::LogoutSignal) -> impl IntoView { 98 | let resource = Resource::new(|| (), move |_| settings_get()); 99 | 100 | view! { 101 | <Title text="Settings"/> 102 | 103 | <div class="settings-page"> 104 | <div class="container page"> 105 | <div class="row"> 106 | <div class="col-md-6 offset-md-3 col-xs-12"> 107 | <h1 class="text-xs-center">"Your Settings"</h1> 108 | 109 | <Suspense fallback=move || view!{<p>"Loading user settings"</p>} > 110 | <ErrorBoundary fallback=|_| view!{<p>"There was a problem while fetching settings, try again later"</p>}> 111 | {move || { 112 | resource.get().map(move |x| { 113 | x.map(move |user| view!{<SettingsViewForm user />}) 114 | }) 115 | }} 116 | </ErrorBoundary> 117 | </Suspense> 118 | <hr /> 119 | <ActionForm action=logout> 120 | <button type="submit" class="btn btn-outline-danger">"Or click here to logout."</button> 121 | </ActionForm> 122 | </div> 123 | </div> 124 | </div> 125 | </div> 126 | } 127 | } 128 | 129 | #[component] 130 | fn SettingsViewForm(user: crate::models::User) -> impl IntoView { 131 | let settings_server_action = ServerAction::<SettingsUpdateAction>::new(); 132 | let result = settings_server_action.value(); 133 | let error = move || { 134 | result.with(|x| { 135 | x.as_ref() 136 | .is_none_or(|y| y.is_err() || !matches!(y, Ok(SettingsUpdateError::Successful))) 137 | }) 138 | }; 139 | 140 | view! { 141 | <p class="text-xs-center" 142 | class:text-success=move || !error() 143 | class:error-messages=error 144 | > 145 | <strong> 146 | {move || result.with(|x| { 147 | match x { 148 | Some(Ok(SettingsUpdateError::Successful)) => { 149 | "Successfully update settings".to_string() 150 | }, 151 | Some(Ok(SettingsUpdateError::ValidationError(x))) => { 152 | format!("Problem while validating: {x:?}") 153 | }, 154 | Some(Ok(SettingsUpdateError::PasswordsNotMatch)) => { 155 | "Passwords don't match".to_string() 156 | }, 157 | Some(Err(x)) => format!("{x:?}"), 158 | None => String::new(), 159 | } 160 | })} 161 | </strong> 162 | </p> 163 | 164 | <ActionForm action=settings_server_action on:submit=move |ev| { 165 | let Ok(data) = SettingsUpdateAction::from_event(&ev) else { 166 | return ev.prevent_default(); 167 | }; 168 | if let Err(x) = update_user_validation(crate::models::User::default(), data.image, data.bio, data.email, data.password, &data.confirm_password) { 169 | result.set(Some(Ok(x))); 170 | ev.prevent_default(); 171 | } 172 | }> 173 | <fieldset> 174 | <fieldset class="form-group"> 175 | <input name="image" value=user.image() class="form-control" type="text" 176 | placeholder="URL of profile picture" /> 177 | </fieldset> 178 | <fieldset class="form-group"> 179 | <input disabled value=user.username() class="form-control form-control-lg" type="text" 180 | placeholder="Your Name" /> 181 | </fieldset> 182 | <fieldset class="form-group"> 183 | <textarea name="bio" class="form-control form-control-lg" rows="8" 184 | placeholder="Short bio about you" prop:value=user.bio().unwrap_or_default()> 185 | </textarea> 186 | </fieldset> 187 | <fieldset class="form-group"> 188 | <input name="email" value=user.email() class="form-control form-control-lg" type="text" 189 | placeholder="Email" /> 190 | </fieldset> 191 | <fieldset class="form-group"> 192 | <input name="password" class="form-control form-control-lg" type="password" 193 | placeholder="New Password" /> 194 | <input name="confirm_password" class="form-control form-control-lg" type="password" 195 | placeholder="Confirm New Password" /> 196 | </fieldset> 197 | <button class="btn btn-lg btn-primary pull-xs-right" type="submit">"Update Settings"</button> 198 | </fieldset> 199 | </ActionForm> 200 | } 201 | } 202 | -------------------------------------------------------------------------------- /src/routes/signup.rs: -------------------------------------------------------------------------------- 1 | use leptos::prelude::*; 2 | use leptos_meta::*; 3 | use leptos_router::components::A; 4 | 5 | use crate::auth::{validate_signup, SignupAction, SignupResponse, SignupSignal}; 6 | 7 | #[component] 8 | pub fn Signup(signup: SignupSignal) -> impl IntoView { 9 | let result_of_call = signup.value(); 10 | 11 | let error_cb = move || { 12 | result_of_call 13 | .get() 14 | .map(|msg| match msg { 15 | Ok(SignupResponse::ValidationError(x)) => format!("Problem while validating: {x}"), 16 | Ok(SignupResponse::CreateUserError(x)) => { 17 | format!("Problem while creating user: {x}") 18 | } 19 | Ok(SignupResponse::Success) => { 20 | tracing::info!("Signup success! redirecting"); 21 | "Done".into() 22 | } 23 | Err(x) => { 24 | tracing::error!("Problem during signup: {x:?}"); 25 | "There was a problem, try again later".into() 26 | } 27 | }) 28 | .unwrap_or_default() 29 | }; 30 | 31 | view! { 32 | <Title text="Signup"/> 33 | <div class="auth-page"> 34 | <div class="container page"> 35 | <div class="row"> 36 | <div class="col-md-6 offset-md-3 col-xs-12"> 37 | <h1 class="text-xs-center">"Sign up"</h1> 38 | <p class="text-xs-center"> 39 | <A href="/login">"Have an account?"</A> 40 | </p> 41 | 42 | <p class="error-messages text-xs-center"> 43 | {error_cb} 44 | </p> 45 | 46 | <ActionForm action=signup on:submit=move |ev| { 47 | let Ok(data) = SignupAction::from_event(&ev) else { 48 | return ev.prevent_default(); 49 | }; 50 | if let Err(x) = validate_signup(data.username, data.email, data.password) { 51 | result_of_call.set(Some(Ok(SignupResponse::ValidationError(x)))); 52 | ev.prevent_default(); 53 | } 54 | }> 55 | <fieldset class="form-group"> 56 | <input name="username" class="form-control form-control-lg" type="text" placeholder="Your Username" required=true/> 57 | </fieldset> 58 | <fieldset class="form-group"> 59 | <input name="email" class="form-control form-control-lg" type="email" placeholder="Email" required=true/> 60 | </fieldset> 61 | <fieldset class="form-group"> 62 | <input name="password" class="form-control form-control-lg" type="password" placeholder="Password" required=true/> 63 | </fieldset> 64 | <button class="btn btn-lg btn-primary pull-xs-right">"Sign up"</button> 65 | </ActionForm> 66 | </div> 67 | </div> 68 | </div> 69 | </div> 70 | } 71 | } 72 | -------------------------------------------------------------------------------- /src/setup.rs: -------------------------------------------------------------------------------- 1 | use leptos::prelude::*; 2 | use leptos_axum::{generate_route_list, LeptosRoutes}; 3 | 4 | use crate::app::App; 5 | 6 | /// # Panics 7 | /// 8 | /// Will panic if anything is badly setup from database, or web server 9 | pub async fn init_app(configuration_path: Option<&str>) { 10 | tracing_subscriber::fmt() 11 | .with_level(true) 12 | .with_max_level(tracing::Level::INFO) 13 | .init(); 14 | // Init the pool into static 15 | crate::database::init_db() 16 | .await 17 | .expect("problem during initialization of the database"); 18 | 19 | // Get leptos configuration 20 | let conf = get_configuration(configuration_path).unwrap(); 21 | let addr = conf.leptos_options.site_addr; 22 | // Generate the list of routes in your Leptos App 23 | let routes = generate_route_list(|| view! { <App/> }); 24 | let leptos_options = conf.leptos_options; 25 | let serve_dir = tower_http::services::ServeDir::new(leptos_options.site_root.as_ref()) 26 | .append_index_html_on_directories(false); 27 | 28 | let app = axum::Router::new() 29 | .leptos_routes(&leptos_options, routes, || view! { <App/> }) 30 | .fallback_service(serve_dir) 31 | .layer( 32 | tower_http::trace::TraceLayer::new_for_http() 33 | .make_span_with( 34 | tower_http::trace::DefaultMakeSpan::new().level(tracing::Level::INFO), 35 | ) 36 | .on_request(tower_http::trace::DefaultOnRequest::new().level(tracing::Level::INFO)) 37 | .on_response( 38 | tower_http::trace::DefaultOnResponse::new().level(tracing::Level::INFO), 39 | ) 40 | .on_failure( 41 | tower_http::trace::DefaultOnFailure::new().level(tracing::Level::ERROR), 42 | ), 43 | ) 44 | .layer(axum::middleware::from_fn(crate::auth::auth_middleware)) 45 | .with_state(leptos_options); 46 | 47 | let listener = tokio::net::TcpListener::bind(addr).await.unwrap(); 48 | axum::serve(listener, app).await.unwrap(); 49 | } 50 | -------------------------------------------------------------------------------- /style/main.scss: -------------------------------------------------------------------------------- 1 | .navbar-light .navbar-nav .nav-link[aria-current="page"] { 2 | color: rgba(0, 0, 0, 0.8); 3 | } 4 | 5 | .inline { 6 | display: inline-block; 7 | } --------------------------------------------------------------------------------