cloudcost-cli 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- backend/__init__.py +1 -0
- backend/app/__init__.py +1 -0
- backend/app/auth.py +104 -0
- backend/app/cli.py +726 -0
- backend/app/comments.py +94 -0
- backend/app/config.py +191 -0
- backend/app/database.py +470 -0
- backend/app/emailer.py +157 -0
- backend/app/github_client.py +197 -0
- backend/app/infracost.py +129 -0
- backend/app/litellm_admin.py +41 -0
- backend/app/main.py +833 -0
- backend/app/model_pricing.py +80 -0
- backend/app/security.py +15 -0
- backend/app/storage.py +31 -0
- backend/app/usage.py +73 -0
- cloudcost_cli-0.1.0.dist-info/METADATA +340 -0
- cloudcost_cli-0.1.0.dist-info/RECORD +21 -0
- cloudcost_cli-0.1.0.dist-info/WHEEL +5 -0
- cloudcost_cli-0.1.0.dist-info/entry_points.txt +2 -0
- cloudcost_cli-0.1.0.dist-info/top_level.txt +1 -0
backend/app/database.py
ADDED
|
@@ -0,0 +1,470 @@
|
|
|
1
|
+
import json
|
|
2
|
+
import hmac
|
|
3
|
+
from json import JSONDecodeError
|
|
4
|
+
from datetime import timedelta
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
from typing import Any
|
|
7
|
+
from uuid import uuid4
|
|
8
|
+
|
|
9
|
+
import psycopg
|
|
10
|
+
from psycopg.rows import dict_row
|
|
11
|
+
|
|
12
|
+
from backend.app.auth import hash_otp_code, hash_session_token, utc_now
|
|
13
|
+
from backend.app.config import Settings
|
|
14
|
+
from backend.app.storage import append_jsonl, read_jsonl
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def has_database(settings: Settings) -> bool:
|
|
18
|
+
return bool(settings.backend_database_url)
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def _connect(settings: Settings):
|
|
22
|
+
if not settings.backend_database_url:
|
|
23
|
+
raise RuntimeError("Set APP_DATABASE_URL or DATABASE_URL.")
|
|
24
|
+
return psycopg.connect(settings.backend_database_url, row_factory=dict_row)
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def init_app_database(settings: Settings) -> None:
|
|
28
|
+
if not has_database(settings):
|
|
29
|
+
return
|
|
30
|
+
|
|
31
|
+
with _connect(settings) as conn:
|
|
32
|
+
with conn.cursor() as cur:
|
|
33
|
+
cur.execute(
|
|
34
|
+
"""
|
|
35
|
+
CREATE TABLE IF NOT EXISTS cloudcost_waitlist (
|
|
36
|
+
id BIGSERIAL PRIMARY KEY,
|
|
37
|
+
email TEXT NOT NULL,
|
|
38
|
+
source TEXT NOT NULL DEFAULT 'landing_page',
|
|
39
|
+
plan TEXT NOT NULL DEFAULT 'early_access',
|
|
40
|
+
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
|
41
|
+
)
|
|
42
|
+
"""
|
|
43
|
+
)
|
|
44
|
+
cur.execute(
|
|
45
|
+
"""
|
|
46
|
+
CREATE INDEX IF NOT EXISTS cloudcost_waitlist_created_at_idx
|
|
47
|
+
ON cloudcost_waitlist (created_at DESC)
|
|
48
|
+
"""
|
|
49
|
+
)
|
|
50
|
+
cur.execute(
|
|
51
|
+
"""
|
|
52
|
+
DELETE FROM cloudcost_waitlist duplicate
|
|
53
|
+
USING cloudcost_waitlist original
|
|
54
|
+
WHERE lower(duplicate.email) = lower(original.email)
|
|
55
|
+
AND duplicate.id > original.id
|
|
56
|
+
"""
|
|
57
|
+
)
|
|
58
|
+
cur.execute(
|
|
59
|
+
"""
|
|
60
|
+
CREATE UNIQUE INDEX IF NOT EXISTS cloudcost_waitlist_email_unique_idx
|
|
61
|
+
ON cloudcost_waitlist (lower(email))
|
|
62
|
+
"""
|
|
63
|
+
)
|
|
64
|
+
cur.execute(
|
|
65
|
+
"""
|
|
66
|
+
CREATE TABLE IF NOT EXISTS cloudcost_usage_events (
|
|
67
|
+
id BIGSERIAL PRIMARY KEY,
|
|
68
|
+
event_type TEXT NOT NULL DEFAULT 'success',
|
|
69
|
+
model TEXT,
|
|
70
|
+
user_id TEXT,
|
|
71
|
+
key_alias TEXT,
|
|
72
|
+
team_id TEXT,
|
|
73
|
+
metadata JSONB NOT NULL DEFAULT '{}'::jsonb,
|
|
74
|
+
usage JSONB NOT NULL DEFAULT '{}'::jsonb,
|
|
75
|
+
cost NUMERIC,
|
|
76
|
+
started_at TIMESTAMPTZ,
|
|
77
|
+
ended_at TIMESTAMPTZ,
|
|
78
|
+
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
|
79
|
+
)
|
|
80
|
+
"""
|
|
81
|
+
)
|
|
82
|
+
cur.execute(
|
|
83
|
+
"""
|
|
84
|
+
CREATE INDEX IF NOT EXISTS cloudcost_usage_events_created_at_idx
|
|
85
|
+
ON cloudcost_usage_events (created_at DESC)
|
|
86
|
+
"""
|
|
87
|
+
)
|
|
88
|
+
cur.execute(
|
|
89
|
+
"""
|
|
90
|
+
CREATE INDEX IF NOT EXISTS cloudcost_usage_events_team_idx
|
|
91
|
+
ON cloudcost_usage_events (team_id)
|
|
92
|
+
"""
|
|
93
|
+
)
|
|
94
|
+
cur.execute(
|
|
95
|
+
"""
|
|
96
|
+
CREATE TABLE IF NOT EXISTS cloudcost_users (
|
|
97
|
+
id TEXT PRIMARY KEY,
|
|
98
|
+
email TEXT NOT NULL UNIQUE,
|
|
99
|
+
password_hash TEXT NOT NULL,
|
|
100
|
+
full_name TEXT,
|
|
101
|
+
company TEXT,
|
|
102
|
+
email_verified_at TIMESTAMPTZ,
|
|
103
|
+
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
|
104
|
+
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
|
105
|
+
)
|
|
106
|
+
"""
|
|
107
|
+
)
|
|
108
|
+
cur.execute(
|
|
109
|
+
"""
|
|
110
|
+
CREATE UNIQUE INDEX IF NOT EXISTS cloudcost_users_email_lower_unique_idx
|
|
111
|
+
ON cloudcost_users (lower(email))
|
|
112
|
+
"""
|
|
113
|
+
)
|
|
114
|
+
cur.execute(
|
|
115
|
+
"""
|
|
116
|
+
CREATE TABLE IF NOT EXISTS cloudcost_email_otps (
|
|
117
|
+
id BIGSERIAL PRIMARY KEY,
|
|
118
|
+
user_id TEXT NOT NULL REFERENCES cloudcost_users(id) ON DELETE CASCADE,
|
|
119
|
+
email TEXT NOT NULL,
|
|
120
|
+
purpose TEXT NOT NULL DEFAULT 'signup',
|
|
121
|
+
otp_hash TEXT NOT NULL,
|
|
122
|
+
attempts INTEGER NOT NULL DEFAULT 0,
|
|
123
|
+
expires_at TIMESTAMPTZ NOT NULL,
|
|
124
|
+
consumed_at TIMESTAMPTZ,
|
|
125
|
+
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
|
126
|
+
)
|
|
127
|
+
"""
|
|
128
|
+
)
|
|
129
|
+
cur.execute(
|
|
130
|
+
"""
|
|
131
|
+
CREATE INDEX IF NOT EXISTS cloudcost_email_otps_lookup_idx
|
|
132
|
+
ON cloudcost_email_otps (email, purpose, created_at DESC)
|
|
133
|
+
"""
|
|
134
|
+
)
|
|
135
|
+
cur.execute(
|
|
136
|
+
"""
|
|
137
|
+
CREATE TABLE IF NOT EXISTS cloudcost_sessions (
|
|
138
|
+
id TEXT PRIMARY KEY,
|
|
139
|
+
user_id TEXT NOT NULL REFERENCES cloudcost_users(id) ON DELETE CASCADE,
|
|
140
|
+
session_hash TEXT NOT NULL UNIQUE,
|
|
141
|
+
expires_at TIMESTAMPTZ NOT NULL,
|
|
142
|
+
last_seen_at TIMESTAMPTZ,
|
|
143
|
+
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
|
144
|
+
)
|
|
145
|
+
"""
|
|
146
|
+
)
|
|
147
|
+
cur.execute(
|
|
148
|
+
"""
|
|
149
|
+
CREATE INDEX IF NOT EXISTS cloudcost_sessions_user_idx
|
|
150
|
+
ON cloudcost_sessions (user_id)
|
|
151
|
+
"""
|
|
152
|
+
)
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
def _jsonl_has_waitlist_email(path: str, email: str) -> bool:
|
|
156
|
+
target = Path(path)
|
|
157
|
+
if not target.exists():
|
|
158
|
+
return False
|
|
159
|
+
|
|
160
|
+
normalized = email.strip().lower()
|
|
161
|
+
with target.open("r", encoding="utf-8") as handle:
|
|
162
|
+
for line in handle:
|
|
163
|
+
line = line.strip()
|
|
164
|
+
if not line:
|
|
165
|
+
continue
|
|
166
|
+
try:
|
|
167
|
+
row = json.loads(line)
|
|
168
|
+
except JSONDecodeError:
|
|
169
|
+
continue
|
|
170
|
+
if str(row.get("email", "")).strip().lower() == normalized:
|
|
171
|
+
return True
|
|
172
|
+
return False
|
|
173
|
+
|
|
174
|
+
|
|
175
|
+
def append_waitlist_record(settings: Settings, record: dict[str, Any]) -> bool:
|
|
176
|
+
if not has_database(settings):
|
|
177
|
+
if _jsonl_has_waitlist_email(settings.waitlist_path, record["email"]):
|
|
178
|
+
return False
|
|
179
|
+
append_jsonl(settings.waitlist_path, record)
|
|
180
|
+
return True
|
|
181
|
+
|
|
182
|
+
with _connect(settings) as conn:
|
|
183
|
+
with conn.cursor() as cur:
|
|
184
|
+
cur.execute(
|
|
185
|
+
"""
|
|
186
|
+
INSERT INTO cloudcost_waitlist (email, source, plan)
|
|
187
|
+
VALUES (%s, %s, %s)
|
|
188
|
+
ON CONFLICT DO NOTHING
|
|
189
|
+
RETURNING id
|
|
190
|
+
""",
|
|
191
|
+
(
|
|
192
|
+
record["email"],
|
|
193
|
+
record.get("source") or "landing_page",
|
|
194
|
+
record.get("plan") or "early_access",
|
|
195
|
+
),
|
|
196
|
+
)
|
|
197
|
+
return cur.fetchone() is not None
|
|
198
|
+
|
|
199
|
+
|
|
200
|
+
def append_usage_event(settings: Settings, event: dict[str, Any]) -> None:
|
|
201
|
+
if not has_database(settings):
|
|
202
|
+
append_jsonl(settings.usage_events_path, event)
|
|
203
|
+
return
|
|
204
|
+
|
|
205
|
+
with _connect(settings) as conn:
|
|
206
|
+
with conn.cursor() as cur:
|
|
207
|
+
cur.execute(
|
|
208
|
+
"""
|
|
209
|
+
INSERT INTO cloudcost_usage_events (
|
|
210
|
+
event_type,
|
|
211
|
+
model,
|
|
212
|
+
user_id,
|
|
213
|
+
key_alias,
|
|
214
|
+
team_id,
|
|
215
|
+
metadata,
|
|
216
|
+
usage,
|
|
217
|
+
cost,
|
|
218
|
+
started_at,
|
|
219
|
+
ended_at
|
|
220
|
+
)
|
|
221
|
+
VALUES (%s, %s, %s, %s, %s, %s::jsonb, %s::jsonb, %s, %s, %s)
|
|
222
|
+
""",
|
|
223
|
+
(
|
|
224
|
+
event.get("event_type") or "success",
|
|
225
|
+
event.get("model"),
|
|
226
|
+
event.get("user"),
|
|
227
|
+
event.get("key_alias"),
|
|
228
|
+
event.get("team_id"),
|
|
229
|
+
json.dumps(event.get("metadata") or {}),
|
|
230
|
+
json.dumps(event.get("usage") or {}),
|
|
231
|
+
event.get("cost"),
|
|
232
|
+
event.get("started_at"),
|
|
233
|
+
event.get("ended_at"),
|
|
234
|
+
),
|
|
235
|
+
)
|
|
236
|
+
|
|
237
|
+
|
|
238
|
+
def read_usage_event_records(settings: Settings, limit: int = 1000) -> list[dict[str, Any]]:
|
|
239
|
+
if not has_database(settings):
|
|
240
|
+
return read_jsonl(settings.usage_events_path, limit=limit)
|
|
241
|
+
|
|
242
|
+
with _connect(settings) as conn:
|
|
243
|
+
with conn.cursor() as cur:
|
|
244
|
+
cur.execute(
|
|
245
|
+
"""
|
|
246
|
+
SELECT
|
|
247
|
+
event_type,
|
|
248
|
+
model,
|
|
249
|
+
user_id AS "user",
|
|
250
|
+
key_alias,
|
|
251
|
+
team_id,
|
|
252
|
+
metadata,
|
|
253
|
+
usage,
|
|
254
|
+
cost,
|
|
255
|
+
started_at,
|
|
256
|
+
ended_at,
|
|
257
|
+
created_at AS recorded_at
|
|
258
|
+
FROM cloudcost_usage_events
|
|
259
|
+
ORDER BY created_at DESC
|
|
260
|
+
LIMIT %s
|
|
261
|
+
""",
|
|
262
|
+
(limit,),
|
|
263
|
+
)
|
|
264
|
+
rows = cur.fetchall()
|
|
265
|
+
|
|
266
|
+
rows.reverse()
|
|
267
|
+
return [dict(row) for row in rows]
|
|
268
|
+
|
|
269
|
+
|
|
270
|
+
def create_or_update_signup_user(
|
|
271
|
+
settings: Settings,
|
|
272
|
+
*,
|
|
273
|
+
email: str,
|
|
274
|
+
password_hash: str,
|
|
275
|
+
full_name: str | None,
|
|
276
|
+
company: str | None,
|
|
277
|
+
) -> dict[str, Any]:
|
|
278
|
+
if not has_database(settings):
|
|
279
|
+
raise RuntimeError("Signup requires Neon/Postgres. Set APP_DATABASE_URL or DATABASE_URL.")
|
|
280
|
+
|
|
281
|
+
with _connect(settings) as conn:
|
|
282
|
+
with conn.cursor() as cur:
|
|
283
|
+
cur.execute("SELECT * FROM cloudcost_users WHERE email = %s", (email,))
|
|
284
|
+
existing = cur.fetchone()
|
|
285
|
+
if existing and existing.get("email_verified_at"):
|
|
286
|
+
raise ValueError("An account already exists for this email.")
|
|
287
|
+
if existing:
|
|
288
|
+
cur.execute(
|
|
289
|
+
"""
|
|
290
|
+
UPDATE cloudcost_users
|
|
291
|
+
SET password_hash = %s,
|
|
292
|
+
full_name = %s,
|
|
293
|
+
company = %s,
|
|
294
|
+
updated_at = now()
|
|
295
|
+
WHERE id = %s
|
|
296
|
+
RETURNING *
|
|
297
|
+
""",
|
|
298
|
+
(password_hash, full_name, company, existing["id"]),
|
|
299
|
+
)
|
|
300
|
+
return dict(cur.fetchone())
|
|
301
|
+
|
|
302
|
+
cur.execute(
|
|
303
|
+
"""
|
|
304
|
+
INSERT INTO cloudcost_users (id, email, password_hash, full_name, company)
|
|
305
|
+
VALUES (%s, %s, %s, %s, %s)
|
|
306
|
+
RETURNING *
|
|
307
|
+
""",
|
|
308
|
+
(str(uuid4()), email, password_hash, full_name, company),
|
|
309
|
+
)
|
|
310
|
+
return dict(cur.fetchone())
|
|
311
|
+
|
|
312
|
+
|
|
313
|
+
def get_user_by_email(settings: Settings, email: str) -> dict[str, Any] | None:
|
|
314
|
+
if not has_database(settings):
|
|
315
|
+
return None
|
|
316
|
+
|
|
317
|
+
with _connect(settings) as conn:
|
|
318
|
+
with conn.cursor() as cur:
|
|
319
|
+
cur.execute("SELECT * FROM cloudcost_users WHERE email = %s", (email,))
|
|
320
|
+
row = cur.fetchone()
|
|
321
|
+
return dict(row) if row else None
|
|
322
|
+
|
|
323
|
+
|
|
324
|
+
def get_user_by_id(settings: Settings, user_id: str) -> dict[str, Any] | None:
|
|
325
|
+
if not has_database(settings):
|
|
326
|
+
return None
|
|
327
|
+
|
|
328
|
+
with _connect(settings) as conn:
|
|
329
|
+
with conn.cursor() as cur:
|
|
330
|
+
cur.execute("SELECT * FROM cloudcost_users WHERE id = %s", (user_id,))
|
|
331
|
+
row = cur.fetchone()
|
|
332
|
+
return dict(row) if row else None
|
|
333
|
+
|
|
334
|
+
|
|
335
|
+
def create_email_otp(
|
|
336
|
+
settings: Settings,
|
|
337
|
+
*,
|
|
338
|
+
user_id: str,
|
|
339
|
+
email: str,
|
|
340
|
+
code: str,
|
|
341
|
+
purpose: str = "signup",
|
|
342
|
+
) -> None:
|
|
343
|
+
if not has_database(settings):
|
|
344
|
+
raise RuntimeError("Email OTP requires Neon/Postgres. Set APP_DATABASE_URL or DATABASE_URL.")
|
|
345
|
+
|
|
346
|
+
expires_at = utc_now() + timedelta(minutes=settings.auth_otp_ttl_minutes)
|
|
347
|
+
otp_hash = hash_otp_code(settings, email, code, purpose)
|
|
348
|
+
with _connect(settings) as conn:
|
|
349
|
+
with conn.cursor() as cur:
|
|
350
|
+
cur.execute(
|
|
351
|
+
"""
|
|
352
|
+
INSERT INTO cloudcost_email_otps (user_id, email, purpose, otp_hash, expires_at)
|
|
353
|
+
VALUES (%s, %s, %s, %s, %s)
|
|
354
|
+
""",
|
|
355
|
+
(user_id, email, purpose, otp_hash, expires_at),
|
|
356
|
+
)
|
|
357
|
+
|
|
358
|
+
|
|
359
|
+
def verify_email_otp(
|
|
360
|
+
settings: Settings,
|
|
361
|
+
*,
|
|
362
|
+
email: str,
|
|
363
|
+
code: str,
|
|
364
|
+
purpose: str = "signup",
|
|
365
|
+
) -> dict[str, Any] | None:
|
|
366
|
+
if not has_database(settings):
|
|
367
|
+
raise RuntimeError("Email OTP requires Neon/Postgres. Set APP_DATABASE_URL or DATABASE_URL.")
|
|
368
|
+
|
|
369
|
+
now = utc_now()
|
|
370
|
+
expected_hash = hash_otp_code(settings, email, code, purpose)
|
|
371
|
+
with _connect(settings) as conn:
|
|
372
|
+
with conn.cursor() as cur:
|
|
373
|
+
cur.execute(
|
|
374
|
+
"""
|
|
375
|
+
SELECT *
|
|
376
|
+
FROM cloudcost_email_otps
|
|
377
|
+
WHERE email = %s
|
|
378
|
+
AND purpose = %s
|
|
379
|
+
AND consumed_at IS NULL
|
|
380
|
+
AND expires_at > %s
|
|
381
|
+
ORDER BY created_at DESC
|
|
382
|
+
LIMIT 1
|
|
383
|
+
""",
|
|
384
|
+
(email, purpose, now),
|
|
385
|
+
)
|
|
386
|
+
otp = cur.fetchone()
|
|
387
|
+
if not otp:
|
|
388
|
+
return None
|
|
389
|
+
if int(otp["attempts"]) >= settings.auth_otp_max_attempts:
|
|
390
|
+
return None
|
|
391
|
+
|
|
392
|
+
if not secrets_compare(expected_hash, otp["otp_hash"]):
|
|
393
|
+
cur.execute(
|
|
394
|
+
"UPDATE cloudcost_email_otps SET attempts = attempts + 1 WHERE id = %s",
|
|
395
|
+
(otp["id"],),
|
|
396
|
+
)
|
|
397
|
+
return None
|
|
398
|
+
|
|
399
|
+
cur.execute(
|
|
400
|
+
"UPDATE cloudcost_email_otps SET consumed_at = now() WHERE id = %s",
|
|
401
|
+
(otp["id"],),
|
|
402
|
+
)
|
|
403
|
+
cur.execute(
|
|
404
|
+
"""
|
|
405
|
+
UPDATE cloudcost_users
|
|
406
|
+
SET email_verified_at = COALESCE(email_verified_at, now()),
|
|
407
|
+
updated_at = now()
|
|
408
|
+
WHERE id = %s
|
|
409
|
+
RETURNING *
|
|
410
|
+
""",
|
|
411
|
+
(otp["user_id"],),
|
|
412
|
+
)
|
|
413
|
+
return dict(cur.fetchone())
|
|
414
|
+
|
|
415
|
+
|
|
416
|
+
def secrets_compare(left: str, right: str) -> bool:
|
|
417
|
+
return hmac.compare_digest(left, right)
|
|
418
|
+
|
|
419
|
+
|
|
420
|
+
def create_session(settings: Settings, *, user_id: str, token: str, expires_at: Any) -> None:
|
|
421
|
+
if not has_database(settings):
|
|
422
|
+
raise RuntimeError("Sessions require Neon/Postgres. Set APP_DATABASE_URL or DATABASE_URL.")
|
|
423
|
+
|
|
424
|
+
with _connect(settings) as conn:
|
|
425
|
+
with conn.cursor() as cur:
|
|
426
|
+
cur.execute(
|
|
427
|
+
"""
|
|
428
|
+
INSERT INTO cloudcost_sessions (id, user_id, session_hash, expires_at)
|
|
429
|
+
VALUES (%s, %s, %s, %s)
|
|
430
|
+
""",
|
|
431
|
+
(str(uuid4()), user_id, hash_session_token(settings, token), expires_at),
|
|
432
|
+
)
|
|
433
|
+
|
|
434
|
+
|
|
435
|
+
def get_user_for_session(settings: Settings, token: str | None) -> dict[str, Any] | None:
|
|
436
|
+
if not token or not has_database(settings):
|
|
437
|
+
return None
|
|
438
|
+
|
|
439
|
+
token_hash = hash_session_token(settings, token)
|
|
440
|
+
with _connect(settings) as conn:
|
|
441
|
+
with conn.cursor() as cur:
|
|
442
|
+
cur.execute(
|
|
443
|
+
"""
|
|
444
|
+
SELECT u.*
|
|
445
|
+
FROM cloudcost_sessions s
|
|
446
|
+
JOIN cloudcost_users u ON u.id = s.user_id
|
|
447
|
+
WHERE s.session_hash = %s
|
|
448
|
+
AND s.expires_at > now()
|
|
449
|
+
""",
|
|
450
|
+
(token_hash,),
|
|
451
|
+
)
|
|
452
|
+
user = cur.fetchone()
|
|
453
|
+
if user:
|
|
454
|
+
cur.execute(
|
|
455
|
+
"UPDATE cloudcost_sessions SET last_seen_at = now() WHERE session_hash = %s",
|
|
456
|
+
(token_hash,),
|
|
457
|
+
)
|
|
458
|
+
return dict(user) if user else None
|
|
459
|
+
|
|
460
|
+
|
|
461
|
+
def delete_session(settings: Settings, token: str | None) -> None:
|
|
462
|
+
if not token or not has_database(settings):
|
|
463
|
+
return
|
|
464
|
+
|
|
465
|
+
with _connect(settings) as conn:
|
|
466
|
+
with conn.cursor() as cur:
|
|
467
|
+
cur.execute(
|
|
468
|
+
"DELETE FROM cloudcost_sessions WHERE session_hash = %s",
|
|
469
|
+
(hash_session_token(settings, token),),
|
|
470
|
+
)
|
backend/app/emailer.py
ADDED
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
import asyncio
|
|
2
|
+
import smtplib
|
|
3
|
+
from email.message import EmailMessage
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
|
|
6
|
+
import httpx
|
|
7
|
+
|
|
8
|
+
from backend.app.config import Settings
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def _smtp_ready(settings: Settings) -> bool:
|
|
12
|
+
return bool(settings.smtp_host and settings.smtp_from_email)
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def _resend_ready(settings: Settings) -> bool:
|
|
16
|
+
return bool(settings.resend_api_key and settings.from_email)
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def waitlist_confirmation_available(settings: Settings) -> bool:
|
|
20
|
+
return settings.waitlist_confirmation_enabled and (_resend_ready(settings) or _smtp_ready(settings))
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def _send_message_sync(settings: Settings, message: EmailMessage) -> dict[str, str]:
|
|
24
|
+
with smtplib.SMTP(settings.smtp_host, settings.smtp_port, timeout=20) as smtp:
|
|
25
|
+
if settings.smtp_use_tls:
|
|
26
|
+
smtp.starttls()
|
|
27
|
+
if settings.smtp_username:
|
|
28
|
+
smtp.login(settings.smtp_username, settings.smtp_password or "")
|
|
29
|
+
smtp.send_message(message)
|
|
30
|
+
|
|
31
|
+
return {"mode": "smtp"}
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def _send_resend_sync(
|
|
35
|
+
settings: Settings,
|
|
36
|
+
*,
|
|
37
|
+
to_email: str,
|
|
38
|
+
subject: str,
|
|
39
|
+
text: str,
|
|
40
|
+
html: str | None = None,
|
|
41
|
+
) -> dict[str, str]:
|
|
42
|
+
if not settings.resend_api_key or not settings.from_email:
|
|
43
|
+
return {"mode": "disabled"}
|
|
44
|
+
|
|
45
|
+
payload: dict[str, object] = {
|
|
46
|
+
"from": settings.from_email,
|
|
47
|
+
"to": [to_email],
|
|
48
|
+
"subject": subject,
|
|
49
|
+
"text": text,
|
|
50
|
+
}
|
|
51
|
+
if html:
|
|
52
|
+
payload["html"] = html
|
|
53
|
+
|
|
54
|
+
with httpx.Client(timeout=20.0) as client:
|
|
55
|
+
response = client.post(
|
|
56
|
+
"https://api.resend.com/emails",
|
|
57
|
+
headers={
|
|
58
|
+
"Authorization": f"Bearer {settings.resend_api_key}",
|
|
59
|
+
"Content-Type": "application/json",
|
|
60
|
+
},
|
|
61
|
+
json=payload,
|
|
62
|
+
)
|
|
63
|
+
response.raise_for_status()
|
|
64
|
+
return {"mode": "resend"}
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def _write_dev_otp(settings: Settings, email: str, code: str) -> dict[str, str]:
|
|
68
|
+
path = Path(settings.auth_dev_otp_log_path)
|
|
69
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
70
|
+
with path.open("a", encoding="utf-8") as handle:
|
|
71
|
+
handle.write(f"{email} signup OTP: {code}\n")
|
|
72
|
+
return {"mode": "dev_log", "path": str(path)}
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def _send_signup_otp_sync(settings: Settings, email: str, code: str) -> dict[str, str]:
|
|
76
|
+
subject = "Your CloudCost AI verification code"
|
|
77
|
+
text = "\n".join(
|
|
78
|
+
[
|
|
79
|
+
"Your CloudCost AI verification code is:",
|
|
80
|
+
"",
|
|
81
|
+
code,
|
|
82
|
+
"",
|
|
83
|
+
f"This code expires in {settings.auth_otp_ttl_minutes} minutes.",
|
|
84
|
+
]
|
|
85
|
+
)
|
|
86
|
+
|
|
87
|
+
if _resend_ready(settings):
|
|
88
|
+
html = f"""<!doctype html>
|
|
89
|
+
<html>
|
|
90
|
+
<body style="margin:0;background:#f3ede1;color:#18130f;font-family:Arial,sans-serif;">
|
|
91
|
+
<table role="presentation" width="100%" cellspacing="0" cellpadding="0" style="padding:32px 16px;">
|
|
92
|
+
<tr><td align="center">
|
|
93
|
+
<table role="presentation" width="100%" cellspacing="0" cellpadding="0" style="max-width:560px;background:#fffaf0;border:1px solid #d8cfbf;border-radius:10px;">
|
|
94
|
+
<tr><td style="padding:28px 32px;border-bottom:1px solid #e2d8c8;">
|
|
95
|
+
<div style="font-family:Georgia,serif;font-size:24px;font-weight:700;color:#1f5132;">CloudCost AI</div>
|
|
96
|
+
<div style="margin-top:8px;font-size:12px;letter-spacing:3px;text-transform:uppercase;color:#7b7064;">Account verification</div>
|
|
97
|
+
</td></tr>
|
|
98
|
+
<tr><td style="padding:34px 32px;">
|
|
99
|
+
<h1 style="margin:0;font-family:Georgia,serif;font-size:34px;font-weight:500;">Verify your account.</h1>
|
|
100
|
+
<p style="margin:16px 0 22px;font-size:16px;line-height:1.7;color:#5f564e;">Use this code to finish creating your CloudCost AI account.</p>
|
|
101
|
+
<div style="font-size:34px;letter-spacing:8px;font-weight:800;color:#1f5132;background:#f7f0e6;border:1px solid #e2d8c8;border-radius:8px;padding:18px 20px;text-align:center;">{code}</div>
|
|
102
|
+
<p style="margin:18px 0 0;font-size:14px;color:#7b7064;">This code expires in {settings.auth_otp_ttl_minutes} minutes. Ignore this email if you did not request it.</p>
|
|
103
|
+
</td></tr>
|
|
104
|
+
</table>
|
|
105
|
+
</td></tr>
|
|
106
|
+
</table>
|
|
107
|
+
</body>
|
|
108
|
+
</html>"""
|
|
109
|
+
return _send_resend_sync(settings, to_email=email, subject=subject, text=text, html=html)
|
|
110
|
+
|
|
111
|
+
if not _smtp_ready(settings):
|
|
112
|
+
return _write_dev_otp(settings, email, code)
|
|
113
|
+
|
|
114
|
+
message = EmailMessage()
|
|
115
|
+
message["Subject"] = subject
|
|
116
|
+
message["From"] = settings.smtp_from_email
|
|
117
|
+
message["To"] = email
|
|
118
|
+
message.set_content(text)
|
|
119
|
+
|
|
120
|
+
return _send_message_sync(settings, message)
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
def _send_waitlist_confirmation_sync(settings: Settings, email: str) -> dict[str, str]:
|
|
124
|
+
if not waitlist_confirmation_available(settings):
|
|
125
|
+
return {"mode": "disabled"}
|
|
126
|
+
|
|
127
|
+
subject = "CloudCost AI early access request received"
|
|
128
|
+
text = "\n".join(
|
|
129
|
+
[
|
|
130
|
+
"Your CloudCost AI early access request is in.",
|
|
131
|
+
"",
|
|
132
|
+
"We will reach out when your slot opens.",
|
|
133
|
+
"",
|
|
134
|
+
"If you did not request this, you can ignore this email.",
|
|
135
|
+
"",
|
|
136
|
+
"CloudCost AI",
|
|
137
|
+
]
|
|
138
|
+
)
|
|
139
|
+
|
|
140
|
+
if _resend_ready(settings):
|
|
141
|
+
return _send_resend_sync(settings, to_email=email, subject=subject, text=text)
|
|
142
|
+
|
|
143
|
+
message = EmailMessage()
|
|
144
|
+
message["Subject"] = subject
|
|
145
|
+
message["From"] = settings.smtp_from_email
|
|
146
|
+
message["To"] = email
|
|
147
|
+
message.set_content(text)
|
|
148
|
+
|
|
149
|
+
return _send_message_sync(settings, message)
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
async def send_signup_otp(settings: Settings, email: str, code: str) -> dict[str, str]:
|
|
153
|
+
return await asyncio.to_thread(_send_signup_otp_sync, settings, email, code)
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
async def send_waitlist_confirmation(settings: Settings, email: str) -> dict[str, str]:
|
|
157
|
+
return await asyncio.to_thread(_send_waitlist_confirmation_sync, settings, email)
|