Skip to content

Commit b985867

Browse files
Initial commit
Co-authored-by: Sally McGrath <[email protected]>
1 parent b3cddd4 commit b985867

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

51 files changed

+3317
-0
lines changed

.gitignore

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
node_modules
2+
.env
3+
.specstory/
4+
.DS_Store
5+
playwright-report/
6+
.vscode/
7+
test-results/

backend/.gitignore

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
/.env
2+
/.venv/
3+
*.pyc

backend/README.md

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
# PurpleForest Backend
2+
3+
To run:
4+
5+
### One time
6+
7+
1. In the `backend` directory, create a file named `.env` with values for the following environment variables:
8+
* `JWT_SECRET_KEY`: Any random string.
9+
* `PGPASSWORD`: Any random string.
10+
* `PGUSER`: `postgres`, assuming you're using the bundled docker-based database, or whatever user you need if you have a custom postgres set up.
11+
* Optionally, `PGDATABASE`, `PGHOST`, and `PGPORT` if you're not using default postgres values.
12+
2. Make a virtual environment: `python3 -m venv .venv`
13+
3. Activate the virtual environment: `. .venv/bin/activate`
14+
4. Install dependencies: `pip install -r requirements.txt`
15+
5. Run the database: `../db/run.sh` (you must have Docker installed and running).
16+
6. Create the database schema: `../db/create-schema.sh`
17+
18+
You may want to run `python3 populate.py` to populate sample data.
19+
20+
If you ever need to wipe the database, just delete `../db/pg_data` (and remember to set it up again after).
21+
22+
### Each time
23+
24+
1. In one terminal, run the database: `../db/run.sh` (you must have Docker installed and running).
25+
2. In another terminal, activate the virtual environment: `. .venv/bin/activate`
26+
3. With the virtual environment activated, run the backend: `python3 main.py`

backend/__init__.py

Whitespace-only changes.

backend/custom_json_provider.py

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
from datetime import datetime
2+
from flask.json.provider import DefaultJSONProvider
3+
4+
5+
class CustomJsonProvider(DefaultJSONProvider):
6+
def __init__(self, *args, **kwargs):
7+
DefaultJSONProvider.__init__(self, *args, **kwargs)
8+
original_default = self.default
9+
self.default = lambda x: (
10+
x.isoformat() if isinstance(x, datetime) else original_default(x)
11+
)

backend/custom_json_provider_test.py

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
import datetime
2+
import unittest
3+
4+
from flask import Flask
5+
6+
from custom_json_provider import CustomJsonProvider
7+
8+
9+
class TestCustomJsonProvider(unittest.TestCase):
10+
def test_datetime(self):
11+
serialised = CustomJsonProvider(Flask("Dummy")).dumps(
12+
{
13+
"timestamp": datetime.datetime(
14+
year=2020,
15+
month=3,
16+
day=4,
17+
hour=14,
18+
minute=15,
19+
second=16,
20+
tzinfo=datetime.UTC,
21+
)
22+
}
23+
)
24+
self.assertEqual(serialised, """{"timestamp": "2020-03-04T14:15:16+00:00"}""")
25+
26+
27+
if __name__ == "__main__":
28+
unittest.main()

backend/data/blooms.py

Lines changed: 142 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,142 @@
1+
import datetime
2+
3+
from dataclasses import dataclass
4+
from typing import Any, Dict, List, Optional
5+
6+
from data.connection import db_cursor
7+
from data.users import User
8+
9+
10+
@dataclass
11+
class Bloom:
12+
id: int
13+
sender: User
14+
content: str
15+
sent_timestamp: datetime.datetime
16+
17+
18+
def add_bloom(*, sender: User, content: str) -> Bloom:
19+
hashtags = [word[1:] for word in content.split(" ") if word.startswith("#")]
20+
21+
now = datetime.datetime.now(tz=datetime.UTC)
22+
bloom_id = int(now.timestamp() * 1000000)
23+
with db_cursor() as cur:
24+
cur.execute(
25+
"INSERT INTO blooms (id, sender_id, content, send_timestamp) VALUES (%(bloom_id)s, %(sender_id)s, %(content)s, %(timestamp)s)",
26+
dict(
27+
bloom_id=bloom_id,
28+
sender_id=sender.id,
29+
content=content,
30+
timestamp=datetime.datetime.now(datetime.UTC),
31+
),
32+
)
33+
for hashtag in hashtags:
34+
cur.execute(
35+
"INSERT INTO hashtags (hashtag, bloom_id) VALUES (%(hashtag)s, %(bloom_id)s)",
36+
dict(hashtag=hashtag, bloom_id=bloom_id),
37+
)
38+
39+
40+
def get_blooms_for_user(
41+
username: str, *, before: Optional[int] = None, limit: Optional[int] = None
42+
) -> List[Bloom]:
43+
with db_cursor() as cur:
44+
kwargs = {
45+
"sender_username": username,
46+
}
47+
if before is not None:
48+
before_clause = "AND send_timestamp < %(before_limit)s"
49+
kwargs["before_limit"] = before
50+
else:
51+
before_clause = ""
52+
53+
limit_clause = make_limit_clause(limit, kwargs)
54+
55+
cur.execute(
56+
f"""SELECT
57+
blooms.id, users.username, content, send_timestamp
58+
FROM
59+
blooms INNER JOIN users ON users.id = blooms.sender_id
60+
WHERE
61+
username = %(sender_username)s
62+
{before_clause}
63+
ORDER BY send_timestamp DESC
64+
{limit_clause}
65+
""",
66+
kwargs,
67+
)
68+
rows = cur.fetchall()
69+
blooms = []
70+
for row in rows:
71+
bloom_id, sender_username, content, timestamp = row
72+
blooms.append(
73+
Bloom(
74+
id=bloom_id,
75+
sender=sender_username,
76+
content=content,
77+
sent_timestamp=timestamp,
78+
)
79+
)
80+
return blooms
81+
82+
83+
def get_bloom(bloom_id: int) -> Optional[Bloom]:
84+
with db_cursor() as cur:
85+
cur.execute(
86+
"SELECT blooms.id, users.username, content, send_timestamp FROM blooms INNER JOIN users ON users.id = blooms.sender_id WHERE blooms.id = %s",
87+
(bloom_id,),
88+
)
89+
row = cur.fetchone()
90+
if row is None:
91+
return None
92+
bloom_id, sender_username, content, timestamp = row
93+
return Bloom(
94+
id=bloom_id,
95+
sender=sender_username,
96+
content=content,
97+
sent_timestamp=timestamp,
98+
)
99+
100+
101+
def get_blooms_with_hashtag(
102+
hashtag_without_leading_hash: str, *, limit: int = None
103+
) -> List[Bloom]:
104+
kwargs = {
105+
"hashtag_without_leading_hash": hashtag_without_leading_hash,
106+
}
107+
limit_clause = make_limit_clause(limit, kwargs)
108+
with db_cursor() as cur:
109+
cur.execute(
110+
f"""SELECT
111+
blooms.id, users.username, content, send_timestamp
112+
FROM
113+
blooms INNER JOIN hashtags ON blooms.id = hashtags.bloom_id INNER JOIN users ON blooms.sender_id = users.id
114+
WHERE
115+
hashtag = %(hashtag_without_leading_hash)s
116+
ORDER BY send_timestamp DESC
117+
{limit_clause}
118+
""",
119+
kwargs,
120+
)
121+
rows = cur.fetchall()
122+
blooms = []
123+
for row in rows:
124+
bloom_id, sender_username, content, timestamp = row
125+
blooms.append(
126+
Bloom(
127+
id=bloom_id,
128+
sender=sender_username,
129+
content=content,
130+
sent_timestamp=timestamp,
131+
)
132+
)
133+
return blooms
134+
135+
136+
def make_limit_clause(limit: Optional[int], kwargs: Dict[Any, Any]) -> str:
137+
if limit is not None:
138+
limit_clause = "LIMIT %(limit)s"
139+
kwargs["limit"] = limit
140+
else:
141+
limit_clause = ""
142+
return limit_clause

backend/data/connection.py

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
from contextlib import contextmanager
2+
import os
3+
import psycopg2
4+
5+
6+
@contextmanager
7+
def db_cursor():
8+
with psycopg2.connect(
9+
dbname=os.getenv("PGDATABASE"),
10+
user=os.getenv("PGUSER"),
11+
password=os.environ["PGPASSWORD"],
12+
host=os.getenv("PGHOST", "127.0.0.1"),
13+
port=os.getenv("PGPORT"),
14+
) as conn:
15+
with conn.cursor() as cur:
16+
yield cur

backend/data/follows.py

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
from typing import List
2+
3+
from data.connection import db_cursor
4+
from data.users import User
5+
6+
from psycopg2.errors import UniqueViolation
7+
8+
9+
def follow(follower: User, followee: User):
10+
with db_cursor() as cur:
11+
try:
12+
cur.execute(
13+
"INSERT INTO follows (follower, followee) VALUES (%(follower_id)s, %(followee_id)s)",
14+
dict(
15+
follower_id=follower.id,
16+
followee_id=followee.id,
17+
),
18+
)
19+
except UniqueViolation:
20+
# Already following - treat as idempotent request.
21+
pass
22+
23+
24+
def get_followed_usernames(follower: User) -> List[str]:
25+
"""get_followed_usernames returns a list of usernames followee follows."""
26+
with db_cursor() as cur:
27+
cur.execute(
28+
"SELECT users.username FROM follows INNER JOIN users ON follows.followee = users.id WHERE follower = %s",
29+
(follower.id,),
30+
)
31+
rows = cur.fetchall()
32+
return [row[0] for row in rows]
33+
34+
35+
def get_inverse_followed_usernames(followee: User) -> List[str]:
36+
"""get_followed_usernames returns a list of usernames followed by follower."""
37+
with db_cursor() as cur:
38+
cur.execute(
39+
"SELECT users.username FROM follows INNER JOIN users ON follows.follower = users.id WHERE followee = %s",
40+
(followee.id,),
41+
)
42+
rows = cur.fetchall()
43+
return [row[0] for row in rows]

backend/data/users.py

Lines changed: 108 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,108 @@
1+
from dataclasses import dataclass
2+
from hashlib import scrypt
3+
import hashlib
4+
import random
5+
import string
6+
from typing import List, Optional
7+
8+
from data.connection import db_cursor
9+
from psycopg2.errors import UniqueViolation
10+
11+
12+
@dataclass
13+
class User:
14+
id: int
15+
username: str
16+
password_salt: bytes
17+
password_scrypt: bytes
18+
19+
def check_password(self, password_plaintext: str) -> bool:
20+
return self.password_scrypt == scrypt(
21+
password_plaintext.encode("utf-8"), self.password_salt
22+
)
23+
24+
25+
class UserRegistrationError(Exception):
26+
reason: str
27+
28+
def __init__(self, reason: str):
29+
self.reason = reason
30+
31+
32+
def get_user(username: str) -> Optional[User]:
33+
with db_cursor() as cur:
34+
cur.execute(
35+
"SELECT id, password_salt, password_scrypt FROM users WHERE username = %s",
36+
(username,),
37+
)
38+
row = cur.fetchone()
39+
if row is None:
40+
return None
41+
user_id, password_salt, password_scrypt = row
42+
return User(
43+
id=user_id,
44+
username=username,
45+
password_salt=bytes(password_salt),
46+
password_scrypt=bytes(password_scrypt),
47+
)
48+
49+
50+
def get_suggested_follows(following_user: User, limit: int) -> List[str]:
51+
with db_cursor() as cur:
52+
cur.execute(
53+
"""
54+
SELECT
55+
users.username
56+
FROM
57+
users
58+
WHERE
59+
users.id <> %(following_user_id)s AND
60+
users.id NOT IN (
61+
SELECT followee FROM follows WHERE follower = %(following_user_id)s
62+
)
63+
ORDER BY RANDOM()
64+
LIMIT %(limit)s
65+
""",
66+
dict(
67+
following_user_id=following_user.id,
68+
limit=limit,
69+
),
70+
)
71+
rows = cur.fetchall()
72+
return [row[0] for row in rows]
73+
74+
75+
def register_user(username: str, password_plaintext: str) -> User:
76+
salt = generate_salt()
77+
password_scrypt = scrypt(password_plaintext.encode("utf-8"), salt)
78+
79+
with db_cursor() as cur:
80+
try:
81+
cur.execute(
82+
"INSERT INTO users (username, password_salt, password_scrypt) VALUES (%(username)s, %(password_salt)s, %(password_scrypt)s)",
83+
dict(
84+
username=username,
85+
password_salt=salt,
86+
password_scrypt=password_scrypt,
87+
),
88+
)
89+
except UniqueViolation as err:
90+
raise UserRegistrationError("user already exists")
91+
92+
93+
def scrypt(password_plaintext: bytes, password_salt: bytes) -> bytes:
94+
return hashlib.scrypt(password_plaintext, salt=password_salt, n=8, r=8, p=1)
95+
96+
97+
SALT_CHARACTERS = string.ascii_uppercase + string.ascii_lowercase + string.digits
98+
99+
100+
def generate_salt() -> bytes:
101+
return (
102+
"".join(random.SystemRandom().choice(SALT_CHARACTERS) for _ in range(10))
103+
).encode("utf-8")
104+
105+
106+
def lookup_user(header_info, payload_info):
107+
"""lookup_user is a hook for the jwt middleware to look-up authenticated users."""
108+
return get_user(payload_info["sub"])

0 commit comments

Comments
 (0)