Merge pull request #15 from Xevion/0.3.0

## Added

- A release checklist to the `CHANGELOG.md` file, as a reminder for procedure.
- An action workflow for invoking `pytest`, with coverage report generation in CI/CD
- backend: Login & Logout routes
- backend: Rate Limiting via custom `RateLimiter` dependency
- backend: `User` model, `Session` model with migration script
- backend: `Session` model constraints for `token` length, `expiry` & `last_used` timestamps
- backend: `SessionDependency` for easy session validation, enforcement & handling per route
- backend: provided `LOG_JSON_FORMAT` and `LOG_LEVEL` environment variable defaults in `run.sh` development script
- backend: Simple `/health` & `/api/migrations` endpoint tests
- backend: `utc_now` helper function
- backend: `pwdlib[argon2]`, `pytest` (`pytest-cov`, `pytest-xdist`), `limits`, `httpx`, `email-validator` pacakges
- frontend: Re-initialized with `vite` template, setup `@tanstack/router` & `shadcn` components.
- frontend: Added Login & Register page, added basic authentication check with redirect
- frontend: Added Zustand state management, basic login & session API functions with `true-myth` types.
- frontend: Added `zustand`, `true-myth`, `@tanstack/router`, `clsx`, `tailwind-merge` packages

## Changed

- Set `black` formatter line length to 120 characters
- backend: migration squashing threshold to 15
- backend: moved top level `app` routes to `router.misc`

## Removed

- frontend: Most old packages from initial `vite` template
- backend: `IPAddress` Model (definition + DB state via migration) & all related code
This commit is contained in:
2024-11-10 23:49:49 -06:00
committed by GitHub
75 changed files with 4456 additions and 3447 deletions

73
.github/workflows/checklist.yaml vendored Normal file
View File

@@ -0,0 +1,73 @@
name: Checklist
on:
pull_request:
paths:
- 'CHANGELOG.md'
jobs:
changelog:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/github-script@v7
with:
script: |
const fs = require('fs');
const path = 'CHANGELOG.md';
const changelog = fs.readFileSync(path, 'utf8');
let failed = false;
try {
// Check each line that starts with '##' for the version & date format
changelog.split('\n').forEach((line, index) => {
index += 1;
if (!line.startsWith('## '))
return;
if (line.toLowerCase().includes('unreleased')) {
const message = 'Unreleased section found. Please release the changes before merging.';
core.error(message, {
title: 'Unreleased Section Found',
file: path,
startline: index,
});
failed = true;
}
// Expected format: '## [X.Y.Z] - YYYY-MM-DD'
const pattern = /^\d+\.\d+\.\d+ - \d{4}-\d{2}-\d{2}$/;
if (pattern.test(line)) {
const message = 'Invalid version/date format. Expected: "## [X.Y.Z] - YYYY-MM-DD"';
core.error(message, {
title: 'Invalid Version/Date Format',
file: path,
startline: index,
});
failed = true;
}
});
if (failed) {
core.setFailed('Changelog validation failed')
}
} catch (error) {
core.setFailed(`Exception occurred while validating changelog: ${error}`);
}
draft:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/github-script@v7
with:
script: |
const forbiddenLabels = ['draft'];
let labels = context.payload.pull_request.labels;
if (labels.some(l => forbiddenLabels.includes(l.name))) {
core.setFailed(`Forbidden labels detected: ${forbiddenLabels.join(', ')}`);
}

83
.github/workflows/test.yaml vendored Normal file
View File

@@ -0,0 +1,83 @@
name: Pytest
on: [pull_request]
# https://docs.github.com/en/actions/using-jobs/assigning-permissions-to-jobs
# Required by MishaKav/pytest-coverage-comment
permissions:
contents: read
checks: write
pull-requests: write
env:
POETRY_VERSION: 1.8.4
jobs:
test:
runs-on: ubuntu-latest
steps:
- name: Check out repository
uses: actions/checkout@v4
- name: Set up python
id: setup-python
uses: actions/setup-python@v5
with:
python-version: "3.12.7"
- name: Install Poetry
uses: snok/install-poetry@v1
with:
version: ${{ env.POETRY_VERSION }}
virtualenvs-create: true
virtualenvs-in-project: true
- name: Load cached venv
id: cached-pip-wheels
uses: actions/cache@v4
with:
# TODO: Apparently this is failing for some reason, path does not exist? Fix after 0.3.0 release
path: .venv # While ~/.cache is a fine default, I want to separate this cache from other caches
key: venv-${{ steps.setup-python.outputs.python-version }}-${{ env.POETRY_VERSION }}-${{ hashFiles('**/poetry.lock') }}
- name: Install dependencies
run: cd backend && poetry install --no-interaction --no-root
# Disable for now, remove if ultimately not needed.
# - name: Install library
# run: cd backend && poetry install --no-interaction
- name: Acquire Database URL from Railway
env:
RAILWAY_TOKEN: ${{ secrets.RAILWAY_TOKEN }}
SERVICE_ID: Postgres
ENVIRONMENT_ID: development
run: |
bash <(curl -fsSL cli.new) --verbose --yes
DATABASE_URL=$(railway variables --service $SERVICE_ID --environment $ENVIRONMENT_ID --json | jq -cMr .DATABASE_PUBLIC_URL)
echo "::add-mask::$DATABASE_URL"
echo "DATABASE_URL=$DATABASE_URL" >> "$GITHUB_ENV"
- name: Pytest
env:
LOG_LEVEL: DEBUG
LOG_JSON_FORMAT: false
run: |
cd backend
set -o pipefail # otherwise 'tee' will eat the exit code
# TODO: Switch away from using the ENVIRONMENT variable during pytest or for anything at runtime
export ENVIRONMENT=development
poetry run pytest -n $(nproc) --color=yes --cov=linkpulse --cov-report=term-missing:skip-covered --junitxml=pytest.xml | tee pytest-coverage.txt
# pytest-coverage-comment won't error if the files are missing
if [ ! -f ./pytest-coverage.txt ] || [ ! -f ./pytest.xml ]; then
echo "::error::Coverage files not found"
exit 1
fi
- name: Pytest coverage comment
id: coverageComment
uses: MishaKav/pytest-coverage-comment@main
with:
pytest-coverage-path: backend/pytest-coverage.txt
junitxml-path: backend/pytest.xml

163
.gitignore vendored
View File

@@ -1,164 +1 @@
.env
# Byte-compiled / optimized / DLL files
__pycache__/
*.py[cod]
*$py.class
# C extensions
*.so
# Distribution / packaging
.Python
build/
develop-eggs/
dist/
downloads/
eggs/
.eggs/
lib/
lib64/
parts/
sdist/
var/
wheels/
share/python-wheels/
*.egg-info/
.installed.cfg
*.egg
MANIFEST
# PyInstaller
# Usually these files are written by a python script from a template
# before PyInstaller builds the exe, so as to inject date/other infos into it.
*.manifest
*.spec
# Installer logs
pip-log.txt
pip-delete-this-directory.txt
# Unit test / coverage reports
htmlcov/
.tox/
.nox/
.coverage
.coverage.*
.cache
nosetests.xml
coverage.xml
*.cover
*.py,cover
.hypothesis/
.pytest_cache/
cover/
# Translations
*.mo
*.pot
# Django stuff:
*.log
local_settings.py
db.sqlite3
db.sqlite3-journal
# Flask stuff:
instance/
.webassets-cache
# Scrapy stuff:
.scrapy
# Sphinx documentation
docs/_build/
# PyBuilder
.pybuilder/
target/
# Jupyter Notebook
.ipynb_checkpoints
# IPython
profile_default/
ipython_config.py
# pyenv
# For a library or package, you might want to ignore these files since the code is
# intended to run in multiple environments; otherwise, check them in:
# .python-version
# pipenv
# According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control.
# However, in case of collaboration, if having platform-specific dependencies or dependencies
# having no cross-platform support, pipenv may install dependencies that don't work, or not
# install all needed dependencies.
#Pipfile.lock
# poetry
# Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control.
# This is especially recommended for binary packages to ensure reproducibility, and is more
# commonly ignored for libraries.
# https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control
#poetry.lock
# pdm
# Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control.
#pdm.lock
# pdm stores project-wide configurations in .pdm.toml, but it is recommended to not include it
# in version control.
# https://pdm.fming.dev/latest/usage/project/#working-with-version-control
.pdm.toml
.pdm-python
.pdm-build/
# PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm
__pypackages__/
# Celery stuff
celerybeat-schedule
celerybeat.pid
# SageMath parsed files
*.sage.py
# Environments
.env
.venv
env/
venv/
ENV/
env.bak/
venv.bak/
# Spyder project settings
.spyderproject
.spyproject
# Rope project settings
.ropeproject
# mkdocs documentation
/site
# mypy
.mypy_cache/
.dmypy.json
dmypy.json
# Pyre type checker
.pyre/
# pytype static type analyzer
.pytype/
# Cython debug symbols
cython_debug/
# PyCharm
# JetBrains specific template is maintained in a separate JetBrains.gitignore that can
# be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore
# and can be added to the global gitignore or merged into this file. For a more nuclear
# option (not recommended) you can uncomment the following to ignore the entire idea folder.
#.idea/

51
.vscode/settings.json vendored
View File

@@ -1,18 +1,65 @@
{
"cSpell.words": [
"apscheduler",
"Avenir",
"backref",
"bpython",
"Callsite",
"clsx",
"excepthook",
"httpx",
"humanfs",
"humanwhocodes",
"inmemory",
"jridgewell",
"linkpulse",
"migratehistory",
"Nixpacks",
"nkzw",
"nocheck",
"ORJSON",
"pacakges",
"pext",
"postcss",
"pwdlib",
"pyproject",
"pytest",
"pytz",
"riscv",
"rollup",
"rtype",
"shadcn",
"starlette",
"structlog",
"timestamper"
"sugarss",
"tailwindcss",
"tanstack",
"timestamper",
"tseslint",
"vitest",
"xdist",
"Zustand's"
],
"python.analysis.extraPaths": ["./backend/"]
"python.analysis.extraPaths": ["./backend/"],
"editor.formatOnSave": true,
"[github-actions-workflow]": {
"editor.formatOnSave": false
},
"python.analysis.diagnosticMode": "workspace",
"cSpell.ignorePaths": [
"package-lock.json",
"node_modules",
"vscode-extension",
".git/objects",
".vscode",
".vscode-insiders",
"settings.json",
"*.lock",
"*lock.*",
"package.json",
"routeTree.gen.ts"
],
"files.associations": {
"Caddyfile.*": "caddyfile"
}
}

View File

@@ -5,6 +5,37 @@ All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
## [0.3.0]
## Added
- A release checklist to the `CHANGELOG.md` file, as a reminder for procedure.
- An action workflow for invoking `pytest`, with coverage report generation in CI/CD
- backend: Login & Logout routes
- backend: Rate Limiting via custom `RateLimiter` dependency
- backend: `User` model, `Session` model with migration script
- backend: `Session` model constraints for `token` length, `expiry` & `last_used` timestamps
- backend: `SessionDependency` for easy session validation, enforcement & handling per route
- backend: provided `LOG_JSON_FORMAT` and `LOG_LEVEL` environment variable defaults in `run.sh` development script
- backend: Simple `/health` & `/api/migrations` endpoint tests
- backend: `utc_now` helper function
- backend: `pwdlib[argon2]`, `pytest` (`pytest-cov`, `pytest-xdist`), `limits`, `httpx`, `email-validator` pacakges
- frontend: Re-initialized with `vite` template, setup `@tanstack/router` & `shadcn` components.
- frontend: Added Login & Register page, added basic authentication check with redirect
- frontend: Added Zustand state management, basic login & session API functions with `true-myth` types.
- frontend: Added `zustand`, `true-myth`, `@tanstack/router`, `clsx`, `tailwind-merge` packages
## Changed
- Set `black` formatter line length to 120 characters
- backend: migration squashing threshold to 15
- backend: moved top level `app` routes to `router.misc`
## Removed
- frontend: Most old packages from initial `vite` template
- backend: `IPAddress` Model (definition + DB state via migration) & all related code
## [0.2.2] - 2024-11-01
### Added

8
RELEASE_CHECKLIST.md Normal file
View File

@@ -0,0 +1,8 @@
# Release Checklist
1. Ensure `backend/pyproject.toml` and `frontend/package.json` have the correct version number for the upcoming release.
2. Ensure `CHANGELOG.md` contains all changes for the upcoming release, with the correct version number and date labeled.
- `Unreleased` section should not exist.
- The version should match `pyproject.toml` and `package.json`.
3. Ensure all tests pass locally, as well as the CI/CD pipeline.
4. Correct all linting errors and warnings.

165
backend/.gitignore vendored Normal file
View File

@@ -0,0 +1,165 @@
pytest.xml
pytest-coverage.txt
# Byte-compiled / optimized / DLL files
__pycache__/
*.py[cod]
*$py.class
# C extensions
*.so
# Distribution / packaging
.Python
build/
develop-eggs/
dist/
downloads/
eggs/
.eggs/
lib/
lib64/
parts/
sdist/
var/
wheels/
share/python-wheels/
*.egg-info/
.installed.cfg
*.egg
MANIFEST
# PyInstaller
# Usually these files are written by a python script from a template
# before PyInstaller builds the exe, so as to inject date/other infos into it.
*.manifest
*.spec
# Installer logs
pip-log.txt
pip-delete-this-directory.txt
# Unit test / coverage reports
htmlcov/
.tox/
.nox/
.coverage
.coverage.*
.cache
nosetests.xml
coverage.xml
*.cover
*.py,cover
.hypothesis/
.pytest_cache/
cover/
# Translations
*.mo
*.pot
# Django stuff:
*.log
local_settings.py
db.sqlite3
db.sqlite3-journal
# Flask stuff:
instance/
.webassets-cache
# Scrapy stuff:
.scrapy
# Sphinx documentation
docs/_build/
# PyBuilder
.pybuilder/
target/
# Jupyter Notebook
.ipynb_checkpoints
# IPython
profile_default/
ipython_config.py
# pyenv
# For a library or package, you might want to ignore these files since the code is
# intended to run in multiple environments; otherwise, check them in:
# .python-version
# pipenv
# According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control.
# However, in case of collaboration, if having platform-specific dependencies or dependencies
# having no cross-platform support, pipenv may install dependencies that don't work, or not
# install all needed dependencies.
#Pipfile.lock
# poetry
# Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control.
# This is especially recommended for binary packages to ensure reproducibility, and is more
# commonly ignored for libraries.
# https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control
#poetry.lock
# pdm
# Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control.
#pdm.lock
# pdm stores project-wide configurations in .pdm.toml, but it is recommended to not include it
# in version control.
# https://pdm.fming.dev/latest/usage/project/#working-with-version-control
.pdm.toml
.pdm-python
.pdm-build/
# PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm
__pypackages__/
# Celery stuff
celerybeat-schedule
celerybeat.pid
# SageMath parsed files
*.sage.py
# Environments
.env
.venv
env/
venv/
ENV/
env.bak/
venv.bak/
# Spyder project settings
.spyderproject
.spyproject
# Rope project settings
.ropeproject
# mkdocs documentation
/site
# mypy
.mypy_cache/
.dmypy.json
dmypy.json
# Pyre type checker
.pyre/
# pytype static type analyzer
.pytype/
# Cython debug symbols
cython_debug/
# PyCharm
# JetBrains specific template is maintained in a separate JetBrains.gitignore that can
# be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore
# and can be added to the global gitignore or merged into this file. For a more nuclear
# option (not recommended) you can uncomment the following to ignore the entire idea folder.
#.idea/

View File

View File

@@ -16,17 +16,18 @@ setup_logging()
import os
import sys
import structlog
import structlog
logger = structlog.get_logger()
def main(*args):
"""
Primary entrypoint for the LinkPulse application
- Don't import any modules globally unless you're certain it's necessary. Imports should be tightly controlled.
"""
def main(*args: str) -> None:
"""Primary entrypoint for the LinkPulse application
NOTE: Don't import any modules globally unless you're certain it's necessary. Imports should be tightly controlled.
:param args: The command-line arguments to parse and execute.
:type args: str"""
if args[0] == "serve":
from linkpulse.utilities import is_development
from uvicorn import run
@@ -57,9 +58,9 @@ def main(*args):
# import most useful objects, models, and functions
lp = linkpulse # alias
from linkpulse.utilities import get_db
from linkpulse.app import app
from linkpulse.models import BaseModel, IPAddress
from linkpulse.models import BaseModel, Session, User
from linkpulse.utilities import get_db
db = get_db()

View File

@@ -1,120 +1,53 @@
import random
from collections import defaultdict
from contextlib import asynccontextmanager
from dataclasses import dataclass, field
from datetime import datetime, timezone
from typing import AsyncIterator
import human_readable
import pytz
import structlog
from apscheduler.schedulers.background import BackgroundScheduler # type: ignore
from apscheduler.triggers.interval import IntervalTrigger # type: ignore
from asgi_correlation_id import CorrelationIdMiddleware
from dotenv import load_dotenv
from fastapi import FastAPI, Request, Response, status
from fastapi import FastAPI
from fastapi.responses import ORJSONResponse
from fastapi_cache import FastAPICache
from fastapi_cache.backends.inmemory import InMemoryBackend
from fastapi_cache.decorator import cache
from linkpulse.logging import setup_logging
from linkpulse.middleware import LoggingMiddleware
from linkpulse.utilities import get_db, get_ip, hide_ip, is_development
from psycopg2.extras import execute_values
from linkpulse.utilities import get_db, is_development
load_dotenv(dotenv_path=".env")
from linkpulse import models, responses # type: ignore
from linkpulse import models # type: ignore
db = get_db()
def flush_ips():
if len(app.state.buffered_updates) == 0:
logger.debug("No IPs to flush to Database")
return
try:
with db.atomic():
sql = """
WITH updates (ip, last_seen, increment) AS (VALUES %s)
INSERT INTO ipaddress (ip, last_seen, count)
SELECT ip, last_seen, increment
FROM updates
ON CONFLICT (ip)
DO UPDATE
SET count = ipaddress.count + EXCLUDED.count, last_seen = EXCLUDED.last_seen;
"""
rows = [
(ip, data.last_seen, data.count)
for ip, data in app.state.buffered_updates.items()
]
cur = db.cursor()
execute_values(cur, sql, rows)
except Exception as e:
logger.error("Failed to flush IPs to Database", error=e)
i = len(app.state.buffered_updates)
logger.debug("IPs written to database", count=i)
# Finish up
app.state.buffered_updates.clear()
scheduler = BackgroundScheduler()
scheduler.add_job(flush_ips, IntervalTrigger(seconds=5))
@asynccontextmanager
async def lifespan(_: FastAPI) -> AsyncIterator[None]:
# Originally, this was used to generate a pool of random IP addresses so we could demo a changing list.
# Now, this isn't necessary, but I just wanna test it for now. It'll be removed pretty soon.
random.seed(42) # 42 is the answer to everything
app.state.ip_pool = [
".".join(str(random.randint(0, 255)) for _ in range(4)) for _ in range(50)
]
# Connect to database, ensure specific tables exist
db.connect()
db.create_tables([models.IPAddress])
db.create_tables([models.User, models.Session])
# Delete all randomly generated IP addresses
with db.atomic():
logger.info(
"Deleting Randomized IP Addresses", ip_pool_count=len(app.state.ip_pool)
)
query = models.IPAddress.delete().where(
models.IPAddress.ip << app.state.ip_pool
)
row_count = query.execute()
logger.info("Randomized IP Addresses deleted", row_count=row_count)
FastAPICache.init(
backend=InMemoryBackend(), prefix="fastapi-cache", cache_status_header="X-Cache"
)
app.state.buffered_updates = defaultdict(IPCounter)
FastAPICache.init(backend=InMemoryBackend(), prefix="fastapi-cache", cache_status_header="X-Cache")
scheduler.start()
yield
scheduler.shutdown()
flush_ips()
if not db.is_closed():
db.close()
@dataclass
class IPCounter:
# Note: This is not the true 'seen' count, but the count of how many times the IP has been seen since the last flush.
count: int = 0
last_seen: datetime = field(default_factory=lambda: datetime.now(timezone.utc))
from linkpulse.routers import auth, misc
# TODO: Apply migrations on startup in production environments
app = FastAPI(lifespan=lifespan, default_response_class=ORJSONResponse)
app.include_router(auth.router)
app.include_router(misc.router)
setup_logging()
@@ -123,79 +56,19 @@ logger = structlog.get_logger()
if is_development:
from fastapi.middleware.cors import CORSMiddleware
origins = [
"http://localhost:8080",
"http://localhost:5173",
]
app.add_middleware(
CORSMiddleware,
allow_origins=[
"http://localhost",
"http://localhost:5173",
],
allow_origins=origins,
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
)
logger.info("CORS Enabled", origins=origins)
app.add_middleware(LoggingMiddleware)
app.add_middleware(CorrelationIdMiddleware)
@app.get("/health")
async def health():
return "OK"
@app.get("/api/migration")
@cache(expire=60)
async def get_migration():
"""
Returns the details of the most recent migration.
"""
# Kind of insecure, but this is just a demo thing to show that migratehistory is available.
cursor = db.execute_sql(
"SELECT name, migrated_at FROM migratehistory ORDER BY migrated_at DESC LIMIT 1"
)
name, migrated_at = cursor.fetchone()
return {"name": name, "migrated_at": migrated_at}
@app.get("/api/ips")
async def get_ips(request: Request, response: Response):
"""
Returns a list of partially redacted IP addresses, as well as submitting the user's IP address to the database (buffered).
"""
now = datetime.now(timezone.utc)
# Get the user's IP address
user_ip = get_ip(request)
# If the IP address is not found, return an error
if user_ip is None:
logger.warning("unable to acquire user IP address")
response.status_code = status.HTTP_403_FORBIDDEN
return {"error": "Unable to handle request."}
# Update the buffered updates
app.state.buffered_updates[user_ip].count += 1
app.state.buffered_updates[user_ip].last_seen = now
# Query the latest IPs
latest_ips = (
models.IPAddress.select(
models.IPAddress.ip, models.IPAddress.last_seen, models.IPAddress.count
)
.order_by(models.IPAddress.last_seen.desc())
.limit(10)
)
return {
"ips": [
responses.SeenIP(
ip=hide_ip(ip.ip) if ip.ip != user_ip else ip.ip,
last_seen=human_readable.date_time(
value=pytz.utc.localize(ip.last_seen),
when=datetime.now(timezone.utc),
),
count=ip.count,
)
for ip in latest_ips
]
}

View File

@@ -0,0 +1,75 @@
import os
from dataclasses import dataclass
import structlog
from fastapi import HTTPException, Request, Response, status
from limits import parse
from limits.aio.storage import MemoryStorage
from limits.aio.strategies import MovingWindowRateLimiter
from linkpulse.models import Session
storage = MemoryStorage()
strategy = MovingWindowRateLimiter(storage)
logger = structlog.get_logger()
is_pytest = os.environ.get("PYTEST_VERSION") is not None
class RateLimiter:
def __init__(self, limit: str):
self.limit = parse(limit)
self.retry_after = str(self.limit.get_expiry())
async def __call__(self, request: Request, response: Response):
key = request.headers.get("X-Real-IP")
if key is None:
if request.client is None:
logger.warning("No client information available for request.")
return False
key = request.client.host
if is_pytest:
# This is somewhat hacky, I'm not sure if there's a way it can break during pytesting, but look here if odd rate limiting errors occur during tests
# The reason for this is so tests don't compete with each other for rate limiting
key += "." + os.environ["PYTEST_CURRENT_TEST"]
if not await strategy.hit(self.limit, key):
logger.warning("Rate limit exceeded", key=key)
raise HTTPException(
status_code=status.HTTP_429_TOO_MANY_REQUESTS,
detail="Too Many Requests",
headers={"Retry-After": self.retry_after},
)
return True
class SessionDependency:
def __init__(self, required: bool = False):
self.required = required
async def __call__(self, request: Request, response: Response):
session_token = request.cookies.get("session")
# If not present, raise 401 if required
if session_token is None:
logger.debug("No session cookie found", required=self.required)
if self.required:
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Unauthorized")
return None
# Get session from database
session = Session.get_or_none(Session.token == session_token)
# This doesn't differentiate between expired or completely invalid sessions
if session is None or session.is_expired(revoke=True):
if self.required:
logger.debug("Session Cookie Revoked", token=session_token)
response.delete_cookie("session")
headers = {"set-cookie": response.headers["set-cookie"]}
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED, detail="Unauthorized", headers=headers
)
return None
return session

View File

@@ -31,12 +31,12 @@ def drop_color_message_key(_: Any, __: Any, event_dict: EventDict) -> EventDict:
return event_dict
def setup_logging(
json_logs: Optional[bool] = None, log_level: Optional[str] = None
) -> None:
def setup_logging(json_logs: Optional[bool] = None, log_level: Optional[str] = None) -> None:
# Pull from environment variables, apply defaults if not set
json_logs = json_logs or os.getenv("LOG_JSON_FORMAT", "true").lower() == "true"
log_level = log_level or os.getenv("LOG_LEVEL", "INFO")
if json_logs is None:
json_logs = os.getenv("LOG_JSON_FORMAT", "true").lower() == "true"
if log_level is None:
log_level = os.getenv("LOG_LEVEL", "INFO")
def flatten(n):
"""
@@ -158,8 +158,6 @@ def setup_logging(
sys.__excepthook__(exc_type, exc_value, exc_traceback)
return
root_logger.error(
"Uncaught exception", exc_info=(exc_type, exc_value, exc_traceback)
)
root_logger.error("Uncaught exception", exc_info=(exc_type, exc_value, exc_traceback))
sys.excepthook = handle_exception

View File

@@ -36,11 +36,7 @@ class ExtendedRouter(Router):
try:
modules = models
if isinstance(module, bool):
modules = [
m
for _, m, ispkg in pkgutil.iter_modules([f"{router.CURDIR}"])
if ispkg
]
modules = [m for _, m, ispkg in pkgutil.iter_modules([f"{router.CURDIR}"]) if ispkg]
models = [m for module in modules for m in router.load_models(module)]
except ImportError:
@@ -48,7 +44,7 @@ class ExtendedRouter(Router):
return None
if self.ignore:
models = [m for m in models if m._meta.name not in self.ignore] # type: ignore[]
models = [m for m in models if m._meta.name not in self.ignore] # type: ignore
for migration in self.diff:
self.run_one(migration, self.migrator, fake=True)
@@ -91,9 +87,7 @@ def main(*args: str) -> None:
diff = router.diff
if len(diff) == 0:
logger.info(
"No migrations found, no pending migrations to apply. Creating initial migration."
)
logger.info("No migrations found, no pending migrations to apply. Creating initial migration.")
migration = router.create("initial", auto=target_models)
if not migration:
@@ -107,13 +101,9 @@ def main(*args: str) -> None:
logger.info(
"Note: Selecting a migration will apply all migrations up to and including the selected migration."
)
logger.info(
"e.g. Applying 004 while only 001 is applied would apply 002, 003, and 004."
)
logger.info("e.g. Applying 004 while only 001 is applied would apply 002, 003, and 004.")
choice = questionary.select(
"Select highest migration to apply:", choices=diff
).ask()
choice = questionary.select("Select highest migration to apply:", choices=diff).ask()
if choice is None:
logger.warning(
"For safety reasons, you won't be able to create migrations without applying the pending ones."
@@ -163,14 +153,10 @@ def main(*args: str) -> None:
logger.info(f"Migration created: {migration}")
if len(router.diff) == 1:
if questionary.confirm(
"Do you want to apply this migration immediately?"
).ask():
if questionary.confirm("Do you want to apply this migration immediately?").ask():
router.run(migration)
logger.info("Done.")
logger.warning(
"!!! Commit and push this migration file immediately!"
)
logger.warning("!!! Commit and push this migration file immediately!")
else:
raise RuntimeError(
"Changes anticipated with show() but no migration created with create(), model definition may have reverted."
@@ -178,9 +164,10 @@ def main(*args: str) -> None:
else:
logger.info("No database changes detected.")
if len(current) > 5:
migration_squash_threshold: int = 15
if len(current) > migration_squash_threshold:
if questionary.confirm(
"There are more than 5 migrations applied. Do you want to merge them?",
f"There are more than {migration_squash_threshold} migrations applied. Do you want to merge them?",
default=False,
).ask():
logger.info("Merging migrations...")

View File

@@ -0,0 +1,66 @@
"""Peewee migrations -- 004_create_user_remove_ipaddress.py.
Some examples (model - class or model name)::
> Model = migrator.orm['table_name'] # Return model in current state by name
> Model = migrator.ModelClass # Return model in current state by name
> migrator.sql(sql) # Run custom SQL
> migrator.run(func, *args, **kwargs) # Run python function with the given args
> migrator.create_model(Model) # Create a model (could be used as decorator)
> migrator.remove_model(model, cascade=True) # Remove a model
> migrator.add_fields(model, **fields) # Add fields to a model
> migrator.change_fields(model, **fields) # Change fields
> migrator.remove_fields(model, *field_names, cascade=True)
> migrator.rename_field(model, old_field_name, new_field_name)
> migrator.rename_table(model, new_table_name)
> migrator.add_index(model, *col_names, unique=False)
> migrator.add_not_null(model, *field_names)
> migrator.add_default(model, field_name, default)
> migrator.add_constraint(model, name, sql)
> migrator.drop_index(model, *col_names)
> migrator.drop_not_null(model, *field_names)
> migrator.drop_constraints(model, *constraints)
"""
from contextlib import suppress
import peewee as pw
from peewee_migrate import Migrator
with suppress(ImportError):
import playhouse.postgres_ext as pw_pext
def migrate(migrator: Migrator, database: pw.Database, *, fake=False):
"""Write your migrations here."""
@migrator.create_model
class User(pw.Model):
id = pw.AutoField()
email = pw.CharField(max_length=45, unique=True)
password_hash = pw.CharField(max_length=96)
created_at = pw.DateTimeField()
updated_at = pw.DateTimeField()
class Meta:
table_name = "user"
migrator.remove_model("ipaddress")
def rollback(migrator: Migrator, database: pw.Database, *, fake=False):
"""Write your rollback migrations here."""
@migrator.create_model
class IPAddress(pw.Model):
ip = pw.CharField(max_length=255, primary_key=True)
last_seen = pw.DateTimeField()
count = pw.IntegerField(default=0)
class Meta:
table_name = "ipaddress"
migrator.remove_model("user")

View File

@@ -0,0 +1,63 @@
"""Peewee migrations -- 005_create_session_add_user_flags.py.
Some examples (model - class or model name)::
> Model = migrator.orm['table_name'] # Return model in current state by name
> Model = migrator.ModelClass # Return model in current state by name
> migrator.sql(sql) # Run custom SQL
> migrator.run(func, *args, **kwargs) # Run python function with the given args
> migrator.create_model(Model) # Create a model (could be used as decorator)
> migrator.remove_model(model, cascade=True) # Remove a model
> migrator.add_fields(model, **fields) # Add fields to a model
> migrator.change_fields(model, **fields) # Change fields
> migrator.remove_fields(model, *field_names, cascade=True)
> migrator.rename_field(model, old_field_name, new_field_name)
> migrator.rename_table(model, new_table_name)
> migrator.add_index(model, *col_names, unique=False)
> migrator.add_not_null(model, *field_names)
> migrator.add_default(model, field_name, default)
> migrator.add_constraint(model, name, sql)
> migrator.drop_index(model, *col_names)
> migrator.drop_not_null(model, *field_names)
> migrator.drop_constraints(model, *constraints)
"""
from contextlib import suppress
import peewee as pw
from peewee_migrate import Migrator
with suppress(ImportError):
import playhouse.postgres_ext as pw_pext
def migrate(migrator: Migrator, database: pw.Database, *, fake=False):
"""Write your migrations here."""
migrator.add_fields(
'user',
flags=pw.BitField(default=0),
deleted_at=pw.DateTimeField(null=True))
@migrator.create_model
class Session(pw.Model):
token = pw.CharField(max_length=32, primary_key=True)
user = pw.ForeignKeyField(column_name='user_id', field='id', model=migrator.orm['user'], on_delete='CASCADE')
expiry = pw.DateTimeField()
created_at = pw.DateTimeField()
last_used = pw.DateTimeField(null=True)
class Meta:
table_name = "session"
def rollback(migrator: Migrator, database: pw.Database, *, fake=False):
"""Write your rollback migrations here."""
migrator.remove_fields('user', 'flags', 'deleted_at')
migrator.remove_model('session')

View File

@@ -0,0 +1,63 @@
"""Peewee migrations -- 006_add_session_constraints.py.
Some examples (model - class or model name)::
> Model = migrator.orm['table_name'] # Return model in current state by name
> Model = migrator.ModelClass # Return model in current state by name
> migrator.sql(sql) # Run custom SQL
> migrator.run(func, *args, **kwargs) # Run python function with the given args
> migrator.create_model(Model) # Create a model (could be used as decorator)
> migrator.remove_model(model, cascade=True) # Remove a model
> migrator.add_fields(model, **fields) # Add fields to a model
> migrator.change_fields(model, **fields) # Change fields
> migrator.remove_fields(model, *field_names, cascade=True)
> migrator.rename_field(model, old_field_name, new_field_name)
> migrator.rename_table(model, new_table_name)
> migrator.add_index(model, *col_names, unique=False)
> migrator.add_not_null(model, *field_names)
> migrator.add_default(model, field_name, default)
> migrator.add_constraint(model, name, sql)
> migrator.drop_index(model, *col_names)
> migrator.drop_not_null(model, *field_names)
> migrator.drop_constraints(model, *constraints)
"""
from contextlib import suppress
import peewee as pw
from peewee_migrate import Migrator
with suppress(ImportError):
import playhouse.postgres_ext as pw_pext
def migrate(migrator: Migrator, database: pw.Database, *, fake=False):
"""Write your migrations here."""
migrator.add_constraint(
"session", "session_token_length", pw.Check("LENGTH(token) = 32")
)
migrator.add_constraint(
"session", "session_expiry_created_at", pw.Check("expiry > created_at")
)
migrator.add_constraint(
"session",
"session_last_used_created_at",
pw.Check("last_used IS NULL OR last_used >= created_at"),
)
def rollback(migrator: Migrator, database: pw.Database, *, fake=False):
"""Write your rollback migrations here."""
migrator.drop_constraints(
"session",
"session_token_length",
"session_expiry_created_at",
"session_last_used_created_at",
)

View File

@@ -0,0 +1,50 @@
"""Peewee migrations -- 007_password_hash_length_adjust_session_token_index.py.
Some examples (model - class or model name)::
> Model = migrator.orm['table_name'] # Return model in current state by name
> Model = migrator.ModelClass # Return model in current state by name
> migrator.sql(sql) # Run custom SQL
> migrator.run(func, *args, **kwargs) # Run python function with the given args
> migrator.create_model(Model) # Create a model (could be used as decorator)
> migrator.remove_model(model, cascade=True) # Remove a model
> migrator.add_fields(model, **fields) # Add fields to a model
> migrator.change_fields(model, **fields) # Change fields
> migrator.remove_fields(model, *field_names, cascade=True)
> migrator.rename_field(model, old_field_name, new_field_name)
> migrator.rename_table(model, new_table_name)
> migrator.add_index(model, *col_names, unique=False)
> migrator.add_not_null(model, *field_names)
> migrator.add_default(model, field_name, default)
> migrator.add_constraint(model, name, sql)
> migrator.drop_index(model, *col_names)
> migrator.drop_not_null(model, *field_names)
> migrator.drop_constraints(model, *constraints)
"""
from contextlib import suppress
import peewee as pw
from peewee_migrate import Migrator
with suppress(ImportError):
import playhouse.postgres_ext as pw_pext
def migrate(migrator: Migrator, database: pw.Database, *, fake=False):
"""Write your migrations here."""
migrator.change_fields('user', password_hash=pw.CharField(max_length=97))
migrator.add_index('session', 'token', unique=True)
def rollback(migrator: Migrator, database: pw.Database, *, fake=False):
"""Write your rollback migrations here."""
migrator.drop_index('session', 'token')
migrator.change_fields('user', password_hash=pw.CharField(max_length=96))

View File

@@ -3,10 +3,14 @@ This module defines the database models for the LinkPulse backend.
It also provides a base model with database connection details.
"""
import datetime
import secrets
from os import getenv
from typing import Optional
import structlog
from peewee import CharField, DateTimeField, IntegerField, Model
from linkpulse.utilities import utc_now
from peewee import AutoField, BitField, CharField, Check, DateTimeField, ForeignKeyField, Model
from playhouse.db_url import connect
logger = structlog.get_logger()
@@ -26,7 +30,80 @@ class BaseModel(Model):
database = connect(url=_get_database_url())
class IPAddress(BaseModel):
ip = CharField(primary_key=True)
last_seen = DateTimeField() # timezone naive
count = IntegerField(default=0)
class User(BaseModel):
id = AutoField(primary_key=True)
# arbitrary max length, but statistically reasonable and limits UI concerns/abuse cases
email = CharField(unique=True, max_length=45)
flags = BitField()
# full hash with encoded salt/parameters, argon2 but assume nothing
password_hash = CharField(max_length=97)
created_at = DateTimeField(default=utc_now)
updated_at = DateTimeField(default=utc_now)
# prefer soft deletes before hard deletes
deleted_at = DateTimeField(null=True)
deleted = flags.flag(1)
# TODO: delete method, ensure sessions are deleted as well
# TODO: undelete method
class Session(BaseModel):
"""
A session represents a user's login session.
For now, a session returned from the API implies it's validity.
In the future, sessions may be invalidated or revoked, but kept in the database AND returned.
This could allow sessions to be tracked and audited even after they are no longer valid, or allow more proper 'logout' messages.
"""
token = CharField(unique=True, primary_key=True, max_length=32)
user = ForeignKeyField(User, backref="sessions", on_delete="CASCADE")
expiry = DateTimeField()
created_at = DateTimeField(default=utc_now)
last_used = DateTimeField(default=None, null=True)
class Meta:
constraints = [
Check("LENGTH(token) = 32", name="session_token_length"),
Check("expiry > created_at", name="session_expiry_created_at"),
Check(
"last_used IS NULL OR last_used >= created_at",
name="session_last_used_created_at",
),
]
@classmethod
def generate_token(cls) -> str:
alphabet = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789"
return "".join(secrets.choice(alphabet) for _ in range(32))
@property
def expiry_utc(self) -> datetime.datetime:
return self.expiry.replace(tzinfo=datetime.timezone.utc) # type: ignore
def is_expired(self, revoke: bool = True, now: Optional[datetime.datetime] = None) -> bool:
"""
Check if the session is expired. If `revoke` is True, the session will be automatically revoked if it is expired.
"""
if now is None:
now = utc_now()
if self.expiry_utc < now:
logger.debug("Session expired", token=self.token, user=self.user.email, revoke=revoke)
if revoke:
self.delete_instance()
return True
return False
def use(self, now: Optional[datetime.datetime] = None):
"""
Update the last_used field of the session.
"""
if now is None:
now = utc_now()
self.last_used = now # type: ignore
# TODO: This should be buffered, as it'll be called *constantly*, perhaps every single request.
# The ideal solution would be emitting updates to a Redis-based cache, and then flushing to the database every few seconds/minute.
self.save()

View File

View File

@@ -0,0 +1,164 @@
from datetime import datetime, timedelta
from typing import Annotated, Optional, Tuple
import structlog
from fastapi import APIRouter, Depends, Response, status
from linkpulse.dependencies import RateLimiter, SessionDependency
from linkpulse.models import Session, User
from linkpulse.utilities import utc_now, is_development
from pwdlib import PasswordHash
from pwdlib.hashers.argon2 import Argon2Hasher
from pydantic import BaseModel, EmailStr, Field
logger = structlog.get_logger()
router = APIRouter()
hasher = PasswordHash([Argon2Hasher()])
# cspell: disable
dummy_hash = (
"$argon2id$v=19$m=65536,t=3,p=4$Ii3hm5/NqcJddQDFK24Wtw$I99xV/qkaLROo0VZcvaZrYMAD9RTcWzxY5/RbMoRLQ4"
)
# Session expiry times
default_session_expiry = timedelta(hours=12)
remember_me_session_expiry = timedelta(days=14)
def validate_session(token: str, user: bool = True) -> Tuple[bool, bool, Optional[User]]:
"""Given a token, validate that the session exists and is not expired.
This function has side effects:
- This function updates last_used if `user` is True.
- This function will invalidate the session if it is expired.
:param token: The session token to validate.
:type token: str
:param user: Whether to update the last_used timestamp of the session.
:type user: bool
:return: A tuple containing:
- A boolean indicating if the session exists.
- A boolean indicating if the session is valid.
- The User object if the session is valid, otherwise None.
:rtype: Tuple[bool, bool, Optional[User]]
"""
# Check if session exists
session = Session.get_or_none(Session.token == token)
if session is None:
return False, False, None
# Check if session is expired
if session.is_expired(revoke=True):
return True, False, None
if user:
session.use()
return True, True, session.user
class LoginBody(BaseModel):
email: EmailStr # May be a heavy check; profiling could determine if this is necessary
password: str = Field(min_length=1) # Basic check, registration will have more stringent requirements
remember_me: bool = False
class LoginError(BaseModel):
error: str
class LoginSuccess(BaseModel):
email: EmailStr
expiry: datetime
@router.post(
"/api/login",
responses={200: {"model": LoginSuccess}, 401: {"model": LoginError}},
dependencies=[Depends(RateLimiter("6/minute"))],
)
async def login(body: LoginBody, response: Response):
# Acquire user by email
user = User.get_or_none(User.email == body.email)
if user is None:
# Hash regardless of user existence to prevent timing attacks
hasher.verify(body.password, dummy_hash)
response.status_code = status.HTTP_401_UNAUTHORIZED
return LoginError(error="Invalid email or password")
logger.warning("Hash", hash=user.password_hash)
valid, updated_hash = hasher.verify_and_update(body.password, user.password_hash)
# Check if password matches, return 401 if not
if not valid:
response.status_code = status.HTTP_401_UNAUTHORIZED
return LoginError(error="Invalid email or password")
# Update password hash if necessary
if updated_hash:
user.password_hash = updated_hash
user.save()
# Create session
token = Session.generate_token()
session_duration = remember_me_session_expiry if body.remember_me else default_session_expiry
session = Session.create(
token=token,
user=user,
expiry=utc_now() + session_duration,
)
# Set Cookie of session token
max_age = int(session_duration.total_seconds())
response.set_cookie("session", token, max_age=max_age, secure=not is_development, httponly=True)
return {"email": user.email, "expiry": session.expiry}
@router.post("/api/logout", status_code=status.HTTP_200_OK)
async def logout(
response: Response,
session: Annotated[Session, Depends(SessionDependency(required=True))],
all: bool = False,
):
# We can assume the session is valid via the dependency
if not all:
session.delete_instance()
logger.debug("Session deleted", user=session.user.email, token=session.token)
else:
count = Session.delete().where(Session.user == session.user).execute()
logger.debug("All sessions deleted", user=session.user.email, count=count, source_token=session.token)
response.delete_cookie("session")
@router.post("/api/register")
async def register():
# Validate parameters
# Hash password
# Create User
# Create Session
# Set Cookie of session token
# Return 200 with mild user information
pass
@router.get("/api/session")
async def session(session: Annotated[Session, Depends(SessionDependency(required=True))]):
# Returns the session information for the current session
return {
"user": {
"email": session.user.email,
}
}
@router.get("/api/sessions")
async def sessions(session: Annotated[Session, Depends(SessionDependency(required=True))]):
# Returns a list of all active sessions for this user
return {}
# GET /api/user/{id}/sessions
# GET /api/user/{id}/sessions/{token}
# DELETE /api/user/{id}/sessions
# POST /api/user/{id}/logout (delete all sessions)

View File

@@ -0,0 +1,33 @@
"""Miscellaneous endpoints for the Linkpulse API."""
from typing import Any
from fastapi import APIRouter
from fastapi_cache.decorator import cache
from linkpulse.utilities import get_db
router = APIRouter()
db = get_db()
@router.get("/health")
async def health():
"""An endpoint to check if the service is running.
:return: OK
:rtype: Literal['OK']"""
# TODO: Check database connection
return "OK"
@router.get("/api/migration")
@cache(expire=60)
async def get_migration() -> dict[str, Any]:
"""Get the last migration name and timestamp from the migratehistory table.
:return: The last migration name and timestamp.
:rtype: dict[str, Any]
"""
# Kind of insecure, but this is just a demo thing to show that migratehistory is available.
cursor = db.execute_sql("SELECT name, migrated_at FROM migratehistory ORDER BY migrated_at DESC LIMIT 1")
name, migrated_at = cursor.fetchone()
return {"name": name, "migrated_at": migrated_at}

View File

View File

@@ -0,0 +1,15 @@
import random
import string
import time
def epoch() -> int:
return int(time.time())
def random_string(length: int = 10) -> str:
return "".join(random.choices(string.ascii_lowercase + string.digits, k=length))
def random_email() -> str:
return random_string() + str(epoch()) + "@example.com"

View File

@@ -0,0 +1,16 @@
from fastapi.testclient import TestClient
from linkpulse.app import app
def test_health():
with TestClient(app) as client:
response = client.get("/health")
assert response.status_code == 200
assert response.json() == "OK"
def test_migration():
with TestClient(app) as client:
response = client.get("/api/migration")
assert response.status_code == 200

View File

@@ -0,0 +1,82 @@
from datetime import datetime, timedelta
from wsgiref import headers
import pytest
import structlog
from fastapi import status
from fastapi.testclient import TestClient
from linkpulse.app import app
from linkpulse.tests.test_session import expired_session, session
from linkpulse.tests.test_user import user
from linkpulse.utilities import utc_now
logger = structlog.get_logger()
def test_auth_login(user):
args = {"email": user.email, "password": "password"}
with TestClient(app) as client:
def test_expiry(response, expected):
expiry = datetime.fromisoformat(response.json()["expiry"])
relative_expiry_days = (expiry - utc_now()).total_seconds() / timedelta(days=1).total_seconds()
assert relative_expiry_days == pytest.approx(expected, rel=1e-5)
# Remember Me, default False
response = client.post("/api/login", json=args)
assert response.status_code == status.HTTP_200_OK
test_expiry(response, 0.5)
assert client.cookies.get("session") is not None
# Remember Me, True
response = client.post("/api/login", json={**args, "remember_me": True})
assert response.status_code == status.HTTP_200_OK
test_expiry(response, 14)
# Invalid Email
response = client.post("/api/login", json={**args, "email": "invalid_email"})
assert response.status_code == status.HTTP_422_UNPROCESSABLE_ENTITY
# Wrong Email
response = client.post("/api/login", json={**args, "email": "bad@email.com"})
assert response.status_code == status.HTTP_401_UNAUTHORIZED
# Wrong Password
response = client.post("/api/login", json={**args, "password": "bad_password"})
assert response.status_code == status.HTTP_401_UNAUTHORIZED
def test_auth_login_logout(user):
"""Test full login & logout cycle"""
args = {"email": user.email, "password": "password"}
with TestClient(app) as client:
response = client.post("/api/logout")
assert response.status_code == status.HTTP_401_UNAUTHORIZED
response = client.post("/api/login", json=args)
assert response.status_code == status.HTTP_200_OK
assert client.cookies.get("session") is not None
response = client.post("/api/logout")
assert response.status_code == status.HTTP_200_OK
assert client.cookies.get("session") is None
def test_auth_logout_expired(expired_session):
# Test that an expired session cannot be used to logout, but still removes the cookie
with TestClient(app) as client:
response = client.post("/api/logout")
assert response.status_code == status.HTTP_401_UNAUTHORIZED
# Add expired session cookie
client.cookies.set("session", expired_session.token)
assert client.cookies.get("session") is not None
# Attempt to logout
response = client.post("/api/logout")
assert response.status_code == status.HTTP_401_UNAUTHORIZED
assert response.headers.get("set-cookie") is not None
# TODO: Ensure ?all=True doesn't do anything either

View File

@@ -0,0 +1,22 @@
import structlog
from fastapi import status
from fastapi.testclient import TestClient
from linkpulse.app import app
from linkpulse.tests.test_user import user
logger = structlog.get_logger()
def test_rate_limit(user):
args = {"email": user.email, "password": "password"}
with TestClient(app) as client:
for _ in range(6):
response = client.post("/api/login", json=args)
assert response.status_code == status.HTTP_200_OK
# 7th request should be rate limited
response = client.post("/api/login", json=args)
assert response.status_code == status.HTTP_429_TOO_MANY_REQUESTS
assert "Retry-After" in response.headers
assert int(response.headers["Retry-After"]) > 1

View File

@@ -0,0 +1,78 @@
from datetime import timedelta
import pytest
import structlog
from linkpulse.models import Session
from linkpulse.routers.auth import validate_session
from linkpulse.tests.random import random_string
from linkpulse.tests.test_user import user
from linkpulse.utilities import utc_now
from peewee import IntegrityError
logger = structlog.get_logger()
@pytest.fixture
def db():
return Session._meta.database
@pytest.fixture
def session(user):
return Session.create(user=user, token=Session.generate_token(), expiry=utc_now() + timedelta(hours=1))
@pytest.fixture
def expired_session(session):
session.created_at = utc_now() - timedelta(hours=2) # Required to bypass the constraint
session.expiry = utc_now() - timedelta(hours=1)
session.save()
return session
def test_expired_session_fixture(expired_session):
assert expired_session.is_expired() is True
def test_session_create(session):
assert Session.get_or_none(Session.token == session.token) is not None
def test_auto_revoke(db, expired_session):
# Expired, but still exists
assert Session.get_or_none(Session.token == expired_session.token) is not None
# Test revoke=False
assert expired_session.is_expired(revoke=False) is True
# Test revoke=True
assert expired_session.is_expired(revoke=True) is True
# Expired, and no longer exists
assert Session.get_or_none(Session.token == expired_session.token) is None
def test_expiry_valid(session):
assert session.is_expired() is False
def test_expiry_invalid(expired_session):
assert expired_session.is_expired() is True
def test_session_constraint_token_length(user):
Session.create(user=user, token=Session.generate_token(), expiry=utc_now() + timedelta(hours=1))
with pytest.raises(IntegrityError):
Session.create(user=user, token=Session.generate_token()[:-1], expiry=utc_now() + timedelta(hours=1))
def test_session_constraint_expiry(user):
Session.create(user=user, token=Session.generate_token(), expiry=utc_now() + timedelta(minutes=1))
with pytest.raises(IntegrityError):
Session.create(user=user, token=Session.generate_token(), expiry=utc_now())
def test_validate_session(db, session):
assert session.last_used is None
assert validate_session(session.token, user=True) == (True, True, session.user)
session = Session.get(Session.token == session.token)
assert session.last_used is not None

View File

@@ -0,0 +1,12 @@
import pytest
import structlog
from linkpulse.models import User
from linkpulse.routers.auth import hasher
from linkpulse.tests.random import random_email
logger = structlog.get_logger()
@pytest.fixture
def user():
return User.create(email=random_email(), password_hash=hasher.hash("password"))

View File

@@ -0,0 +1,6 @@
from linkpulse.utilities import utc_now
def test_utcnow_tz_aware():
dt = utc_now()
dt.tzinfo is not None and dt.tzinfo.utcoffset(dt) is not None

View File

@@ -3,8 +3,10 @@ This module provides utility functions for database connection, string manipulat
"""
import os
from datetime import datetime
from typing import Optional
import pytz
from fastapi import Request
from peewee import PostgresqlDatabase
@@ -12,6 +14,13 @@ from peewee import PostgresqlDatabase
is_development = os.getenv("ENVIRONMENT") == "development"
def utc_now() -> datetime:
"""
A utility function to replace the deprecated datetime.datetime.utcnow() function.
"""
return datetime.now(pytz.utc)
def get_db() -> PostgresqlDatabase:
"""
Acquires the database connector from the BaseModel class.

564
backend/poetry.lock generated
View File

@@ -70,6 +70,63 @@ tornado = ["tornado (>=4.3)"]
twisted = ["twisted"]
zookeeper = ["kazoo"]
[[package]]
name = "argon2-cffi"
version = "23.1.0"
description = "Argon2 for Python"
optional = false
python-versions = ">=3.7"
files = [
{file = "argon2_cffi-23.1.0-py3-none-any.whl", hash = "sha256:c670642b78ba29641818ab2e68bd4e6a78ba53b7eff7b4c3815ae16abf91c7ea"},
{file = "argon2_cffi-23.1.0.tar.gz", hash = "sha256:879c3e79a2729ce768ebb7d36d4609e3a78a4ca2ec3a9f12286ca057e3d0db08"},
]
[package.dependencies]
argon2-cffi-bindings = "*"
[package.extras]
dev = ["argon2-cffi[tests,typing]", "tox (>4)"]
docs = ["furo", "myst-parser", "sphinx", "sphinx-copybutton", "sphinx-notfound-page"]
tests = ["hypothesis", "pytest"]
typing = ["mypy"]
[[package]]
name = "argon2-cffi-bindings"
version = "21.2.0"
description = "Low-level CFFI bindings for Argon2"
optional = false
python-versions = ">=3.6"
files = [
{file = "argon2-cffi-bindings-21.2.0.tar.gz", hash = "sha256:bb89ceffa6c791807d1305ceb77dbfacc5aa499891d2c55661c6459651fc39e3"},
{file = "argon2_cffi_bindings-21.2.0-cp36-abi3-macosx_10_9_x86_64.whl", hash = "sha256:ccb949252cb2ab3a08c02024acb77cfb179492d5701c7cbdbfd776124d4d2367"},
{file = "argon2_cffi_bindings-21.2.0-cp36-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:9524464572e12979364b7d600abf96181d3541da11e23ddf565a32e70bd4dc0d"},
{file = "argon2_cffi_bindings-21.2.0-cp36-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b746dba803a79238e925d9046a63aa26bf86ab2a2fe74ce6b009a1c3f5c8f2ae"},
{file = "argon2_cffi_bindings-21.2.0-cp36-abi3-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:58ed19212051f49a523abb1dbe954337dc82d947fb6e5a0da60f7c8471a8476c"},
{file = "argon2_cffi_bindings-21.2.0-cp36-abi3-musllinux_1_1_aarch64.whl", hash = "sha256:bd46088725ef7f58b5a1ef7ca06647ebaf0eb4baff7d1d0d177c6cc8744abd86"},
{file = "argon2_cffi_bindings-21.2.0-cp36-abi3-musllinux_1_1_i686.whl", hash = "sha256:8cd69c07dd875537a824deec19f978e0f2078fdda07fd5c42ac29668dda5f40f"},
{file = "argon2_cffi_bindings-21.2.0-cp36-abi3-musllinux_1_1_x86_64.whl", hash = "sha256:f1152ac548bd5b8bcecfb0b0371f082037e47128653df2e8ba6e914d384f3c3e"},
{file = "argon2_cffi_bindings-21.2.0-cp36-abi3-win32.whl", hash = "sha256:603ca0aba86b1349b147cab91ae970c63118a0f30444d4bc80355937c950c082"},
{file = "argon2_cffi_bindings-21.2.0-cp36-abi3-win_amd64.whl", hash = "sha256:b2ef1c30440dbbcba7a5dc3e319408b59676e2e039e2ae11a8775ecf482b192f"},
{file = "argon2_cffi_bindings-21.2.0-cp38-abi3-macosx_10_9_universal2.whl", hash = "sha256:e415e3f62c8d124ee16018e491a009937f8cf7ebf5eb430ffc5de21b900dad93"},
{file = "argon2_cffi_bindings-21.2.0-pp37-pypy37_pp73-macosx_10_9_x86_64.whl", hash = "sha256:3e385d1c39c520c08b53d63300c3ecc28622f076f4c2b0e6d7e796e9f6502194"},
{file = "argon2_cffi_bindings-21.2.0-pp37-pypy37_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:2c3e3cc67fdb7d82c4718f19b4e7a87123caf8a93fde7e23cf66ac0337d3cb3f"},
{file = "argon2_cffi_bindings-21.2.0-pp37-pypy37_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6a22ad9800121b71099d0fb0a65323810a15f2e292f2ba450810a7316e128ee5"},
{file = "argon2_cffi_bindings-21.2.0-pp37-pypy37_pp73-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:f9f8b450ed0547e3d473fdc8612083fd08dd2120d6ac8f73828df9b7d45bb351"},
{file = "argon2_cffi_bindings-21.2.0-pp37-pypy37_pp73-win_amd64.whl", hash = "sha256:93f9bf70084f97245ba10ee36575f0c3f1e7d7724d67d8e5b08e61787c320ed7"},
{file = "argon2_cffi_bindings-21.2.0-pp38-pypy38_pp73-macosx_10_9_x86_64.whl", hash = "sha256:3b9ef65804859d335dc6b31582cad2c5166f0c3e7975f324d9ffaa34ee7e6583"},
{file = "argon2_cffi_bindings-21.2.0-pp38-pypy38_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d4966ef5848d820776f5f562a7d45fdd70c2f330c961d0d745b784034bd9f48d"},
{file = "argon2_cffi_bindings-21.2.0-pp38-pypy38_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:20ef543a89dee4db46a1a6e206cd015360e5a75822f76df533845c3cbaf72670"},
{file = "argon2_cffi_bindings-21.2.0-pp38-pypy38_pp73-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:ed2937d286e2ad0cc79a7087d3c272832865f779430e0cc2b4f3718d3159b0cb"},
{file = "argon2_cffi_bindings-21.2.0-pp38-pypy38_pp73-win_amd64.whl", hash = "sha256:5e00316dabdaea0b2dd82d141cc66889ced0cdcbfa599e8b471cf22c620c329a"},
]
[package.dependencies]
cffi = ">=1.0.1"
[package.extras]
dev = ["cogapp", "pre-commit", "pytest", "wheel"]
tests = ["pytest"]
[[package]]
name = "asgi-correlation-id"
version = "4.3.4"
@@ -140,6 +197,85 @@ files = [
{file = "certifi-2024.8.30.tar.gz", hash = "sha256:bec941d2aa8195e248a60b31ff9f0558284cf01a52591ceda73ea9afffd69fd9"},
]
[[package]]
name = "cffi"
version = "1.17.1"
description = "Foreign Function Interface for Python calling C code."
optional = false
python-versions = ">=3.8"
files = [
{file = "cffi-1.17.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:df8b1c11f177bc2313ec4b2d46baec87a5f3e71fc8b45dab2ee7cae86d9aba14"},
{file = "cffi-1.17.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:8f2cdc858323644ab277e9bb925ad72ae0e67f69e804f4898c070998d50b1a67"},
{file = "cffi-1.17.1-cp310-cp310-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:edae79245293e15384b51f88b00613ba9f7198016a5948b5dddf4917d4d26382"},
{file = "cffi-1.17.1-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:45398b671ac6d70e67da8e4224a065cec6a93541bb7aebe1b198a61b58c7b702"},
{file = "cffi-1.17.1-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:ad9413ccdeda48c5afdae7e4fa2192157e991ff761e7ab8fdd8926f40b160cc3"},
{file = "cffi-1.17.1-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:5da5719280082ac6bd9aa7becb3938dc9f9cbd57fac7d2871717b1feb0902ab6"},
{file = "cffi-1.17.1-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:2bb1a08b8008b281856e5971307cc386a8e9c5b625ac297e853d36da6efe9c17"},
{file = "cffi-1.17.1-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:045d61c734659cc045141be4bae381a41d89b741f795af1dd018bfb532fd0df8"},
{file = "cffi-1.17.1-cp310-cp310-musllinux_1_1_i686.whl", hash = "sha256:6883e737d7d9e4899a8a695e00ec36bd4e5e4f18fabe0aca0efe0a4b44cdb13e"},
{file = "cffi-1.17.1-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:6b8b4a92e1c65048ff98cfe1f735ef8f1ceb72e3d5f0c25fdb12087a23da22be"},
{file = "cffi-1.17.1-cp310-cp310-win32.whl", hash = "sha256:c9c3d058ebabb74db66e431095118094d06abf53284d9c81f27300d0e0d8bc7c"},
{file = "cffi-1.17.1-cp310-cp310-win_amd64.whl", hash = "sha256:0f048dcf80db46f0098ccac01132761580d28e28bc0f78ae0d58048063317e15"},
{file = "cffi-1.17.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:a45e3c6913c5b87b3ff120dcdc03f6131fa0065027d0ed7ee6190736a74cd401"},
{file = "cffi-1.17.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:30c5e0cb5ae493c04c8b42916e52ca38079f1b235c2f8ae5f4527b963c401caf"},
{file = "cffi-1.17.1-cp311-cp311-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:f75c7ab1f9e4aca5414ed4d8e5c0e303a34f4421f8a0d47a4d019ceff0ab6af4"},
{file = "cffi-1.17.1-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a1ed2dd2972641495a3ec98445e09766f077aee98a1c896dcb4ad0d303628e41"},
{file = "cffi-1.17.1-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:46bf43160c1a35f7ec506d254e5c890f3c03648a4dbac12d624e4490a7046cd1"},
{file = "cffi-1.17.1-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:a24ed04c8ffd54b0729c07cee15a81d964e6fee0e3d4d342a27b020d22959dc6"},
{file = "cffi-1.17.1-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:610faea79c43e44c71e1ec53a554553fa22321b65fae24889706c0a84d4ad86d"},
{file = "cffi-1.17.1-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:a9b15d491f3ad5d692e11f6b71f7857e7835eb677955c00cc0aefcd0669adaf6"},
{file = "cffi-1.17.1-cp311-cp311-musllinux_1_1_i686.whl", hash = "sha256:de2ea4b5833625383e464549fec1bc395c1bdeeb5f25c4a3a82b5a8c756ec22f"},
{file = "cffi-1.17.1-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:fc48c783f9c87e60831201f2cce7f3b2e4846bf4d8728eabe54d60700b318a0b"},
{file = "cffi-1.17.1-cp311-cp311-win32.whl", hash = "sha256:85a950a4ac9c359340d5963966e3e0a94a676bd6245a4b55bc43949eee26a655"},
{file = "cffi-1.17.1-cp311-cp311-win_amd64.whl", hash = "sha256:caaf0640ef5f5517f49bc275eca1406b0ffa6aa184892812030f04c2abf589a0"},
{file = "cffi-1.17.1-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:805b4371bf7197c329fcb3ead37e710d1bca9da5d583f5073b799d5c5bd1eee4"},
{file = "cffi-1.17.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:733e99bc2df47476e3848417c5a4540522f234dfd4ef3ab7fafdf555b082ec0c"},
{file = "cffi-1.17.1-cp312-cp312-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:1257bdabf294dceb59f5e70c64a3e2f462c30c7ad68092d01bbbfb1c16b1ba36"},
{file = "cffi-1.17.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:da95af8214998d77a98cc14e3a3bd00aa191526343078b530ceb0bd710fb48a5"},
{file = "cffi-1.17.1-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:d63afe322132c194cf832bfec0dc69a99fb9bb6bbd550f161a49e9e855cc78ff"},
{file = "cffi-1.17.1-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:f79fc4fc25f1c8698ff97788206bb3c2598949bfe0fef03d299eb1b5356ada99"},
{file = "cffi-1.17.1-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b62ce867176a75d03a665bad002af8e6d54644fad99a3c70905c543130e39d93"},
{file = "cffi-1.17.1-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:386c8bf53c502fff58903061338ce4f4950cbdcb23e2902d86c0f722b786bbe3"},
{file = "cffi-1.17.1-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:4ceb10419a9adf4460ea14cfd6bc43d08701f0835e979bf821052f1805850fe8"},
{file = "cffi-1.17.1-cp312-cp312-win32.whl", hash = "sha256:a08d7e755f8ed21095a310a693525137cfe756ce62d066e53f502a83dc550f65"},
{file = "cffi-1.17.1-cp312-cp312-win_amd64.whl", hash = "sha256:51392eae71afec0d0c8fb1a53b204dbb3bcabcb3c9b807eedf3e1e6ccf2de903"},
{file = "cffi-1.17.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:f3a2b4222ce6b60e2e8b337bb9596923045681d71e5a082783484d845390938e"},
{file = "cffi-1.17.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:0984a4925a435b1da406122d4d7968dd861c1385afe3b45ba82b750f229811e2"},
{file = "cffi-1.17.1-cp313-cp313-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:d01b12eeeb4427d3110de311e1774046ad344f5b1a7403101878976ecd7a10f3"},
{file = "cffi-1.17.1-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:706510fe141c86a69c8ddc029c7910003a17353970cff3b904ff0686a5927683"},
{file = "cffi-1.17.1-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:de55b766c7aa2e2a3092c51e0483d700341182f08e67c63630d5b6f200bb28e5"},
{file = "cffi-1.17.1-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:c59d6e989d07460165cc5ad3c61f9fd8f1b4796eacbd81cee78957842b834af4"},
{file = "cffi-1.17.1-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:dd398dbc6773384a17fe0d3e7eeb8d1a21c2200473ee6806bb5e6a8e62bb73dd"},
{file = "cffi-1.17.1-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:3edc8d958eb099c634dace3c7e16560ae474aa3803a5df240542b305d14e14ed"},
{file = "cffi-1.17.1-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:72e72408cad3d5419375fc87d289076ee319835bdfa2caad331e377589aebba9"},
{file = "cffi-1.17.1-cp313-cp313-win32.whl", hash = "sha256:e03eab0a8677fa80d646b5ddece1cbeaf556c313dcfac435ba11f107ba117b5d"},
{file = "cffi-1.17.1-cp313-cp313-win_amd64.whl", hash = "sha256:f6a16c31041f09ead72d69f583767292f750d24913dadacf5756b966aacb3f1a"},
{file = "cffi-1.17.1-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:636062ea65bd0195bc012fea9321aca499c0504409f413dc88af450b57ffd03b"},
{file = "cffi-1.17.1-cp38-cp38-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:c7eac2ef9b63c79431bc4b25f1cd649d7f061a28808cbc6c47b534bd789ef964"},
{file = "cffi-1.17.1-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e221cf152cff04059d011ee126477f0d9588303eb57e88923578ace7baad17f9"},
{file = "cffi-1.17.1-cp38-cp38-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:31000ec67d4221a71bd3f67df918b1f88f676f1c3b535a7eb473255fdc0b83fc"},
{file = "cffi-1.17.1-cp38-cp38-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:6f17be4345073b0a7b8ea599688f692ac3ef23ce28e5df79c04de519dbc4912c"},
{file = "cffi-1.17.1-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0e2b1fac190ae3ebfe37b979cc1ce69c81f4e4fe5746bb401dca63a9062cdaf1"},
{file = "cffi-1.17.1-cp38-cp38-win32.whl", hash = "sha256:7596d6620d3fa590f677e9ee430df2958d2d6d6de2feeae5b20e82c00b76fbf8"},
{file = "cffi-1.17.1-cp38-cp38-win_amd64.whl", hash = "sha256:78122be759c3f8a014ce010908ae03364d00a1f81ab5c7f4a7a5120607ea56e1"},
{file = "cffi-1.17.1-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:b2ab587605f4ba0bf81dc0cb08a41bd1c0a5906bd59243d56bad7668a6fc6c16"},
{file = "cffi-1.17.1-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:28b16024becceed8c6dfbc75629e27788d8a3f9030691a1dbf9821a128b22c36"},
{file = "cffi-1.17.1-cp39-cp39-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:1d599671f396c4723d016dbddb72fe8e0397082b0a77a4fab8028923bec050e8"},
{file = "cffi-1.17.1-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ca74b8dbe6e8e8263c0ffd60277de77dcee6c837a3d0881d8c1ead7268c9e576"},
{file = "cffi-1.17.1-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:f7f5baafcc48261359e14bcd6d9bff6d4b28d9103847c9e136694cb0501aef87"},
{file = "cffi-1.17.1-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:98e3969bcff97cae1b2def8ba499ea3d6f31ddfdb7635374834cf89a1a08ecf0"},
{file = "cffi-1.17.1-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:cdf5ce3acdfd1661132f2a9c19cac174758dc2352bfe37d98aa7512c6b7178b3"},
{file = "cffi-1.17.1-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:9755e4345d1ec879e3849e62222a18c7174d65a6a92d5b346b1863912168b595"},
{file = "cffi-1.17.1-cp39-cp39-musllinux_1_1_i686.whl", hash = "sha256:f1e22e8c4419538cb197e4dd60acc919d7696e5ef98ee4da4e01d3f8cfa4cc5a"},
{file = "cffi-1.17.1-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:c03e868a0b3bc35839ba98e74211ed2b05d2119be4e8a0f224fba9384f1fe02e"},
{file = "cffi-1.17.1-cp39-cp39-win32.whl", hash = "sha256:e31ae45bc2e29f6b2abd0de1cc3b9d5205aa847cafaecb8af1476a609a2f6eb7"},
{file = "cffi-1.17.1-cp39-cp39-win_amd64.whl", hash = "sha256:d016c76bdd850f3c626af19b0542c9677ba156e4ee4fccfdd7848803533ef662"},
{file = "cffi-1.17.1.tar.gz", hash = "sha256:1c39c6016c32bc48dd54561950ebd6836e1670f2ae46128f67cf49e789c52824"},
]
[package.dependencies]
pycparser = "*"
[[package]]
name = "charset-normalizer"
version = "3.4.0"
@@ -279,6 +415,80 @@ files = [
{file = "colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44"},
]
[[package]]
name = "coverage"
version = "7.6.4"
description = "Code coverage measurement for Python"
optional = false
python-versions = ">=3.9"
files = [
{file = "coverage-7.6.4-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:5f8ae553cba74085db385d489c7a792ad66f7f9ba2ee85bfa508aeb84cf0ba07"},
{file = "coverage-7.6.4-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:8165b796df0bd42e10527a3f493c592ba494f16ef3c8b531288e3d0d72c1f6f0"},
{file = "coverage-7.6.4-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c7c8b95bf47db6d19096a5e052ffca0a05f335bc63cef281a6e8fe864d450a72"},
{file = "coverage-7.6.4-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:8ed9281d1b52628e81393f5eaee24a45cbd64965f41857559c2b7ff19385df51"},
{file = "coverage-7.6.4-cp310-cp310-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0809082ee480bb8f7416507538243c8863ac74fd8a5d2485c46f0f7499f2b491"},
{file = "coverage-7.6.4-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:d541423cdd416b78626b55f123412fcf979d22a2c39fce251b350de38c15c15b"},
{file = "coverage-7.6.4-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:58809e238a8a12a625c70450b48e8767cff9eb67c62e6154a642b21ddf79baea"},
{file = "coverage-7.6.4-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:c9b8e184898ed014884ca84c70562b4a82cbc63b044d366fedc68bc2b2f3394a"},
{file = "coverage-7.6.4-cp310-cp310-win32.whl", hash = "sha256:6bd818b7ea14bc6e1f06e241e8234508b21edf1b242d49831831a9450e2f35fa"},
{file = "coverage-7.6.4-cp310-cp310-win_amd64.whl", hash = "sha256:06babbb8f4e74b063dbaeb74ad68dfce9186c595a15f11f5d5683f748fa1d172"},
{file = "coverage-7.6.4-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:73d2b73584446e66ee633eaad1a56aad577c077f46c35ca3283cd687b7715b0b"},
{file = "coverage-7.6.4-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:51b44306032045b383a7a8a2c13878de375117946d68dcb54308111f39775a25"},
{file = "coverage-7.6.4-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0b3fb02fe73bed561fa12d279a417b432e5b50fe03e8d663d61b3d5990f29546"},
{file = "coverage-7.6.4-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:ed8fe9189d2beb6edc14d3ad19800626e1d9f2d975e436f84e19efb7fa19469b"},
{file = "coverage-7.6.4-cp311-cp311-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b369ead6527d025a0fe7bd3864e46dbee3aa8f652d48df6174f8d0bac9e26e0e"},
{file = "coverage-7.6.4-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:ade3ca1e5f0ff46b678b66201f7ff477e8fa11fb537f3b55c3f0568fbfe6e718"},
{file = "coverage-7.6.4-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:27fb4a050aaf18772db513091c9c13f6cb94ed40eacdef8dad8411d92d9992db"},
{file = "coverage-7.6.4-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:4f704f0998911abf728a7783799444fcbbe8261c4a6c166f667937ae6a8aa522"},
{file = "coverage-7.6.4-cp311-cp311-win32.whl", hash = "sha256:29155cd511ee058e260db648b6182c419422a0d2e9a4fa44501898cf918866cf"},
{file = "coverage-7.6.4-cp311-cp311-win_amd64.whl", hash = "sha256:8902dd6a30173d4ef09954bfcb24b5d7b5190cf14a43170e386979651e09ba19"},
{file = "coverage-7.6.4-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:12394842a3a8affa3ba62b0d4ab7e9e210c5e366fbac3e8b2a68636fb19892c2"},
{file = "coverage-7.6.4-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:2b6b4c83d8e8ea79f27ab80778c19bc037759aea298da4b56621f4474ffeb117"},
{file = "coverage-7.6.4-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:1d5b8007f81b88696d06f7df0cb9af0d3b835fe0c8dbf489bad70b45f0e45613"},
{file = "coverage-7.6.4-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:b57b768feb866f44eeed9f46975f3d6406380275c5ddfe22f531a2bf187eda27"},
{file = "coverage-7.6.4-cp312-cp312-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:5915fcdec0e54ee229926868e9b08586376cae1f5faa9bbaf8faf3561b393d52"},
{file = "coverage-7.6.4-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:0b58c672d14f16ed92a48db984612f5ce3836ae7d72cdd161001cc54512571f2"},
{file = "coverage-7.6.4-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:2fdef0d83a2d08d69b1f2210a93c416d54e14d9eb398f6ab2f0a209433db19e1"},
{file = "coverage-7.6.4-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:8cf717ee42012be8c0cb205dbbf18ffa9003c4cbf4ad078db47b95e10748eec5"},
{file = "coverage-7.6.4-cp312-cp312-win32.whl", hash = "sha256:7bb92c539a624cf86296dd0c68cd5cc286c9eef2d0c3b8b192b604ce9de20a17"},
{file = "coverage-7.6.4-cp312-cp312-win_amd64.whl", hash = "sha256:1032e178b76a4e2b5b32e19d0fd0abbce4b58e77a1ca695820d10e491fa32b08"},
{file = "coverage-7.6.4-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:023bf8ee3ec6d35af9c1c6ccc1d18fa69afa1cb29eaac57cb064dbb262a517f9"},
{file = "coverage-7.6.4-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:b0ac3d42cb51c4b12df9c5f0dd2f13a4f24f01943627120ec4d293c9181219ba"},
{file = "coverage-7.6.4-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f8fe4984b431f8621ca53d9380901f62bfb54ff759a1348cd140490ada7b693c"},
{file = "coverage-7.6.4-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:5fbd612f8a091954a0c8dd4c0b571b973487277d26476f8480bfa4b2a65b5d06"},
{file = "coverage-7.6.4-cp313-cp313-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:dacbc52de979f2823a819571f2e3a350a7e36b8cb7484cdb1e289bceaf35305f"},
{file = "coverage-7.6.4-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:dab4d16dfef34b185032580e2f2f89253d302facba093d5fa9dbe04f569c4f4b"},
{file = "coverage-7.6.4-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:862264b12ebb65ad8d863d51f17758b1684560b66ab02770d4f0baf2ff75da21"},
{file = "coverage-7.6.4-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:5beb1ee382ad32afe424097de57134175fea3faf847b9af002cc7895be4e2a5a"},
{file = "coverage-7.6.4-cp313-cp313-win32.whl", hash = "sha256:bf20494da9653f6410213424f5f8ad0ed885e01f7e8e59811f572bdb20b8972e"},
{file = "coverage-7.6.4-cp313-cp313-win_amd64.whl", hash = "sha256:182e6cd5c040cec0a1c8d415a87b67ed01193ed9ad458ee427741c7d8513d963"},
{file = "coverage-7.6.4-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:a181e99301a0ae128493a24cfe5cfb5b488c4e0bf2f8702091473d033494d04f"},
{file = "coverage-7.6.4-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:df57bdbeffe694e7842092c5e2e0bc80fff7f43379d465f932ef36f027179806"},
{file = "coverage-7.6.4-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0bcd1069e710600e8e4cf27f65c90c7843fa8edfb4520fb0ccb88894cad08b11"},
{file = "coverage-7.6.4-cp313-cp313t-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:99b41d18e6b2a48ba949418db48159d7a2e81c5cc290fc934b7d2380515bd0e3"},
{file = "coverage-7.6.4-cp313-cp313t-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a6b1e54712ba3474f34b7ef7a41e65bd9037ad47916ccb1cc78769bae324c01a"},
{file = "coverage-7.6.4-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:53d202fd109416ce011578f321460795abfe10bb901b883cafd9b3ef851bacfc"},
{file = "coverage-7.6.4-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:c48167910a8f644671de9f2083a23630fbf7a1cb70ce939440cd3328e0919f70"},
{file = "coverage-7.6.4-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:cc8ff50b50ce532de2fa7a7daae9dd12f0a699bfcd47f20945364e5c31799fef"},
{file = "coverage-7.6.4-cp313-cp313t-win32.whl", hash = "sha256:b8d3a03d9bfcaf5b0141d07a88456bb6a4c3ce55c080712fec8418ef3610230e"},
{file = "coverage-7.6.4-cp313-cp313t-win_amd64.whl", hash = "sha256:f3ddf056d3ebcf6ce47bdaf56142af51bb7fad09e4af310241e9db7a3a8022e1"},
{file = "coverage-7.6.4-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:9cb7fa111d21a6b55cbf633039f7bc2749e74932e3aa7cb7333f675a58a58bf3"},
{file = "coverage-7.6.4-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:11a223a14e91a4693d2d0755c7a043db43d96a7450b4f356d506c2562c48642c"},
{file = "coverage-7.6.4-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a413a096c4cbac202433c850ee43fa326d2e871b24554da8327b01632673a076"},
{file = "coverage-7.6.4-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:00a1d69c112ff5149cabe60d2e2ee948752c975d95f1e1096742e6077affd376"},
{file = "coverage-7.6.4-cp39-cp39-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:1f76846299ba5c54d12c91d776d9605ae33f8ae2b9d1d3c3703cf2db1a67f2c0"},
{file = "coverage-7.6.4-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:fe439416eb6380de434886b00c859304338f8b19f6f54811984f3420a2e03858"},
{file = "coverage-7.6.4-cp39-cp39-musllinux_1_2_i686.whl", hash = "sha256:0294ca37f1ba500667b1aef631e48d875ced93ad5e06fa665a3295bdd1d95111"},
{file = "coverage-7.6.4-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:6f01ba56b1c0e9d149f9ac85a2f999724895229eb36bd997b61e62999e9b0901"},
{file = "coverage-7.6.4-cp39-cp39-win32.whl", hash = "sha256:bc66f0bf1d7730a17430a50163bb264ba9ded56739112368ba985ddaa9c3bd09"},
{file = "coverage-7.6.4-cp39-cp39-win_amd64.whl", hash = "sha256:c481b47f6b5845064c65a7bc78bc0860e635a9b055af0df46fdf1c58cebf8e8f"},
{file = "coverage-7.6.4-pp39.pp310-none-any.whl", hash = "sha256:3c65d37f3a9ebb703e710befdc489a38683a5b152242664b973a7b7b22348a4e"},
{file = "coverage-7.6.4.tar.gz", hash = "sha256:29fc0f17b1d3fea332f8001d4558f8214af7f1d87a345f3a133c901d60347c73"},
]
[package.extras]
toml = ["tomli"]
[[package]]
name = "curtsies"
version = "0.4.2"
@@ -339,6 +549,72 @@ files = [
{file = "cwcwidth-0.1.9.tar.gz", hash = "sha256:f19d11a0148d4a8cacd064c96e93bca8ce3415a186ae8204038f45e108db76b8"},
]
[[package]]
name = "deprecated"
version = "1.2.14"
description = "Python @deprecated decorator to deprecate old python classes, functions or methods."
optional = false
python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*"
files = [
{file = "Deprecated-1.2.14-py2.py3-none-any.whl", hash = "sha256:6fac8b097794a90302bdbb17b9b815e732d3c4720583ff1b198499d78470466c"},
{file = "Deprecated-1.2.14.tar.gz", hash = "sha256:e5323eb936458dccc2582dc6f9c322c852a775a27065ff2b0c4970b9d53d01b3"},
]
[package.dependencies]
wrapt = ">=1.10,<2"
[package.extras]
dev = ["PyTest", "PyTest-Cov", "bump2version (<1)", "sphinx (<2)", "tox"]
[[package]]
name = "dnspython"
version = "2.7.0"
description = "DNS toolkit"
optional = false
python-versions = ">=3.9"
files = [
{file = "dnspython-2.7.0-py3-none-any.whl", hash = "sha256:b4c34b7d10b51bcc3a5071e7b8dee77939f1e878477eeecc965e9835f63c6c86"},
{file = "dnspython-2.7.0.tar.gz", hash = "sha256:ce9c432eda0dc91cf618a5cedf1a4e142651196bbcd2c80e89ed5a907e5cfaf1"},
]
[package.extras]
dev = ["black (>=23.1.0)", "coverage (>=7.0)", "flake8 (>=7)", "hypercorn (>=0.16.0)", "mypy (>=1.8)", "pylint (>=3)", "pytest (>=7.4)", "pytest-cov (>=4.1.0)", "quart-trio (>=0.11.0)", "sphinx (>=7.2.0)", "sphinx-rtd-theme (>=2.0.0)", "twine (>=4.0.0)", "wheel (>=0.42.0)"]
dnssec = ["cryptography (>=43)"]
doh = ["h2 (>=4.1.0)", "httpcore (>=1.0.0)", "httpx (>=0.26.0)"]
doq = ["aioquic (>=1.0.0)"]
idna = ["idna (>=3.7)"]
trio = ["trio (>=0.23)"]
wmi = ["wmi (>=1.5.1)"]
[[package]]
name = "email-validator"
version = "2.2.0"
description = "A robust email address syntax and deliverability validation library."
optional = false
python-versions = ">=3.8"
files = [
{file = "email_validator-2.2.0-py3-none-any.whl", hash = "sha256:561977c2d73ce3611850a06fa56b414621e0c8faa9d66f2611407d87465da631"},
{file = "email_validator-2.2.0.tar.gz", hash = "sha256:cb690f344c617a714f22e66ae771445a1ceb46821152df8e165c5f9a364582b7"},
]
[package.dependencies]
dnspython = ">=2.0.0"
idna = ">=2.0.0"
[[package]]
name = "execnet"
version = "2.1.1"
description = "execnet: rapid multi-Python deployment"
optional = false
python-versions = ">=3.8"
files = [
{file = "execnet-2.1.1-py3-none-any.whl", hash = "sha256:26dee51f1b80cebd6d0ca8e74dd8745419761d3bef34163928cbebbdc4749fdc"},
{file = "execnet-2.1.1.tar.gz", hash = "sha256:5189b52c6121c24feae288166ab41b32549c7e2348652736540b9e6e7d4e72e3"},
]
[package.extras]
testing = ["hatch", "pre-commit", "pytest", "tox"]
[[package]]
name = "fastapi"
version = "0.100.0"
@@ -504,6 +780,52 @@ files = [
{file = "hpack-4.0.0.tar.gz", hash = "sha256:fc41de0c63e687ebffde81187a948221294896f6bdc0ae2312708df339430095"},
]
[[package]]
name = "httpcore"
version = "1.0.6"
description = "A minimal low-level HTTP client."
optional = false
python-versions = ">=3.8"
files = [
{file = "httpcore-1.0.6-py3-none-any.whl", hash = "sha256:27b59625743b85577a8c0e10e55b50b5368a4f2cfe8cc7bcfa9cf00829c2682f"},
{file = "httpcore-1.0.6.tar.gz", hash = "sha256:73f6dbd6eb8c21bbf7ef8efad555481853f5f6acdeaff1edb0694289269ee17f"},
]
[package.dependencies]
certifi = "*"
h11 = ">=0.13,<0.15"
[package.extras]
asyncio = ["anyio (>=4.0,<5.0)"]
http2 = ["h2 (>=3,<5)"]
socks = ["socksio (==1.*)"]
trio = ["trio (>=0.22.0,<1.0)"]
[[package]]
name = "httpx"
version = "0.27.2"
description = "The next generation HTTP client."
optional = false
python-versions = ">=3.8"
files = [
{file = "httpx-0.27.2-py3-none-any.whl", hash = "sha256:7bb2708e112d8fdd7829cd4243970f0c223274051cb35ee80c03301ee29a3df0"},
{file = "httpx-0.27.2.tar.gz", hash = "sha256:f7c2be1d2f3c3c3160d441802406b206c2b76f5947b11115e6df10c6c65e66c2"},
]
[package.dependencies]
anyio = "*"
certifi = "*"
httpcore = "==1.*"
idna = "*"
sniffio = "*"
[package.extras]
brotli = ["brotli", "brotlicffi"]
cli = ["click (==8.*)", "pygments (==2.*)", "rich (>=10,<14)"]
http2 = ["h2 (>=3,<5)"]
socks = ["socksio (==1.*)"]
zstd = ["zstandard (>=0.18.0)"]
[[package]]
name = "human-readable"
version = "1.3.4"
@@ -563,6 +885,36 @@ files = [
[package.extras]
all = ["flake8 (>=7.1.1)", "mypy (>=1.11.2)", "pytest (>=8.3.2)", "ruff (>=0.6.2)"]
[[package]]
name = "importlib-resources"
version = "6.4.5"
description = "Read resources from Python packages"
optional = false
python-versions = ">=3.8"
files = [
{file = "importlib_resources-6.4.5-py3-none-any.whl", hash = "sha256:ac29d5f956f01d5e4bb63102a5a19957f1b9175e45649977264a1416783bb717"},
{file = "importlib_resources-6.4.5.tar.gz", hash = "sha256:980862a1d16c9e147a59603677fa2aa5fd82b87f223b6cb870695bcfce830065"},
]
[package.extras]
check = ["pytest-checkdocs (>=2.4)", "pytest-ruff (>=0.2.1)"]
cover = ["pytest-cov"]
doc = ["furo", "jaraco.packaging (>=9.3)", "jaraco.tidelift (>=1.4)", "rst.linker (>=1.9)", "sphinx (>=3.5)", "sphinx-lint"]
enabler = ["pytest-enabler (>=2.2)"]
test = ["jaraco.test (>=5.4)", "pytest (>=6,!=8.1.*)", "zipp (>=3.17)"]
type = ["pytest-mypy"]
[[package]]
name = "iniconfig"
version = "2.0.0"
description = "brain-dead simple config-ini parsing"
optional = false
python-versions = ">=3.7"
files = [
{file = "iniconfig-2.0.0-py3-none-any.whl", hash = "sha256:b6a85871a79d2e3b22d2d1b94ac2824226a63c6b741c88f7ae975f18b6778374"},
{file = "iniconfig-2.0.0.tar.gz", hash = "sha256:2d91e135bf72d31a410b17c16da610a82cb55f6b0477d1a902134b24a455b8b3"},
]
[[package]]
name = "jinxed"
version = "1.3.0"
@@ -577,6 +929,35 @@ files = [
[package.dependencies]
ansicon = {version = "*", markers = "platform_system == \"Windows\""}
[[package]]
name = "limits"
version = "3.13.0"
description = "Rate limiting utilities"
optional = false
python-versions = ">=3.8"
files = [
{file = "limits-3.13.0-py3-none-any.whl", hash = "sha256:9767f7233da4255e9904b79908a728e8ec0984c0b086058b4cbbd309aea553f6"},
{file = "limits-3.13.0.tar.gz", hash = "sha256:6571b0c567bfa175a35fed9f8a954c0c92f1c3200804282f1b8f1de4ad98a953"},
]
[package.dependencies]
deprecated = ">=1.2"
importlib-resources = ">=1.3"
packaging = ">=21,<25"
typing-extensions = "*"
[package.extras]
all = ["aetcd", "coredis (>=3.4.0,<5)", "emcache (>=0.6.1)", "emcache (>=1)", "etcd3", "motor (>=3,<4)", "pymemcache (>3,<5.0.0)", "pymongo (>4.1,<5)", "redis (>3,!=4.5.2,!=4.5.3,<6.0.0)", "redis (>=4.2.0,!=4.5.2,!=4.5.3)"]
async-etcd = ["aetcd"]
async-memcached = ["emcache (>=0.6.1)", "emcache (>=1)"]
async-mongodb = ["motor (>=3,<4)"]
async-redis = ["coredis (>=3.4.0,<5)"]
etcd = ["etcd3"]
memcached = ["pymemcache (>3,<5.0.0)"]
mongodb = ["pymongo (>4.1,<5)"]
redis = ["redis (>3,!=4.5.2,!=4.5.3,<6.0.0)"]
rediscluster = ["redis (>=4.2.0,!=4.5.2,!=4.5.3)"]
[[package]]
name = "memory-profiler"
version = "0.61.0"
@@ -793,6 +1174,21 @@ tzdata = ">=2020.1"
[package.extras]
test = ["time-machine (>=2.6.0)"]
[[package]]
name = "pluggy"
version = "1.5.0"
description = "plugin and hook calling mechanisms for python"
optional = false
python-versions = ">=3.8"
files = [
{file = "pluggy-1.5.0-py3-none-any.whl", hash = "sha256:44e1ad92c8ca002de6377e165f3e0f1be63266ab4d554740532335b9d75ea669"},
{file = "pluggy-1.5.0.tar.gz", hash = "sha256:2cffa88e94fdc978c4c574f15f9e59b7f4201d439195c3715ca9e2486f1d0cf1"},
]
[package.extras]
dev = ["pre-commit", "tox"]
testing = ["pytest", "pytest-benchmark"]
[[package]]
name = "priority"
version = "2.0.0"
@@ -864,6 +1260,35 @@ files = [
{file = "psycopg2-2.9.10.tar.gz", hash = "sha256:12ec0b40b0273f95296233e8750441339298e6a572f7039da5b260e3c8b60e11"},
]
[[package]]
name = "pwdlib"
version = "0.2.1"
description = "Modern password hashing for Python"
optional = false
python-versions = ">=3.8"
files = [
{file = "pwdlib-0.2.1-py3-none-any.whl", hash = "sha256:1823dc6f22eae472b540e889ecf57fd424051d6a4023ec0bcf7f0de2d9d7ef8c"},
{file = "pwdlib-0.2.1.tar.gz", hash = "sha256:9a1d8a8fa09a2f7ebf208265e55d7d008103cbdc82b9e4902ffdd1ade91add5e"},
]
[package.dependencies]
argon2-cffi = {version = ">=23.1.0,<24", optional = true, markers = "extra == \"argon2\""}
[package.extras]
argon2 = ["argon2-cffi (>=23.1.0,<24)"]
bcrypt = ["bcrypt (>=4.1.2,<5)"]
[[package]]
name = "pycparser"
version = "2.22"
description = "C parser in Python"
optional = false
python-versions = ">=3.8"
files = [
{file = "pycparser-2.22-py3-none-any.whl", hash = "sha256:c3702b6d3dd8c7abc1afa565d7e63d53a1d0bd86cdc24edd75470f4de499cfcc"},
{file = "pycparser-2.22.tar.gz", hash = "sha256:491c8be9c040f5390f5bf44a5b07752bd07f56edf992381b05c701439eec10f6"},
]
[[package]]
name = "pydantic"
version = "2.9.2"
@@ -1002,6 +1427,64 @@ files = [
[package.extras]
windows-terminal = ["colorama (>=0.4.6)"]
[[package]]
name = "pytest"
version = "8.3.3"
description = "pytest: simple powerful testing with Python"
optional = false
python-versions = ">=3.8"
files = [
{file = "pytest-8.3.3-py3-none-any.whl", hash = "sha256:a6853c7375b2663155079443d2e45de913a911a11d669df02a50814944db57b2"},
{file = "pytest-8.3.3.tar.gz", hash = "sha256:70b98107bd648308a7952b06e6ca9a50bc660be218d53c257cc1fc94fda10181"},
]
[package.dependencies]
colorama = {version = "*", markers = "sys_platform == \"win32\""}
iniconfig = "*"
packaging = "*"
pluggy = ">=1.5,<2"
[package.extras]
dev = ["argcomplete", "attrs (>=19.2)", "hypothesis (>=3.56)", "mock", "pygments (>=2.7.2)", "requests", "setuptools", "xmlschema"]
[[package]]
name = "pytest-cov"
version = "6.0.0"
description = "Pytest plugin for measuring coverage."
optional = false
python-versions = ">=3.9"
files = [
{file = "pytest-cov-6.0.0.tar.gz", hash = "sha256:fde0b595ca248bb8e2d76f020b465f3b107c9632e6a1d1705f17834c89dcadc0"},
{file = "pytest_cov-6.0.0-py3-none-any.whl", hash = "sha256:eee6f1b9e61008bd34975a4d5bab25801eb31898b032dd55addc93e96fcaaa35"},
]
[package.dependencies]
coverage = {version = ">=7.5", extras = ["toml"]}
pytest = ">=4.6"
[package.extras]
testing = ["fields", "hunter", "process-tests", "pytest-xdist", "virtualenv"]
[[package]]
name = "pytest-xdist"
version = "3.6.1"
description = "pytest xdist plugin for distributed testing, most importantly across multiple CPUs"
optional = false
python-versions = ">=3.8"
files = [
{file = "pytest_xdist-3.6.1-py3-none-any.whl", hash = "sha256:9ed4adfb68a016610848639bb7e02c9352d5d9f03d04809919e2dafc3be4cca7"},
{file = "pytest_xdist-3.6.1.tar.gz", hash = "sha256:ead156a4db231eec769737f57668ef58a2084a34b2e55c4a8fa20d861107300d"},
]
[package.dependencies]
execnet = ">=2.1"
pytest = ">=7.0.0"
[package.extras]
psutil = ["psutil (>=3.0)"]
setproctitle = ["setproctitle"]
testing = ["filelock"]
[[package]]
name = "python-dateutil"
version = "2.9.0.post0"
@@ -1261,6 +1744,85 @@ files = [
{file = "wcwidth-0.2.13.tar.gz", hash = "sha256:72ea0c06399eb286d978fdedb6923a9eb47e1c486ce63e9b4e64fc18303972b5"},
]
[[package]]
name = "wrapt"
version = "1.16.0"
description = "Module for decorators, wrappers and monkey patching."
optional = false
python-versions = ">=3.6"
files = [
{file = "wrapt-1.16.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:ffa565331890b90056c01db69c0fe634a776f8019c143a5ae265f9c6bc4bd6d4"},
{file = "wrapt-1.16.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:e4fdb9275308292e880dcbeb12546df7f3e0f96c6b41197e0cf37d2826359020"},
{file = "wrapt-1.16.0-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:bb2dee3874a500de01c93d5c71415fcaef1d858370d405824783e7a8ef5db440"},
{file = "wrapt-1.16.0-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:2a88e6010048489cda82b1326889ec075a8c856c2e6a256072b28eaee3ccf487"},
{file = "wrapt-1.16.0-cp310-cp310-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ac83a914ebaf589b69f7d0a1277602ff494e21f4c2f743313414378f8f50a4cf"},
{file = "wrapt-1.16.0-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:73aa7d98215d39b8455f103de64391cb79dfcad601701a3aa0dddacf74911d72"},
{file = "wrapt-1.16.0-cp310-cp310-musllinux_1_1_i686.whl", hash = "sha256:807cc8543a477ab7422f1120a217054f958a66ef7314f76dd9e77d3f02cdccd0"},
{file = "wrapt-1.16.0-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:bf5703fdeb350e36885f2875d853ce13172ae281c56e509f4e6eca049bdfb136"},
{file = "wrapt-1.16.0-cp310-cp310-win32.whl", hash = "sha256:f6b2d0c6703c988d334f297aa5df18c45e97b0af3679bb75059e0e0bd8b1069d"},
{file = "wrapt-1.16.0-cp310-cp310-win_amd64.whl", hash = "sha256:decbfa2f618fa8ed81c95ee18a387ff973143c656ef800c9f24fb7e9c16054e2"},
{file = "wrapt-1.16.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:1a5db485fe2de4403f13fafdc231b0dbae5eca4359232d2efc79025527375b09"},
{file = "wrapt-1.16.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:75ea7d0ee2a15733684badb16de6794894ed9c55aa5e9903260922f0482e687d"},
{file = "wrapt-1.16.0-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a452f9ca3e3267cd4d0fcf2edd0d035b1934ac2bd7e0e57ac91ad6b95c0c6389"},
{file = "wrapt-1.16.0-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:43aa59eadec7890d9958748db829df269f0368521ba6dc68cc172d5d03ed8060"},
{file = "wrapt-1.16.0-cp311-cp311-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:72554a23c78a8e7aa02abbd699d129eead8b147a23c56e08d08dfc29cfdddca1"},
{file = "wrapt-1.16.0-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:d2efee35b4b0a347e0d99d28e884dfd82797852d62fcd7ebdeee26f3ceb72cf3"},
{file = "wrapt-1.16.0-cp311-cp311-musllinux_1_1_i686.whl", hash = "sha256:6dcfcffe73710be01d90cae08c3e548d90932d37b39ef83969ae135d36ef3956"},
{file = "wrapt-1.16.0-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:eb6e651000a19c96f452c85132811d25e9264d836951022d6e81df2fff38337d"},
{file = "wrapt-1.16.0-cp311-cp311-win32.whl", hash = "sha256:66027d667efe95cc4fa945af59f92c5a02c6f5bb6012bff9e60542c74c75c362"},
{file = "wrapt-1.16.0-cp311-cp311-win_amd64.whl", hash = "sha256:aefbc4cb0a54f91af643660a0a150ce2c090d3652cf4052a5397fb2de549cd89"},
{file = "wrapt-1.16.0-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:5eb404d89131ec9b4f748fa5cfb5346802e5ee8836f57d516576e61f304f3b7b"},
{file = "wrapt-1.16.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:9090c9e676d5236a6948330e83cb89969f433b1943a558968f659ead07cb3b36"},
{file = "wrapt-1.16.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:94265b00870aa407bd0cbcfd536f17ecde43b94fb8d228560a1e9d3041462d73"},
{file = "wrapt-1.16.0-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:f2058f813d4f2b5e3a9eb2eb3faf8f1d99b81c3e51aeda4b168406443e8ba809"},
{file = "wrapt-1.16.0-cp312-cp312-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:98b5e1f498a8ca1858a1cdbffb023bfd954da4e3fa2c0cb5853d40014557248b"},
{file = "wrapt-1.16.0-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:14d7dc606219cdd7405133c713f2c218d4252f2a469003f8c46bb92d5d095d81"},
{file = "wrapt-1.16.0-cp312-cp312-musllinux_1_1_i686.whl", hash = "sha256:49aac49dc4782cb04f58986e81ea0b4768e4ff197b57324dcbd7699c5dfb40b9"},
{file = "wrapt-1.16.0-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:418abb18146475c310d7a6dc71143d6f7adec5b004ac9ce08dc7a34e2babdc5c"},
{file = "wrapt-1.16.0-cp312-cp312-win32.whl", hash = "sha256:685f568fa5e627e93f3b52fda002c7ed2fa1800b50ce51f6ed1d572d8ab3e7fc"},
{file = "wrapt-1.16.0-cp312-cp312-win_amd64.whl", hash = "sha256:dcdba5c86e368442528f7060039eda390cc4091bfd1dca41e8046af7c910dda8"},
{file = "wrapt-1.16.0-cp36-cp36m-macosx_10_9_x86_64.whl", hash = "sha256:d462f28826f4657968ae51d2181a074dfe03c200d6131690b7d65d55b0f360f8"},
{file = "wrapt-1.16.0-cp36-cp36m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a33a747400b94b6d6b8a165e4480264a64a78c8a4c734b62136062e9a248dd39"},
{file = "wrapt-1.16.0-cp36-cp36m-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:b3646eefa23daeba62643a58aac816945cadc0afaf21800a1421eeba5f6cfb9c"},
{file = "wrapt-1.16.0-cp36-cp36m-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3ebf019be5c09d400cf7b024aa52b1f3aeebeff51550d007e92c3c1c4afc2a40"},
{file = "wrapt-1.16.0-cp36-cp36m-musllinux_1_1_aarch64.whl", hash = "sha256:0d2691979e93d06a95a26257adb7bfd0c93818e89b1406f5a28f36e0d8c1e1fc"},
{file = "wrapt-1.16.0-cp36-cp36m-musllinux_1_1_i686.whl", hash = "sha256:1acd723ee2a8826f3d53910255643e33673e1d11db84ce5880675954183ec47e"},
{file = "wrapt-1.16.0-cp36-cp36m-musllinux_1_1_x86_64.whl", hash = "sha256:bc57efac2da352a51cc4658878a68d2b1b67dbe9d33c36cb826ca449d80a8465"},
{file = "wrapt-1.16.0-cp36-cp36m-win32.whl", hash = "sha256:da4813f751142436b075ed7aa012a8778aa43a99f7b36afe9b742d3ed8bdc95e"},
{file = "wrapt-1.16.0-cp36-cp36m-win_amd64.whl", hash = "sha256:6f6eac2360f2d543cc875a0e5efd413b6cbd483cb3ad7ebf888884a6e0d2e966"},
{file = "wrapt-1.16.0-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:a0ea261ce52b5952bf669684a251a66df239ec6d441ccb59ec7afa882265d593"},
{file = "wrapt-1.16.0-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:7bd2d7ff69a2cac767fbf7a2b206add2e9a210e57947dd7ce03e25d03d2de292"},
{file = "wrapt-1.16.0-cp37-cp37m-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:9159485323798c8dc530a224bd3ffcf76659319ccc7bbd52e01e73bd0241a0c5"},
{file = "wrapt-1.16.0-cp37-cp37m-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a86373cf37cd7764f2201b76496aba58a52e76dedfaa698ef9e9688bfd9e41cf"},
{file = "wrapt-1.16.0-cp37-cp37m-musllinux_1_1_aarch64.whl", hash = "sha256:73870c364c11f03ed072dda68ff7aea6d2a3a5c3fe250d917a429c7432e15228"},
{file = "wrapt-1.16.0-cp37-cp37m-musllinux_1_1_i686.whl", hash = "sha256:b935ae30c6e7400022b50f8d359c03ed233d45b725cfdd299462f41ee5ffba6f"},
{file = "wrapt-1.16.0-cp37-cp37m-musllinux_1_1_x86_64.whl", hash = "sha256:db98ad84a55eb09b3c32a96c576476777e87c520a34e2519d3e59c44710c002c"},
{file = "wrapt-1.16.0-cp37-cp37m-win32.whl", hash = "sha256:9153ed35fc5e4fa3b2fe97bddaa7cbec0ed22412b85bcdaf54aeba92ea37428c"},
{file = "wrapt-1.16.0-cp37-cp37m-win_amd64.whl", hash = "sha256:66dfbaa7cfa3eb707bbfcd46dab2bc6207b005cbc9caa2199bcbc81d95071a00"},
{file = "wrapt-1.16.0-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:1dd50a2696ff89f57bd8847647a1c363b687d3d796dc30d4dd4a9d1689a706f0"},
{file = "wrapt-1.16.0-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:44a2754372e32ab315734c6c73b24351d06e77ffff6ae27d2ecf14cf3d229202"},
{file = "wrapt-1.16.0-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8e9723528b9f787dc59168369e42ae1c3b0d3fadb2f1a71de14531d321ee05b0"},
{file = "wrapt-1.16.0-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:dbed418ba5c3dce92619656802cc5355cb679e58d0d89b50f116e4a9d5a9603e"},
{file = "wrapt-1.16.0-cp38-cp38-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:941988b89b4fd6b41c3f0bfb20e92bd23746579736b7343283297c4c8cbae68f"},
{file = "wrapt-1.16.0-cp38-cp38-musllinux_1_1_aarch64.whl", hash = "sha256:6a42cd0cfa8ffc1915aef79cb4284f6383d8a3e9dcca70c445dcfdd639d51267"},
{file = "wrapt-1.16.0-cp38-cp38-musllinux_1_1_i686.whl", hash = "sha256:1ca9b6085e4f866bd584fb135a041bfc32cab916e69f714a7d1d397f8c4891ca"},
{file = "wrapt-1.16.0-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:d5e49454f19ef621089e204f862388d29e6e8d8b162efce05208913dde5b9ad6"},
{file = "wrapt-1.16.0-cp38-cp38-win32.whl", hash = "sha256:c31f72b1b6624c9d863fc095da460802f43a7c6868c5dda140f51da24fd47d7b"},
{file = "wrapt-1.16.0-cp38-cp38-win_amd64.whl", hash = "sha256:490b0ee15c1a55be9c1bd8609b8cecd60e325f0575fc98f50058eae366e01f41"},
{file = "wrapt-1.16.0-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:9b201ae332c3637a42f02d1045e1d0cccfdc41f1f2f801dafbaa7e9b4797bfc2"},
{file = "wrapt-1.16.0-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:2076fad65c6736184e77d7d4729b63a6d1ae0b70da4868adeec40989858eb3fb"},
{file = "wrapt-1.16.0-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c5cd603b575ebceca7da5a3a251e69561bec509e0b46e4993e1cac402b7247b8"},
{file = "wrapt-1.16.0-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:b47cfad9e9bbbed2339081f4e346c93ecd7ab504299403320bf85f7f85c7d46c"},
{file = "wrapt-1.16.0-cp39-cp39-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f8212564d49c50eb4565e502814f694e240c55551a5f1bc841d4fcaabb0a9b8a"},
{file = "wrapt-1.16.0-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:5f15814a33e42b04e3de432e573aa557f9f0f56458745c2074952f564c50e664"},
{file = "wrapt-1.16.0-cp39-cp39-musllinux_1_1_i686.whl", hash = "sha256:db2e408d983b0e61e238cf579c09ef7020560441906ca990fe8412153e3b291f"},
{file = "wrapt-1.16.0-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:edfad1d29c73f9b863ebe7082ae9321374ccb10879eeabc84ba3b69f2579d537"},
{file = "wrapt-1.16.0-cp39-cp39-win32.whl", hash = "sha256:ed867c42c268f876097248e05b6117a65bcd1e63b779e916fe2e33cd6fd0d3c3"},
{file = "wrapt-1.16.0-cp39-cp39-win_amd64.whl", hash = "sha256:eb1b046be06b0fce7249f1d025cd359b4b80fc1c3e24ad9eca33e0dcdb2e4a35"},
{file = "wrapt-1.16.0-py3-none-any.whl", hash = "sha256:6906c4100a8fcbf2fa735f6059214bb13b97f75b1a61777fcf6432121ef12ef1"},
{file = "wrapt-1.16.0.tar.gz", hash = "sha256:5f370f952971e7d17c7d1ead40e49f32345a7f7a5373571ef44d800d06b1899d"},
]
[[package]]
name = "wsproto"
version = "1.2.0"
@@ -1278,4 +1840,4 @@ h11 = ">=0.9.0,<1"
[metadata]
lock-version = "2.0"
python-versions = "^3.12"
content-hash = "6cab1d930ad04560919f18c779fe4f9f2d28aba84d4385ed01aff1c876e453b6"
content-hash = "9dff2a47bb95e65f15616bb82926eb81d418456c5ec60db46dfe06056c643e31"

View File

@@ -1,6 +1,6 @@
[tool.poetry]
name = "linkpulse"
version = "0.2.2"
version = "0.3.0"
description = ""
authors = ["Xevion <xevion@xevion.dev>"]
license = "GNU GPL v3"
@@ -28,13 +28,23 @@ uvicorn = "^0.32.0"
asgi-correlation-id = "^4.3.4"
orjson = "^3.10.10"
hypercorn = "^0.17.3"
pwdlib = {extras = ["argon2"], version = "^0.2.1"}
pytest-xdist = "^3.6.1"
email-validator = "^2.2.0"
limits = "^3.13.0"
[tool.poetry.group.dev.dependencies]
memory-profiler = "^0.61.0"
bpython = "^0.24"
types-pytz = "^2024.2.0.20241003"
pytest = "^8.3.3"
httpx = "^0.27.2"
pytest-cov = "^6.0.0"
[build-system]
requires = ["poetry-core"]
build-backend = "poetry.core.masonry.api"
[tool.black]
line-length = 110

4
backend/pytest.ini Normal file
View File

@@ -0,0 +1,4 @@
[pytest]
filterwarnings =
ignore::DeprecationWarning
addopts = --show-capture=stderr

View File

@@ -8,7 +8,16 @@ fi
# Default to development mode if not defined
export ENVIRONMENT=${ENVIRONMENT:-development}
COMMAND='poetry run python3 -m linkpulse'
export LOG_JSON_FORMAT=${LOG_JSON_FORMAT:-false}
export LOG_LEVEL=${LOG_LEVEL:-debug}
COMMAND="poetry run python3 -m linkpulse $@"
# If arguments start with 'poetry run pytest' or 'pytest' use args as is
if [[ "$1" == "poetry" && "$2" == "run" && "$3" == "pytest" ]]; then
COMMAND=$@
elif [[ "$1" == "pytest" ]]; then
COMMAND=$@
fi
# Check if Railway CLI is available
RAILWAY_AVAILABLE=false
@@ -43,11 +52,11 @@ if $RAILWAY_AVAILABLE; then
fi
if $DATABASE_DEFINED; then
$COMMAND $@
$COMMAND
else
if $RAILWAY_AVAILABLE; then
if $PROJECT_LINKED; then
DATABASE_URL="$(railway variables -s Postgres --json | jq .DATABASE_PUBLIC_URL -cMr)" $COMMAND $@
DATABASE_URL="$(railway variables --service Postgres --environment development --json | jq .DATABASE_PUBLIC_URL -cMr)" $COMMAND
else
echo "error: Railway project not linked."
echo "Run 'railway link' to link the project."

30
frontend/.gitignore vendored
View File

@@ -1,6 +1,24 @@
.eslintcache
.pnpm-debug.log
node_modules/
coverage/
dist/
tsconfig.tsbuildinfo
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
node_modules
dist
dist-ssr
*.local
# Editor directories and files
.vscode/*
!.vscode/extensions.json
.idea
.DS_Store
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?

View File

@@ -1,3 +1,4 @@
coverage/
dist/
pnpm-lock.yaml
src/routeTree.gen.ts

4
frontend/.prettierrc Normal file
View File

@@ -0,0 +1,4 @@
{
"tabWidth": 2,
"useTabs": false
}

View File

@@ -16,7 +16,10 @@
# site block, listens on the $PORT environment variable, automatically assigned by railway
:{$PORT} {
respond /health 200
encode gzip
encode {
zstd fastest
gzip 3
}
log {
# access logs

View File

@@ -0,0 +1,18 @@
{
admin off # theres no need for the admin api in railway's environment
auto_https off # railway handles https for us, this would cause issues if left enabled
}
http://localhost:8080 {
respond /health 200
encode {
zstd fastest
gzip 3
}
handle /api/* {
reverse_proxy localhost:8000
}
reverse_proxy localhost:5173
}

21
frontend/components.json Normal file
View File

@@ -0,0 +1,21 @@
{
"$schema": "https://ui.shadcn.com/schema.json",
"style": "new-york",
"rsc": false,
"tsx": true,
"tailwind": {
"config": "tailwind.config.js",
"css": "src/index.css",
"baseColor": "zinc",
"cssVariables": true,
"prefix": ""
},
"aliases": {
"components": "@/components",
"utils": "@/lib/utils",
"ui": "@/components/ui",
"lib": "@/lib",
"hooks": "@/hooks"
},
"iconLibrary": "lucide"
}

28
frontend/eslint.config.js Normal file
View File

@@ -0,0 +1,28 @@
import js from "@eslint/js";
import reactHooks from "eslint-plugin-react-hooks";
import reactRefresh from "eslint-plugin-react-refresh";
import globals from "globals";
import tseslint from "typescript-eslint";
export default tseslint.config(
{ ignores: ["dist"] },
{
extends: [js.configs.recommended, ...tseslint.configs.recommended],
files: ["**/*.{ts,tsx}"],
languageOptions: {
ecmaVersion: 2020,
globals: globals.browser,
},
plugins: {
"react-hooks": reactHooks,
"react-refresh": reactRefresh,
},
rules: {
...reactHooks.configs.recommended.rules,
"react-refresh/only-export-components": [
"warn",
{ allowConstantExport: true },
],
},
},
);

View File

@@ -1,16 +1,13 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta
name="viewport"
content="width=device-width, initial-scale=1, shrink-to-fit=no"
/>
<meta name="theme-color" content="#fff" />
<title>App</title>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/linkpulse.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Linkpulse</title>
</head>
<body>
<div id="root" class="min-w-full min-h-screen"></div>
<script src="/src/index.tsx" type="module"></script>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>

View File

@@ -1,58 +1,52 @@
{
"name": "linkpulse",
"version": "0.0.1",
"author": "Xevion <xevion@xevion.dev>",
"private": true,
"version": "0.3.0",
"author": {
"name": "Xevion",
"url": "https://xevion.dev",
"email": "xevion@xevion.dev"
},
"type": "module",
"engines": {
"node": ">=22.0.0",
"pnpm": ">=9.0.0"
"scripts": {
"dev": "vite",
"build": "tsc -b && vite build",
"lint": "eslint .",
"preview": "vite preview"
},
"dependencies": {
"@radix-ui/react-label": "^2.1.0",
"@radix-ui/react-slot": "^1.1.0",
"@tanstack/react-router": "^1.81.1",
"class-variance-authority": "^0.7.0",
"clsx": "^2.1.1",
"lucide-react": "^0.456.0",
"react": "^18.3.1",
"react-dom": "^18.3.1"
"react-dom": "^18.3.1",
"tailwind-merge": "^2.5.4",
"tailwindcss-animate": "^1.0.7",
"true-myth": "^8.0.1",
"zustand": "^5.0.1"
},
"devDependencies": {
"@ianvs/prettier-plugin-sort-imports": "^4.2.1",
"@nkzw/eslint-config": "^1.16.0",
"@swc/core": "^1.6.5",
"@types/node": "^20.14.8",
"@types/react": "^18.3.3",
"@types/react-dom": "^18.3.0",
"@vitejs/plugin-react": "^4.3.1",
"autoprefixer": "^10.4.19",
"eslint": "^8.57.0",
"npm-run-all2": "^6.2.0",
"postcss": "^8.4.38",
"prettier": "^3.3.2",
"prettier-plugin-tailwindcss": "^0.6.5",
"tailwindcss": "^3.4.4",
"ts-node": "^10.9.2",
"typescript": "^5.5.2",
"vite": "^5.3.1",
"vitest": "^1.6.0"
},
"scripts": {
"preinstall": "command -v git >/dev/null 2>&1 && git config core.hooksPath git-hooks || true",
"build": "vite build",
"dev:update-deps": "rm -rf pnpm-lock.yaml node_modules/ **/node_modules && pnpm install",
"dev": "vite dev",
"format": "prettier --write .",
"lint:format": "prettier --cache --check .",
"lint": "eslint --cache .",
"test": "npm-run-all --parallel tsc:check vitest:run lint lint:format",
"tsc:check": "tsc",
"vitest:run": "vitest run"
},
"browserslist": [
">0.2%",
"not dead",
"not op_mini all"
],
"pnpm": {
"updateConfig": {
"ignoreDependencies": [
"eslint"
]
}
}
"@eslint/js": "^9.13.0",
"@tanstack/router-devtools": "^1.81.1",
"@tanstack/router-plugin": "^1.79.0",
"@types/node": "^22.9.0",
"@types/react": "^18.3.12",
"@types/react-dom": "^18.3.1",
"@vitejs/plugin-react-swc": "^3.5.0",
"autoprefixer": "^10.4.20",
"eslint": "^9.13.0",
"eslint-plugin-react-hooks": "^5.0.0",
"eslint-plugin-react-refresh": "^0.4.14",
"globals": "^15.11.0",
"postcss": "^8.4.47",
"prettier": "^3.3.3",
"tailwindcss": "^3.4.14",
"typescript": "~5.6.2",
"typescript-eslint": "^8.11.0",
"vite": "^5.4.10",
"vitest": "^2.1.4"
}
}

4107
frontend/pnpm-lock.yaml generated
View File

File diff suppressed because it is too large Load Diff

View File

@@ -1,6 +1,6 @@
module.exports = {
export default {
plugins: {
autoprefixer: {},
tailwindcss: {},
autoprefixer: {},
},
};

View File

@@ -1,7 +1,7 @@
export default {
plugins: [
'@ianvs/prettier-plugin-sort-imports',
'prettier-plugin-tailwindcss',
"@ianvs/prettier-plugin-sort-imports",
"prettier-plugin-tailwindcss",
],
singleQuote: true,
};

View File

@@ -0,0 +1,5 @@
<svg fill="#000000" viewBox="-8 0 512 512"
xmlns="http://www.w3.org/2000/svg">
<path
d="M248 8C111.03 8 0 119.03 0 256s111.03 248 248 248 248-111.03 248-248S384.97 8 248 8zm0 432c-101.69 0-184-82.29-184-184 0-101.69 82.29-184 184-184 101.69 0 184 82.29 184 184 0 101.69-82.29 184-184 184zm0-312c-70.69 0-128 57.31-128 128s57.31 128 128 128 128-57.31 128-128-57.31-128-128-128zm0 192c-35.29 0-64-28.71-64-64s28.71-64 64-64 64 28.71 64 64-28.71 64-64 64z" />
</svg>

After

Width:  |  Height:  |  Size: 467 B

View File

@@ -1,20 +0,0 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
:root {
--background-color: #fff;
--text-color: #111;
}
@media (prefers-color-scheme: dark) {
:root {
--background-color: rgb(20, 20, 20);
--text-color: rgb(230, 230, 230);
}
}
body {
background-color: var(--background-color);
color: var(--text-color);
}

View File

@@ -1,5 +1,5 @@
import { expect, test } from 'vitest';
import { expect, test } from "vitest";
test('App', () => {
test("App", () => {
expect(1 + 1).toBe(2);
});

View File

@@ -1,91 +0,0 @@
import { useEffect, useState } from 'react';
const backendUrl = import.meta.env.PROD
? '/api'
: `http://${import.meta.env.VITE_BACKEND_TARGET}/api`;
const Code = (props: JSX.IntrinsicElements['code']) => (
<code
className="border-1 2py-1 mx-1 rounded border border-pink-500 bg-neutral-100 px-1 font-mono font-light text-pink-500 dark:border-pink-400 dark:bg-neutral-700 dark:text-pink-400"
{...props}
/>
);
type SeenIP = {
last_seen: string;
ip: string;
count: number;
};
export default function App() {
const [seenIps, setSeenIps] = useState<SeenIP[]>([]);
const [error, setError] = useState<string | null>(null);
const refreshData = async () => {
try {
const response = await fetch(`${backendUrl}/ips`);
if (!response.ok) {
throw new Error(`Error: ${response.statusText}`);
}
const data = await response.json();
setSeenIps(data.ips);
setError(null); // Clear any previous errors
console.log('Data fetched:', data);
} catch (error) {
console.error('Error fetching data:', error);
setError(error.message);
}
};
// Refresh data on component mount and every 30 seconds
useEffect(() => {
refreshData();
const interval = setInterval(
() => {
refreshData();
},
(import.meta.env.DEV ? 3 : 30) * 1000,
);
return () => clearInterval(interval);
}, []);
return (
<div className="min-h-full min-w-full">
<div className="mx-auto my-8 mt-10 w-8/12 max-w-md rounded border border-gray-200 p-4 shadow-md dark:border-neutral-600 dark:bg-neutral-800 dark:shadow-none">
<h1 className="mb-4 text-3xl">LinkPulse</h1>
{error && (
<div className="mb-4 rounded border border-red-500 bg-red-100 p-2 text-red-700 dark:border-red-800/50 dark:bg-red-950/50 dark:text-red-400">
{error}
</div>
)}
<div className="relative overflow-x-auto">
<table className="w-full text-left text-sm text-gray-500 rtl:text-right dark:text-gray-300">
<tbody>
{error == null
? seenIps.map((ip) => (
<tr
key={ip.ip}
className="border-b bg-white last:border-0 dark:border-neutral-700 dark:bg-neutral-800"
>
<td className="py-4">
<Code>{ip.ip}</Code>
</td>
<td className="py-4">
{ip.count} time{ip.count > 1 ? 's' : ''}
</td>
<td className="py-4">{ip.last_seen}</td>
</tr>
))
: null}
</tbody>
</table>
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,125 @@
import { Icons } from "@/components/icons";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { login } from "@/lib/auth";
import { useUserStore } from "@/lib/state";
import { cn } from "@/lib/utils";
import { Link } from "@tanstack/react-router";
import { HTMLAttributes, SyntheticEvent, useState } from "react";
interface UserAuthFormProps extends HTMLAttributes<HTMLDivElement> {}
export function RegisterForm({ className, ...props }: UserAuthFormProps) {
const [isLoading, setIsLoading] = useState<boolean>(false);
async function onSubmit(event: SyntheticEvent) {
event.preventDefault();
setIsLoading(true);
// await login()
setIsLoading(false);
}
return (
<div className={cn("grid gap-6", className)} {...props}>
<form onSubmit={onSubmit}>
<div className="grid gap-2">
<div className="grid gap-1">
<Label className="sr-only" htmlFor="email">
Email
</Label>
<Input
id="email"
placeholder="name@example.com"
type="email"
autoCapitalize="none"
autoComplete="email"
autoCorrect="off"
disabled={isLoading}
/>
</div>
<Button disabled={isLoading || true}>
{isLoading && (
<Icons.spinner className="mr-2 h-4 w-4 animate-spin" />
)}
Create Account
</Button>
</div>
</form>
</div>
);
}
export function LoginForm({ className, ...props }: UserAuthFormProps) {
const [isLoading, setIsLoading] = useState<boolean>(false);
const { setUser } = useUserStore();
async function onSubmit(event: SyntheticEvent) {
event.preventDefault();
const email = (event.target as HTMLFormElement).email.value;
const password = (event.target as HTMLFormElement).password.value;
setIsLoading(true);
const result = await login(email, password);
if (result.isOk) {
setUser(result.value);
} else {
alert("Login failed");
}
setIsLoading(false);
}
return (
<div className={cn("grid gap-6", className)} {...props}>
<form onSubmit={onSubmit}>
<div className="grid gap-2">
<div className="grid gap-1">
<Label htmlFor="email">Email</Label>
<Input
id="email"
type="email"
autoCapitalize="none"
autoComplete="email"
autoCorrect="off"
disabled={isLoading}
/>
</div>
<div className="grid gap-1">
<Label htmlFor="email">Password</Label>
<Input
id="password"
type="password"
autoCapitalize="none"
autoComplete="current-password"
autoCorrect="off"
disabled={isLoading}
/>
</div>
<Button disabled={isLoading}>
{isLoading && (
<Icons.spinner className="mr-2 h-4 w-4 animate-spin" />
)}
Login
</Button>
</div>
</form>
<p className="text-center text-sm text-muted-foreground">
<Link
to="/register"
className="underline underline-offset-4 hover:text-primary"
>
Register
</Link>{" "}
or{" "}
<Link
href="/forgot-password"
className="underline underline-offset-4 hover:text-primary"
>
Forgot Password
</Link>
</p>
</div>
);
}

View File

@@ -0,0 +1,157 @@
type IconProps = React.HTMLAttributes<SVGElement>;
export const Icons = {
linkpulse: (props: IconProps) => (
<svg xmlns="http://www.w3.org/2000/svg" viewBox="-8 0 512 512" {...props}>
<path
fill="currentColor"
stroke="currentColor"
d="M248 8C111.03 8 0 119.03 0 256s111.03 248 248 248 248-111.03 248-248S384.97 8 248 8zm0 432c-101.69 0-184-82.29-184-184 0-101.69 82.29-184 184-184 101.69 0 184 82.29 184 184 0 101.69-82.29 184-184 184zm0-312c-70.69 0-128 57.31-128 128s57.31 128 128 128 128-57.31 128-128-57.31-128-128-128zm0 192c-35.29 0-64-28.71-64-64s28.71-64 64-64 64 28.71 64 64-28.71 64-64 64z"
/>
</svg>
),
logo: (props: IconProps) => (
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 256 256" {...props}>
<rect width="256" height="256" fill="none" />
<line
x1="208"
y1="128"
x2="128"
y2="208"
fill="none"
stroke="currentColor"
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth="32"
/>
<line
x1="192"
y1="40"
x2="40"
y2="192"
fill="none"
stroke="currentColor"
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth="32"
/>
</svg>
),
twitter: (props: IconProps) => (
<svg
{...props}
height="23"
viewBox="0 0 1200 1227"
width="23"
xmlns="http://www.w3.org/2000/svg"
>
<path d="M714.163 519.284L1160.89 0H1055.03L667.137 450.887L357.328 0H0L468.492 681.821L0 1226.37H105.866L515.491 750.218L842.672 1226.37H1200L714.137 519.284H714.163ZM569.165 687.828L521.697 619.934L144.011 79.6944H306.615L611.412 515.685L658.88 583.579L1055.08 1150.3H892.476L569.165 687.854V687.828Z" />
</svg>
),
gitHub: (props: IconProps) => (
<svg viewBox="0 0 438.549 438.549" {...props}>
<path
fill="currentColor"
d="M409.132 114.573c-19.608-33.596-46.205-60.194-79.798-79.8-33.598-19.607-70.277-29.408-110.063-29.408-39.781 0-76.472 9.804-110.063 29.408-33.596 19.605-60.192 46.204-79.8 79.8C9.803 148.168 0 184.854 0 224.63c0 47.78 13.94 90.745 41.827 128.906 27.884 38.164 63.906 64.572 108.063 79.227 5.14.954 8.945.283 11.419-1.996 2.475-2.282 3.711-5.14 3.711-8.562 0-.571-.049-5.708-.144-15.417a2549.81 2549.81 0 01-.144-25.406l-6.567 1.136c-4.187.767-9.469 1.092-15.846 1-6.374-.089-12.991-.757-19.842-1.999-6.854-1.231-13.229-4.086-19.13-8.559-5.898-4.473-10.085-10.328-12.56-17.556l-2.855-6.57c-1.903-4.374-4.899-9.233-8.992-14.559-4.093-5.331-8.232-8.945-12.419-10.848l-1.999-1.431c-1.332-.951-2.568-2.098-3.711-3.429-1.142-1.331-1.997-2.663-2.568-3.997-.572-1.335-.098-2.43 1.427-3.289 1.525-.859 4.281-1.276 8.28-1.276l5.708.853c3.807.763 8.516 3.042 14.133 6.851 5.614 3.806 10.229 8.754 13.846 14.842 4.38 7.806 9.657 13.754 15.846 17.847 6.184 4.093 12.419 6.136 18.699 6.136 6.28 0 11.704-.476 16.274-1.423 4.565-.952 8.848-2.383 12.847-4.285 1.713-12.758 6.377-22.559 13.988-29.41-10.848-1.14-20.601-2.857-29.264-5.14-8.658-2.286-17.605-5.996-26.835-11.14-9.235-5.137-16.896-11.516-22.985-19.126-6.09-7.614-11.088-17.61-14.987-29.979-3.901-12.374-5.852-26.648-5.852-42.826 0-23.035 7.52-42.637 22.557-58.817-7.044-17.318-6.379-36.732 1.997-58.24 5.52-1.715 13.706-.428 24.554 3.853 10.85 4.283 18.794 7.952 23.84 10.994 5.046 3.041 9.089 5.618 12.135 7.708 17.705-4.947 35.976-7.421 54.818-7.421s37.117 2.474 54.823 7.421l10.849-6.849c7.419-4.57 16.18-8.758 26.262-12.565 10.088-3.805 17.802-4.853 23.134-3.138 8.562 21.509 9.325 40.922 2.279 58.24 15.036 16.18 22.559 35.787 22.559 58.817 0 16.178-1.958 30.497-5.853 42.966-3.9 12.471-8.941 22.457-15.125 29.979-6.191 7.521-13.901 13.85-23.131 18.986-9.232 5.14-18.182 8.85-26.84 11.136-8.662 2.286-18.415 4.004-29.263 5.146 9.894 8.562 14.842 22.077 14.842 40.539v60.237c0 3.422 1.19 6.279 3.572 8.562 2.379 2.279 6.136 2.95 11.276 1.995 44.163-14.653 80.185-41.062 108.068-79.226 27.88-38.161 41.825-81.126 41.825-128.906-.01-39.771-9.818-76.454-29.414-110.049z"
></path>
</svg>
),
radix: (props: IconProps) => (
<svg viewBox="0 0 25 25" fill="none" {...props}>
<path
d="M12 25C7.58173 25 4 21.4183 4 17C4 12.5817 7.58173 9 12 9V25Z"
fill="currentcolor"
></path>
<path d="M12 0H4V8H12V0Z" fill="currentcolor"></path>
<path
d="M17 8C19.2091 8 21 6.20914 21 4C21 1.79086 19.2091 0 17 0C14.7909 0 13 1.79086 13 4C13 6.20914 14.7909 8 17 8Z"
fill="currentcolor"
></path>
</svg>
),
aria: (props: IconProps) => (
<svg role="img" viewBox="0 0 24 24" fill="currentColor" {...props}>
<path d="M13.966 22.624l-1.69-4.281H8.122l3.892-9.144 5.662 13.425zM8.884 1.376H0v21.248zm15.116 0h-8.884L24 22.624Z" />
</svg>
),
npm: (props: IconProps) => (
<svg viewBox="0 0 24 24" {...props}>
<path
d="M1.763 0C.786 0 0 .786 0 1.763v20.474C0 23.214.786 24 1.763 24h20.474c.977 0 1.763-.786 1.763-1.763V1.763C24 .786 23.214 0 22.237 0zM5.13 5.323l13.837.019-.009 13.836h-3.464l.01-10.382h-3.456L12.04 19.17H5.113z"
fill="currentColor"
/>
</svg>
),
yarn: (props: IconProps) => (
<svg viewBox="0 0 24 24" {...props}>
<path
d="M12 0C5.375 0 0 5.375 0 12s5.375 12 12 12 12-5.375 12-12S18.625 0 12 0zm.768 4.105c.183 0 .363.053.525.157.125.083.287.185.755 1.154.31-.088.468-.042.551-.019.204.056.366.19.463.375.477.917.542 2.553.334 3.605-.241 1.232-.755 2.029-1.131 2.576.324.329.778.899 1.117 1.825.278.774.31 1.478.273 2.015a5.51 5.51 0 0 0 .602-.329c.593-.366 1.487-.917 2.553-.931.714-.009 1.269.445 1.353 1.103a1.23 1.23 0 0 1-.945 1.362c-.649.158-.95.278-1.821.843-1.232.797-2.539 1.242-3.012 1.39a1.686 1.686 0 0 1-.704.343c-.737.181-3.266.315-3.466.315h-.046c-.783 0-1.214-.241-1.45-.491-.658.329-1.51.19-2.122-.134a1.078 1.078 0 0 1-.58-1.153 1.243 1.243 0 0 1-.153-.195c-.162-.25-.528-.936-.454-1.946.056-.723.556-1.367.88-1.71a5.522 5.522 0 0 1 .408-2.256c.306-.727.885-1.348 1.32-1.737-.32-.537-.644-1.367-.329-2.21.227-.602.412-.936.82-1.08h-.005c.199-.074.389-.153.486-.259a3.418 3.418 0 0 1 2.298-1.103c.037-.093.079-.185.125-.283.31-.658.639-1.029 1.024-1.168a.94.94 0 0 1 .328-.06zm.006.7c-.507.016-1.001 1.519-1.001 1.519s-1.27-.204-2.266.871c-.199.218-.468.334-.746.44-.079.028-.176.023-.417.672-.371.991.625 2.094.625 2.094s-1.186.839-1.626 1.881c-.486 1.144-.338 2.261-.338 2.261s-.843.732-.899 1.487c-.051.663.139 1.2.343 1.515.227.343.51.176.51.176s-.561.653-.037.931c.477.25 1.283.394 1.71-.037.31-.31.371-1.001.486-1.283.028-.065.12.111.209.199.097.093.264.195.264.195s-.755.324-.445 1.066c.102.246.468.403 1.066.398.222-.005 2.664-.139 3.313-.296.375-.088.505-.283.505-.283s1.566-.431 2.998-1.357c.917-.598 1.293-.76 2.034-.936.612-.148.57-1.098-.241-1.084-.839.009-1.575.44-2.196.825-1.163.718-1.742.672-1.742.672l-.018-.032c-.079-.13.371-1.293-.134-2.678-.547-1.515-1.413-1.881-1.344-1.997.297-.5 1.038-1.297 1.334-2.78.176-.899.13-2.377-.269-3.151-.074-.144-.732.241-.732.241s-.616-1.371-.788-1.483a.271.271 0 0 0-.157-.046z"
fill="currentColor"
/>
</svg>
),
pnpm: (props: IconProps) => (
<svg viewBox="0 0 24 24" {...props}>
<path
d="M0 0v7.5h7.5V0zm8.25 0v7.5h7.498V0zm8.25 0v7.5H24V0zM8.25 8.25v7.5h7.498v-7.5zm8.25 0v7.5H24v-7.5zM0 16.5V24h7.5v-7.5zm8.25 0V24h7.498v-7.5zm8.25 0V24H24v-7.5z"
fill="currentColor"
/>
</svg>
),
react: (props: IconProps) => (
<svg viewBox="0 0 24 24" {...props}>
<path
d="M14.23 12.004a2.236 2.236 0 0 1-2.235 2.236 2.236 2.236 0 0 1-2.236-2.236 2.236 2.236 0 0 1 2.235-2.236 2.236 2.236 0 0 1 2.236 2.236zm2.648-10.69c-1.346 0-3.107.96-4.888 2.622-1.78-1.653-3.542-2.602-4.887-2.602-.41 0-.783.093-1.106.278-1.375.793-1.683 3.264-.973 6.365C1.98 8.917 0 10.42 0 12.004c0 1.59 1.99 3.097 5.043 4.03-.704 3.113-.39 5.588.988 6.38.32.187.69.275 1.102.275 1.345 0 3.107-.96 4.888-2.624 1.78 1.654 3.542 2.603 4.887 2.603.41 0 .783-.09 1.106-.275 1.374-.792 1.683-3.263.973-6.365C22.02 15.096 24 13.59 24 12.004c0-1.59-1.99-3.097-5.043-4.032.704-3.11.39-5.587-.988-6.38-.318-.184-.688-.277-1.092-.278zm-.005 1.09v.006c.225 0 .406.044.558.127.666.382.955 1.835.73 3.704-.054.46-.142.945-.25 1.44-.96-.236-2.006-.417-3.107-.534-.66-.905-1.345-1.727-2.035-2.447 1.592-1.48 3.087-2.292 4.105-2.295zm-9.77.02c1.012 0 2.514.808 4.11 2.28-.686.72-1.37 1.537-2.02 2.442-1.107.117-2.154.298-3.113.538-.112-.49-.195-.964-.254-1.42-.23-1.868.054-3.32.714-3.707.19-.09.4-.127.563-.132zm4.882 3.05c.455.468.91.992 1.36 1.564-.44-.02-.89-.034-1.345-.034-.46 0-.915.01-1.36.034.44-.572.895-1.096 1.345-1.565zM12 8.1c.74 0 1.477.034 2.202.093.406.582.802 1.203 1.183 1.86.372.64.71 1.29 1.018 1.946-.308.655-.646 1.31-1.013 1.95-.38.66-.773 1.288-1.18 1.87-.728.063-1.466.098-2.21.098-.74 0-1.477-.035-2.202-.093-.406-.582-.802-1.204-1.183-1.86-.372-.64-.71-1.29-1.018-1.946.303-.657.646-1.313 1.013-1.954.38-.66.773-1.286 1.18-1.868.728-.064 1.466-.098 2.21-.098zm-3.635.254c-.24.377-.48.763-.704 1.16-.225.39-.435.782-.635 1.174-.265-.656-.49-1.31-.676-1.947.64-.15 1.315-.283 2.015-.386zm7.26 0c.695.103 1.365.23 2.006.387-.18.632-.405 1.282-.66 1.933-.2-.39-.41-.783-.64-1.174-.225-.392-.465-.774-.705-1.146zm3.063.675c.484.15.944.317 1.375.498 1.732.74 2.852 1.708 2.852 2.476-.005.768-1.125 1.74-2.857 2.475-.42.18-.88.342-1.355.493-.28-.958-.646-1.956-1.1-2.98.45-1.017.81-2.01 1.085-2.964zm-13.395.004c.278.96.645 1.957 1.1 2.98-.45 1.017-.812 2.01-1.086 2.964-.484-.15-.944-.318-1.37-.5-1.732-.737-2.852-1.706-2.852-2.474 0-.768 1.12-1.742 2.852-2.476.42-.18.88-.342 1.356-.494zm11.678 4.28c.265.657.49 1.312.676 1.948-.64.157-1.316.29-2.016.39.24-.375.48-.762.705-1.158.225-.39.435-.788.636-1.18zm-9.945.02c.2.392.41.783.64 1.175.23.39.465.772.705 1.143-.695-.102-1.365-.23-2.006-.386.18-.63.406-1.282.66-1.933zM17.92 16.32c.112.493.2.968.254 1.423.23 1.868-.054 3.32-.714 3.708-.147.09-.338.128-.563.128-1.012 0-2.514-.807-4.11-2.28.686-.72 1.37-1.536 2.02-2.44 1.107-.118 2.154-.3 3.113-.54zm-11.83.01c.96.234 2.006.415 3.107.532.66.905 1.345 1.727 2.035 2.446-1.595 1.483-3.092 2.295-4.11 2.295-.22-.005-.406-.05-.553-.132-.666-.38-.955-1.834-.73-3.703.054-.46.142-.944.25-1.438zm4.56.64c.44.02.89.034 1.345.034.46 0 .915-.01 1.36-.034-.44.572-.895 1.095-1.345 1.565-.455-.47-.91-.993-1.36-1.565z"
fill="currentColor"
/>
</svg>
),
tailwind: (props: IconProps) => (
<svg viewBox="0 0 24 24" {...props}>
<path
d="M12.001,4.8c-3.2,0-5.2,1.6-6,4.8c1.2-1.6,2.6-2.2,4.2-1.8c0.913,0.228,1.565,0.89,2.288,1.624 C13.666,10.618,15.027,12,18.001,12c3.2,0,5.2-1.6,6-4.8c-1.2,1.6-2.6,2.2-4.2,1.8c-0.913-0.228-1.565-0.89-2.288-1.624 C16.337,6.182,14.976,4.8,12.001,4.8z M6.001,12c-3.2,0-5.2,1.6-6,4.8c1.2-1.6,2.6-2.2,4.2-1.8c0.913,0.228,1.565,0.89,2.288,1.624 c1.177,1.194,2.538,2.576,5.512,2.576c3.2,0,5.2-1.6,6-4.8c-1.2,1.6-2.6,2.2-4.2,1.8c-0.913-0.228-1.565-0.89-2.288-1.624 C10.337,13.382,8.976,12,6.001,12z"
fill="currentColor"
/>
</svg>
),
google: (props: IconProps) => (
<svg role="img" viewBox="0 0 24 24" {...props}>
<path
fill="currentColor"
d="M12.48 10.92v3.28h7.84c-.24 1.84-.853 3.187-1.787 4.133-1.147 1.147-2.933 2.4-6.053 2.4-4.827 0-8.6-3.893-8.6-8.72s3.773-8.72 8.6-8.72c2.6 0 4.507 1.027 5.907 2.347l2.307-2.307C18.747 1.44 16.133 0 12.48 0 5.867 0 .307 5.387.307 12s5.56 12 12.173 12c3.573 0 6.267-1.173 8.373-3.36 2.16-2.16 2.84-5.213 2.84-7.667 0-.76-.053-1.467-.173-2.053H12.48z"
/>
</svg>
),
apple: (props: IconProps) => (
<svg role="img" viewBox="0 0 24 24" {...props}>
<path
d="M12.152 6.896c-.948 0-2.415-1.078-3.96-1.04-2.04.027-3.91 1.183-4.961 3.014-2.117 3.675-.546 9.103 1.519 12.09 1.013 1.454 2.208 3.09 3.792 3.039 1.52-.065 2.09-.987 3.935-.987 1.831 0 2.35.987 3.96.948 1.637-.026 2.676-1.48 3.676-2.948 1.156-1.688 1.636-3.325 1.662-3.415-.039-.013-3.182-1.221-3.22-4.857-.026-3.04 2.48-4.494 2.597-4.559-1.429-2.09-3.623-2.324-4.39-2.376-2-.156-3.675 1.09-4.61 1.09zM15.53 3.83c.843-1.012 1.4-2.427 1.245-3.83-1.207.052-2.662.805-3.532 1.818-.78.896-1.454 2.338-1.273 3.714 1.338.104 2.715-.688 3.559-1.701"
fill="currentColor"
/>
</svg>
),
paypal: (props: IconProps) => (
<svg role="img" viewBox="0 0 24 24" {...props}>
<path
d="M7.076 21.337H2.47a.641.641 0 0 1-.633-.74L4.944.901C5.026.382 5.474 0 5.998 0h7.46c2.57 0 4.578.543 5.69 1.81 1.01 1.15 1.304 2.42 1.012 4.287-.023.143-.047.288-.077.437-.983 5.05-4.349 6.797-8.647 6.797h-2.19c-.524 0-.968.382-1.05.9l-1.12 7.106zm14.146-14.42a3.35 3.35 0 0 0-.607-.541c-.013.076-.026.175-.041.254-.93 4.778-4.005 7.201-9.138 7.201h-2.19a.563.563 0 0 0-.556.479l-1.187 7.527h-.506l-.24 1.516a.56.56 0 0 0 .554.647h3.882c.46 0 .85-.334.922-.788.06-.26.76-4.852.816-5.09a.932.932 0 0 1 .923-.788h.58c3.76 0 6.705-1.528 7.565-5.946.36-1.847.174-3.388-.777-4.471z"
fill="currentColor"
/>
</svg>
),
spinner: (props: IconProps) => (
<svg
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
{...props}
>
<path d="M21 12a9 9 0 1 1-6.219-8.56" />
</svg>
),
};

View File

@@ -0,0 +1,67 @@
import { Icons } from "@/components/icons";
import { buttonVariants } from "@/components/ui/button";
import { cn } from "@/lib/utils";
import { Link } from "@tanstack/react-router";
import { ReactNode } from "react";
export function AgreementText() {
return (
<p className="text-center text-sm text-muted-foreground">
By clicking continue, you agree to our{" "}
<Link
href="/terms"
className="underline underline-offset-4 hover:text-primary"
>
Terms of Service
</Link>{" "}
and{" "}
<Link
href="/privacy"
className="underline underline-offset-4 hover:text-primary"
>
Privacy Policy
</Link>
.
</p>
);
}
export default function AuthenticationPage({
children,
}: {
children: ReactNode;
}) {
return (
<div className="container relative h-[100vh] flex-col items-center grid w-screen lg:max-w-none lg:grid-cols-2 lg:px-0">
<Link
href="/"
className={cn(
buttonVariants({ variant: "ghost" }),
"absolute right-4 top-4 md:right-8 md:top-8",
)}
>
Linkpulse
</Link>
<div className="relative hidden h-full flex-col grow bg-muted p-10 text-white dark:border-r lg:flex">
<div className="absolute inset-0 bg-zinc-900" />
<div className="relative z-20 flex items-center text-lg font-medium">
<Icons.linkpulse className="mr-2 h-6 w-6 text-white" />
Linkpulse
</div>
<div className="z-20 mt-auto space-y-2">
{/* <blockquote className="space-y-2">
<p className="text-lg">
&ldquo;This library has saved me countless hours of work and
helped me deliver stunning designs to my clients faster than
ever before.&rdquo;
</p>
<footer className="text-sm">Sofia Davis</footer>
</blockquote> */}
{/* <p className="text-lg"></p>
<footer className="text-sm"></footer> */}
</div>
</div>
<div className="px-6">{children}</div>
</div>
);
}

View File

@@ -0,0 +1,57 @@
import * as React from "react";
import { Slot } from "@radix-ui/react-slot";
import { cva, type VariantProps } from "class-variance-authority";
import { cn } from "@/lib/utils";
const buttonVariants = cva(
"inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0",
{
variants: {
variant: {
default:
"bg-primary text-primary-foreground shadow hover:bg-primary/90",
destructive:
"bg-destructive text-destructive-foreground shadow-sm hover:bg-destructive/90",
outline:
"border border-input bg-background shadow-sm hover:bg-accent hover:text-accent-foreground",
secondary:
"bg-secondary text-secondary-foreground shadow-sm hover:bg-secondary/80",
ghost: "hover:bg-accent hover:text-accent-foreground",
link: "text-primary underline-offset-4 hover:underline",
},
size: {
default: "h-9 px-4 py-2",
sm: "h-8 rounded-md px-3 text-xs",
lg: "h-10 rounded-md px-8",
icon: "h-9 w-9",
},
},
defaultVariants: {
variant: "default",
size: "default",
},
},
);
export interface ButtonProps
extends React.ButtonHTMLAttributes<HTMLButtonElement>,
VariantProps<typeof buttonVariants> {
asChild?: boolean;
}
const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
({ className, variant, size, asChild = false, ...props }, ref) => {
const Comp = asChild ? Slot : "button";
return (
<Comp
className={cn(buttonVariants({ variant, size, className }))}
ref={ref}
{...props}
/>
);
},
);
Button.displayName = "Button";
export { Button, buttonVariants };

View File

@@ -0,0 +1,22 @@
import * as React from "react";
import { cn } from "@/lib/utils";
const Input = React.forwardRef<HTMLInputElement, React.ComponentProps<"input">>(
({ className, type, ...props }, ref) => {
return (
<input
type={type}
className={cn(
"flex h-9 w-full rounded-md border border-input bg-transparent px-3 py-1 text-base shadow-sm transition-colors file:border-0 file:bg-transparent file:text-sm file:font-medium file:text-foreground placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50 md:text-sm",
className,
)}
ref={ref}
{...props}
/>
);
},
);
Input.displayName = "Input";
export { Input };

View File

@@ -0,0 +1,24 @@
import * as React from "react";
import * as LabelPrimitive from "@radix-ui/react-label";
import { cva, type VariantProps } from "class-variance-authority";
import { cn } from "@/lib/utils";
const labelVariants = cva(
"text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70",
);
const Label = React.forwardRef<
React.ElementRef<typeof LabelPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof LabelPrimitive.Root> &
VariantProps<typeof labelVariants>
>(({ className, ...props }, ref) => (
<LabelPrimitive.Root
ref={ref}
className={cn(labelVariants(), className)}
{...props}
/>
));
Label.displayName = LabelPrimitive.Root.displayName;
export { Label };

137
frontend/src/index.css Normal file
View File

@@ -0,0 +1,137 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
:root {
font-family: Inter, system-ui, Avenir, Helvetica, Arial, sans-serif;
line-height: 1.5;
font-weight: 400;
color-scheme: light dark;
color: rgba(255, 255, 255, 0.87);
background-color: #242424;
font-synthesis: none;
text-rendering: optimizeLegibility;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
a {
font-weight: 500;
color: #646cff;
text-decoration: inherit;
}
a:hover {
color: #535bf2;
}
body {
margin: 0;
display: flex;
place-items: center;
min-width: 320px;
min-height: 100vh;
}
h1 {
font-size: 3.2em;
line-height: 1.1;
}
button {
border-radius: 8px;
border: 1px solid transparent;
padding: 0.6em 1.2em;
font-size: 1em;
font-weight: 500;
font-family: inherit;
background-color: #1a1a1a;
cursor: pointer;
transition: border-color 0.25s;
}
button:hover {
border-color: #646cff;
}
button:focus,
button:focus-visible {
outline: 4px auto -webkit-focus-ring-color;
}
@media (prefers-color-scheme: light) {
:root {
color: #213547;
background-color: #ffffff;
}
a:hover {
color: #747bff;
}
button {
background-color: #f9f9f9;
}
}
@layer base {
:root {
--background: 0 0% 100%;
--foreground: 240 10% 3.9%;
--card: 0 0% 100%;
--card-foreground: 240 10% 3.9%;
--popover: 0 0% 100%;
--popover-foreground: 240 10% 3.9%;
--primary: 240 5.9% 10%;
--primary-foreground: 0 0% 98%;
--secondary: 240 4.8% 95.9%;
--secondary-foreground: 240 5.9% 10%;
--muted: 240 4.8% 95.9%;
--muted-foreground: 240 3.8% 46.1%;
--accent: 240 4.8% 95.9%;
--accent-foreground: 240 5.9% 10%;
--destructive: 0 84.2% 60.2%;
--destructive-foreground: 0 0% 98%;
--border: 240 5.9% 90%;
--input: 240 5.9% 90%;
--ring: 240 10% 3.9%;
--chart-1: 12 76% 61%;
--chart-2: 173 58% 39%;
--chart-3: 197 37% 24%;
--chart-4: 43 74% 66%;
--chart-5: 27 87% 67%;
--radius: 0.5rem;
}
.dark {
--background: 240 10% 3.9%;
--foreground: 0 0% 98%;
--card: 240 10% 3.9%;
--card-foreground: 0 0% 98%;
--popover: 240 10% 3.9%;
--popover-foreground: 0 0% 98%;
--primary: 0 0% 98%;
--primary-foreground: 240 5.9% 10%;
--secondary: 240 3.7% 15.9%;
--secondary-foreground: 0 0% 98%;
--muted: 240 3.7% 15.9%;
--muted-foreground: 240 5% 64.9%;
--accent: 240 3.7% 15.9%;
--accent-foreground: 0 0% 98%;
--destructive: 0 62.8% 30.6%;
--destructive-foreground: 0 0% 98%;
--border: 240 3.7% 15.9%;
--input: 240 3.7% 15.9%;
--ring: 240 4.9% 83.9%;
--chart-1: 220 70% 50%;
--chart-2: 160 60% 45%;
--chart-3: 30 80% 55%;
--chart-4: 280 65% 60%;
--chart-5: 340 75% 55%;
}
}
@layer base {
* {
@apply border-border;
}
body {
@apply bg-background text-foreground;
}
}

View File

@@ -1,10 +0,0 @@
import App from './App.tsx';
import './App.css';
import { StrictMode } from 'react';
import { createRoot } from 'react-dom/client';
createRoot(document.getElementById('root')!).render(
<StrictMode>
<App />
</StrictMode>,
);

81
frontend/src/lib/auth.ts Normal file
View File

@@ -0,0 +1,81 @@
import Result, { ok, err } from "true-myth/result";
import { useUserStore } from "@/lib/state";
// Authentication on the frontend is managed by Zustand's state.
// Upon application load, a single request is made to acquire session state.
// Any route that requires authentication will redirect if a valid session is not acquired.
// The session state is stored in the user store.
// If any protected API call suddenly fails with a 401 status code, the user store is reset, a logout message is displayed, and the user is redirected to the login page.
// All redirects to the login page will carry a masked URL parameter that can be used to redirect the user back to the page they were on after logging in.
const TARGET = !import.meta.env.DEV
? `http://${import.meta.env.VITE_BACKEND_TARGET}`
: "";
type ErrorResponse = {
detail: string;
};
type SessionResponse = {
user: {};
};
/**
* Retrieves the current session from the server.
*
* An Ok result will contain the session data, implying that the session is valid and the user is authenticated.
* An Err result will contain an error response, implying that the session is invalid or non-existent, and the user is not authenticated.
*
* @returns {Promise<Result<SessionResponse, ErrorResponse>>} A promise that resolves to a Result object containing either the session data or an error response.
*/
export const getSession = async (): Promise<
Result<SessionResponse, ErrorResponse>
> => {
const response = await fetch(TARGET + "/api/session");
if (response.ok) {
const user = await response.json();
useUserStore.getState().setUser(user);
return ok({ user });
} else {
useUserStore.getState().logout();
const error = await response.json();
return err({ detail: error.detail });
}
};
export const isAuthenticated = async (): Promise<boolean> => {
let state = useUserStore.getState();
if (state.initialized) return state.user !== null;
else {
const result = await getSession();
return result.isOk;
}
};
type LoginResponse = {
email: string;
expiry: string;
};
export const login = async (
email: string,
password: string,
): Promise<Result<LoginResponse, ErrorResponse>> => {
const response = await fetch(TARGET + "/api/login", {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({ email, password }),
});
if (response.ok) {
const user = await response.json();
useUserStore.getState().setUser(user);
return ok(user);
} else {
const error = await response.json();
return err({ detail: error.detail });
}
};

21
frontend/src/lib/state.ts Normal file
View File

@@ -0,0 +1,21 @@
import { create } from "zustand";
type UserState = {
initialized: boolean;
user: {
// TODO: This will eventually carry more user information (name, avatar, etc.)
email: string;
} | null;
};
type UserActions = {
setUser: (user: UserState["user"]) => void;
logout: () => void;
};
export const useUserStore = create<UserState & UserActions>((set) => ({
initialized: false,
user: null,
setUser: (user) => set({ user, initialized: true }),
logout: () => set({ user: null }),
}));

View File

@@ -0,0 +1,6 @@
import { clsx, type ClassValue } from "clsx";
import { twMerge } from "tailwind-merge";
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs));
}

28
frontend/src/main.tsx Normal file
View File

@@ -0,0 +1,28 @@
import { RouterProvider, createRouter } from "@tanstack/react-router";
import { StrictMode } from "react";
import ReactDOM from "react-dom/client";
import "@/index.css";
// Import the generated route tree
import { routeTree } from "./routeTree.gen";
// Create a new router instance
const router = createRouter({ routeTree });
// Register the router instance for type safety
declare module "@tanstack/react-router" {
interface Register {
router: typeof router;
}
}
// Render the app
const rootElement = document.getElementById("root")!;
if (!rootElement.innerHTML) {
const root = ReactDOM.createRoot(rootElement);
root.render(
<StrictMode>
<RouterProvider router={router} />
</StrictMode>,
);
}

View File

@@ -0,0 +1,162 @@
/* eslint-disable */
// @ts-nocheck
// noinspection JSUnusedGlobalSymbols
// This file was automatically generated by TanStack Router.
// You should NOT make any changes in this file as it will be overwritten.
// Additionally, you should also exclude this file from your linter and/or formatter to prevent it from being checked or modified.
import { createFileRoute } from '@tanstack/react-router'
// Import Routes
import { Route as rootRoute } from './routes/__root'
import { Route as RegisterImport } from './routes/register'
import { Route as LoginImport } from './routes/login'
import { Route as DashboardImport } from './routes/dashboard'
// Create Virtual Routes
const IndexLazyImport = createFileRoute('/')()
// Create/Update Routes
const RegisterRoute = RegisterImport.update({
id: '/register',
path: '/register',
getParentRoute: () => rootRoute,
} as any)
const LoginRoute = LoginImport.update({
id: '/login',
path: '/login',
getParentRoute: () => rootRoute,
} as any)
const DashboardRoute = DashboardImport.update({
id: '/dashboard',
path: '/dashboard',
getParentRoute: () => rootRoute,
} as any)
const IndexLazyRoute = IndexLazyImport.update({
id: '/',
path: '/',
getParentRoute: () => rootRoute,
} as any).lazy(() => import('./routes/index.lazy').then((d) => d.Route))
// Populate the FileRoutesByPath interface
declare module '@tanstack/react-router' {
interface FileRoutesByPath {
'/': {
id: '/'
path: '/'
fullPath: '/'
preLoaderRoute: typeof IndexLazyImport
parentRoute: typeof rootRoute
}
'/dashboard': {
id: '/dashboard'
path: '/dashboard'
fullPath: '/dashboard'
preLoaderRoute: typeof DashboardImport
parentRoute: typeof rootRoute
}
'/login': {
id: '/login'
path: '/login'
fullPath: '/login'
preLoaderRoute: typeof LoginImport
parentRoute: typeof rootRoute
}
'/register': {
id: '/register'
path: '/register'
fullPath: '/register'
preLoaderRoute: typeof RegisterImport
parentRoute: typeof rootRoute
}
}
}
// Create and export the route tree
export interface FileRoutesByFullPath {
'/': typeof IndexLazyRoute
'/dashboard': typeof DashboardRoute
'/login': typeof LoginRoute
'/register': typeof RegisterRoute
}
export interface FileRoutesByTo {
'/': typeof IndexLazyRoute
'/dashboard': typeof DashboardRoute
'/login': typeof LoginRoute
'/register': typeof RegisterRoute
}
export interface FileRoutesById {
__root__: typeof rootRoute
'/': typeof IndexLazyRoute
'/dashboard': typeof DashboardRoute
'/login': typeof LoginRoute
'/register': typeof RegisterRoute
}
export interface FileRouteTypes {
fileRoutesByFullPath: FileRoutesByFullPath
fullPaths: '/' | '/dashboard' | '/login' | '/register'
fileRoutesByTo: FileRoutesByTo
to: '/' | '/dashboard' | '/login' | '/register'
id: '__root__' | '/' | '/dashboard' | '/login' | '/register'
fileRoutesById: FileRoutesById
}
export interface RootRouteChildren {
IndexLazyRoute: typeof IndexLazyRoute
DashboardRoute: typeof DashboardRoute
LoginRoute: typeof LoginRoute
RegisterRoute: typeof RegisterRoute
}
const rootRouteChildren: RootRouteChildren = {
IndexLazyRoute: IndexLazyRoute,
DashboardRoute: DashboardRoute,
LoginRoute: LoginRoute,
RegisterRoute: RegisterRoute,
}
export const routeTree = rootRoute
._addFileChildren(rootRouteChildren)
._addFileTypes<FileRouteTypes>()
/* ROUTE_MANIFEST_START
{
"routes": {
"__root__": {
"filePath": "__root.tsx",
"children": [
"/",
"/dashboard",
"/login",
"/register"
]
},
"/": {
"filePath": "index.lazy.tsx"
},
"/dashboard": {
"filePath": "dashboard.tsx"
},
"/login": {
"filePath": "login.tsx"
},
"/register": {
"filePath": "register.tsx"
}
}
}
ROUTE_MANIFEST_END */

View File

@@ -0,0 +1,11 @@
import { createRootRoute, Outlet } from "@tanstack/react-router";
import { TanStackRouterDevtools } from "@tanstack/router-devtools";
export const Route = createRootRoute({
component: () => (
<>
<Outlet />
<TanStackRouterDevtools />
</>
),
});

View File

@@ -0,0 +1,28 @@
import { useUserStore } from "@/lib/state";
import { createFileRoute, redirect } from "@tanstack/react-router";
import { isAuthenticated } from "@/lib/auth";
export const Route = createFileRoute("/dashboard")({
component: RouteComponent,
beforeLoad: async ({ location }) => {
const authenticated = await isAuthenticated();
if (!authenticated) {
return redirect({
to: "/login",
search: { redirect: location.href },
});
}
},
});
function RouteComponent() {
const { user } = useUserStore();
return (
<div>
<h1>Dashboard</h1>
<p>Welcome, {user?.email || "null"}</p>
</div>
);
}

View File

@@ -0,0 +1,13 @@
import { createLazyFileRoute } from "@tanstack/react-router";
export const Route = createLazyFileRoute("/")({
component: Index,
});
function Index() {
return (
<div className="p-2">
<h3>Welcome Home!</h3>
</div>
);
}

View File

@@ -0,0 +1,35 @@
import { createFileRoute, redirect } from "@tanstack/react-router";
import { LoginForm } from "@/components/auth/form";
import AuthenticationPage from "@/components/pages/authentication";
import { isAuthenticated } from "@/lib/auth";
export const Route = createFileRoute("/login")({
beforeLoad: async ({ location }) => {
const authenticated = await isAuthenticated();
if (authenticated) {
return redirect({
to: "/dashboard",
search: { redirect: location.href },
});
}
},
component: Login,
});
function Login() {
return (
<AuthenticationPage>
<div className="mx-auto flex w-full flex-col space-y-6 sm:w-[350px]">
<div className="flex flex-col space-y-2 text-center">
<h1 className="text-2xl font-semibold tracking-tight">Sign In</h1>
<p className="text-sm text-muted-foreground">
Enter your email & password below to login
</p>
</div>
<LoginForm />
</div>
</AuthenticationPage>
);
}

View File

@@ -0,0 +1,40 @@
import { useUserStore } from "@/lib/state";
import { createFileRoute, redirect } from "@tanstack/react-router";
import { RegisterForm } from "@/components/auth/form";
import AuthenticationPage, {
AgreementText,
} from "@/components/pages/authentication";
export const Route = createFileRoute("/register")({
beforeLoad: async ({ location }) => {
const isLoggedIn = useUserStore.getState().user !== null;
if (isLoggedIn) {
return redirect({
to: "/dashboard",
search: { redirect: location.href },
});
}
},
component: Register,
});
function Register() {
return (
<AuthenticationPage>
<div className="mx-auto flex w-full flex-col space-y-6 sm:w-[350px]">
<div className="flex flex-col space-y-2 text-center">
<h1 className="text-2xl font-semibold tracking-tight">
Create an account
</h1>
<p className="text-sm text-muted-foreground">
Enter your email below to create your account
</p>
</div>
<RegisterForm />
<AgreementText />
</div>
</AuthenticationPage>
);
}

1
frontend/src/vite-env.d.ts vendored Normal file
View File

@@ -0,0 +1 @@
/// <reference types="vite/client" />

View File

@@ -1,13 +0,0 @@
/** @type {import('tailwindcss').Config} */
module.exports = {
content: ['./src/**/*.{js,jsx,ts,tsx}', './*.html'],
darkMode: 'media',
mode: 'jit',
theme: {
extend: {
fontFamily: {
inter: ['Inter', 'sans-serif'],
},
},
},
};

View File

@@ -0,0 +1,57 @@
/** @type {import('tailwindcss').Config} */
export default {
darkMode: ["class"],
content: ["./index.html", "./src/**/*.{ts,tsx,js,jsx}"],
theme: {
extend: {
borderRadius: {
lg: "var(--radius)",
md: "calc(var(--radius) - 2px)",
sm: "calc(var(--radius) - 4px)",
},
colors: {
background: "hsl(var(--background))",
foreground: "hsl(var(--foreground))",
card: {
DEFAULT: "hsl(var(--card))",
foreground: "hsl(var(--card-foreground))",
},
popover: {
DEFAULT: "hsl(var(--popover))",
foreground: "hsl(var(--popover-foreground))",
},
primary: {
DEFAULT: "hsl(var(--primary))",
foreground: "hsl(var(--primary-foreground))",
},
secondary: {
DEFAULT: "hsl(var(--secondary))",
foreground: "hsl(var(--secondary-foreground))",
},
muted: {
DEFAULT: "hsl(var(--muted))",
foreground: "hsl(var(--muted-foreground))",
},
accent: {
DEFAULT: "hsl(var(--accent))",
foreground: "hsl(var(--accent-foreground))",
},
destructive: {
DEFAULT: "hsl(var(--destructive))",
foreground: "hsl(var(--destructive-foreground))",
},
border: "hsl(var(--border))",
input: "hsl(var(--input))",
ring: "hsl(var(--ring))",
chart: {
1: "hsl(var(--chart-1))",
2: "hsl(var(--chart-2))",
3: "hsl(var(--chart-3))",
4: "hsl(var(--chart-4))",
5: "hsl(var(--chart-5))",
},
},
},
},
plugins: [require("tailwindcss-animate")],
};

View File

@@ -0,0 +1,30 @@
{
"compilerOptions": {
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
"target": "ES2020",
"useDefineForClassFields": true,
"lib": ["ES2020", "DOM", "DOM.Iterable"],
"module": "ESNext",
"skipLibCheck": true,
/* Bundler mode */
"moduleResolution": "Bundler",
"allowImportingTsExtensions": true,
"isolatedModules": true,
"moduleDetection": "force",
"noEmit": true,
"jsx": "react-jsx",
/* Linting */
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"noFallthroughCasesInSwitch": true,
"noUncheckedSideEffectImports": true,
"baseUrl": ".",
"paths": {
"@/*": ["./src/*"]
}
},
"include": ["src"]
}

View File

@@ -1,33 +1,13 @@
{
"files": [],
"references": [
{ "path": "./tsconfig.app.json" },
{ "path": "./tsconfig.node.json" }
],
"compilerOptions": {
"allowJs": true,
"allowImportingTsExtensions": true,
"esModuleInterop": true,
"forceConsistentCasingInFileNames": true,
"incremental": true,
"isolatedModules": true,
"jsx": "preserve",
"lib": ["dom", "dom.iterable", "esnext"],
"module": "nodenext",
"moduleResolution": "nodenext",
"noEmit": true,
"noImplicitOverride": true,
"noUnusedLocals": true,
"resolveJsonModule": true,
"skipLibCheck": true,
"strict": true,
"target": "es2017",
"types": ["vite/client"]
},
"exclude": ["node_modules"],
"include": ["**/*.ts", "**/*.tsx"],
"ts-node": {
"transpileOnly": true,
"transpiler": "ts-node/transpilers/swc",
"files": true,
"compilerOptions": {
"module": "esnext",
"isolatedModules": false
"baseUrl": ".",
"paths": {
"@/*": ["./src/*"]
}
}
}

View File

@@ -0,0 +1,28 @@
{
"compilerOptions": {
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo",
"target": "ES2022",
"lib": ["ES2023"],
"module": "ESNext",
"skipLibCheck": true,
/* Bundler mode */
"moduleResolution": "Bundler",
"allowImportingTsExtensions": true,
"isolatedModules": true,
"moduleDetection": "force",
"noEmit": true,
/* Linting */
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"noFallthroughCasesInSwitch": true,
"noUncheckedSideEffectImports": true,
"baseUrl": ".",
"paths": {
"@/*": ["./src/*"]
}
},
"include": ["vite.config.ts"]
}

View File

@@ -1,6 +1,14 @@
import react from '@vitejs/plugin-react';
import { defineConfig } from 'vite';
import { TanStackRouterVite } from "@tanstack/router-plugin/vite";
import react from "@vitejs/plugin-react-swc";
import path from "path";
import { defineConfig } from "vite";
// https://vite.dev/config/
export default defineConfig({
plugins: [react()],
plugins: [TanStackRouterVite(), react()],
resolve: {
alias: {
"@": path.resolve(__dirname, "./src"),
},
},
});