├── .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 |
52 |
53 |
54 |
69 |
70 |
71 | "Loading HomePage"
}>
73 | {move || user.get().map(move |x| {
74 | username.set(x.map(|y| y.username()).ok());
75 | view! {
76 |
77 | }
78 | })}
79 |
80 | }/>
81 | }/>
82 | }/>
83 | }/>
84 | }/>
85 | }/>
86 | "Loading Article"}>
88 | {move || user.get().map(move |x| {
89 | username.set(x.map(|y| y.username()).ok());
90 | view! {
91 |
92 | }
93 | })}
94 |
95 | }/>
96 | "Loading Profile"}>
98 | {move || user.get().map(move |x| {
99 | username.set(x.map(|y| y.username()).ok());
100 | view! {
101 |
102 | }
103 | })}
104 |
105 | }/>
106 |
107 |
108 |
118 |
119 |
120 |
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 {
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 {
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 {
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::().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::().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 {
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;
10 | pub type LoginSignal = ServerAction;
11 | pub type SignupSignal = ServerAction;
12 | pub type UsernameSignal = RwSignal