mirror of
https://github.com/Xevion/linkpulse.git
synced 2025-12-06 15:15:34 -06:00
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:
73
.github/workflows/checklist.yaml
vendored
Normal file
73
.github/workflows/checklist.yaml
vendored
Normal 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
83
.github/workflows/test.yaml
vendored
Normal 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
163
.gitignore
vendored
@@ -1,164 +1 @@
|
|||||||
.env
|
.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
51
.vscode/settings.json
vendored
@@ -1,18 +1,65 @@
|
|||||||
{
|
{
|
||||||
"cSpell.words": [
|
"cSpell.words": [
|
||||||
"apscheduler",
|
"apscheduler",
|
||||||
|
"Avenir",
|
||||||
|
"backref",
|
||||||
"bpython",
|
"bpython",
|
||||||
"Callsite",
|
"Callsite",
|
||||||
|
"clsx",
|
||||||
"excepthook",
|
"excepthook",
|
||||||
|
"httpx",
|
||||||
|
"humanfs",
|
||||||
|
"humanwhocodes",
|
||||||
"inmemory",
|
"inmemory",
|
||||||
|
"jridgewell",
|
||||||
"linkpulse",
|
"linkpulse",
|
||||||
"migratehistory",
|
"migratehistory",
|
||||||
"Nixpacks",
|
"Nixpacks",
|
||||||
|
"nkzw",
|
||||||
|
"nocheck",
|
||||||
"ORJSON",
|
"ORJSON",
|
||||||
|
"pacakges",
|
||||||
|
"pext",
|
||||||
|
"postcss",
|
||||||
|
"pwdlib",
|
||||||
|
"pyproject",
|
||||||
|
"pytest",
|
||||||
"pytz",
|
"pytz",
|
||||||
|
"riscv",
|
||||||
|
"rollup",
|
||||||
|
"rtype",
|
||||||
|
"shadcn",
|
||||||
"starlette",
|
"starlette",
|
||||||
"structlog",
|
"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"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
31
CHANGELOG.md
31
CHANGELOG.md
@@ -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/),
|
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).
|
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
|
## [0.2.2] - 2024-11-01
|
||||||
|
|
||||||
### Added
|
### Added
|
||||||
|
|||||||
8
RELEASE_CHECKLIST.md
Normal file
8
RELEASE_CHECKLIST.md
Normal 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
165
backend/.gitignore
vendored
Normal 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/
|
||||||
0
backend/linkpulse/__init__.py
Normal file
0
backend/linkpulse/__init__.py
Normal file
@@ -16,17 +16,18 @@ setup_logging()
|
|||||||
|
|
||||||
import os
|
import os
|
||||||
import sys
|
import sys
|
||||||
import structlog
|
|
||||||
|
|
||||||
|
import structlog
|
||||||
|
|
||||||
logger = structlog.get_logger()
|
logger = structlog.get_logger()
|
||||||
|
|
||||||
|
|
||||||
def main(*args):
|
def main(*args: str) -> None:
|
||||||
"""
|
"""Primary entrypoint for the LinkPulse application
|
||||||
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.
|
||||||
- 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":
|
if args[0] == "serve":
|
||||||
from linkpulse.utilities import is_development
|
from linkpulse.utilities import is_development
|
||||||
from uvicorn import run
|
from uvicorn import run
|
||||||
@@ -57,9 +58,9 @@ def main(*args):
|
|||||||
|
|
||||||
# import most useful objects, models, and functions
|
# import most useful objects, models, and functions
|
||||||
lp = linkpulse # alias
|
lp = linkpulse # alias
|
||||||
from linkpulse.utilities import get_db
|
|
||||||
from linkpulse.app import app
|
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()
|
db = get_db()
|
||||||
|
|
||||||
|
|||||||
@@ -1,120 +1,53 @@
|
|||||||
import random
|
|
||||||
from collections import defaultdict
|
|
||||||
from contextlib import asynccontextmanager
|
from contextlib import asynccontextmanager
|
||||||
from dataclasses import dataclass, field
|
|
||||||
from datetime import datetime, timezone
|
|
||||||
from typing import AsyncIterator
|
from typing import AsyncIterator
|
||||||
|
|
||||||
import human_readable
|
|
||||||
import pytz
|
|
||||||
import structlog
|
import structlog
|
||||||
from apscheduler.schedulers.background import BackgroundScheduler # type: ignore
|
from apscheduler.schedulers.background import BackgroundScheduler # type: ignore
|
||||||
from apscheduler.triggers.interval import IntervalTrigger # type: ignore
|
from apscheduler.triggers.interval import IntervalTrigger # type: ignore
|
||||||
from asgi_correlation_id import CorrelationIdMiddleware
|
from asgi_correlation_id import CorrelationIdMiddleware
|
||||||
from dotenv import load_dotenv
|
from dotenv import load_dotenv
|
||||||
from fastapi import FastAPI, Request, Response, status
|
from fastapi import FastAPI
|
||||||
from fastapi.responses import ORJSONResponse
|
from fastapi.responses import ORJSONResponse
|
||||||
from fastapi_cache import FastAPICache
|
from fastapi_cache import FastAPICache
|
||||||
from fastapi_cache.backends.inmemory import InMemoryBackend
|
from fastapi_cache.backends.inmemory import InMemoryBackend
|
||||||
from fastapi_cache.decorator import cache
|
|
||||||
from linkpulse.logging import setup_logging
|
from linkpulse.logging import setup_logging
|
||||||
from linkpulse.middleware import LoggingMiddleware
|
from linkpulse.middleware import LoggingMiddleware
|
||||||
from linkpulse.utilities import get_db, get_ip, hide_ip, is_development
|
from linkpulse.utilities import get_db, is_development
|
||||||
from psycopg2.extras import execute_values
|
|
||||||
|
|
||||||
load_dotenv(dotenv_path=".env")
|
load_dotenv(dotenv_path=".env")
|
||||||
|
|
||||||
from linkpulse import models, responses # type: ignore
|
from linkpulse import models # type: ignore
|
||||||
|
|
||||||
db = get_db()
|
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 = BackgroundScheduler()
|
||||||
scheduler.add_job(flush_ips, IntervalTrigger(seconds=5))
|
|
||||||
|
|
||||||
|
|
||||||
@asynccontextmanager
|
@asynccontextmanager
|
||||||
async def lifespan(_: FastAPI) -> AsyncIterator[None]:
|
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
|
# Connect to database, ensure specific tables exist
|
||||||
db.connect()
|
db.connect()
|
||||||
db.create_tables([models.IPAddress])
|
db.create_tables([models.User, models.Session])
|
||||||
|
|
||||||
# Delete all randomly generated IP addresses
|
FastAPICache.init(backend=InMemoryBackend(), prefix="fastapi-cache", cache_status_header="X-Cache")
|
||||||
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)
|
|
||||||
|
|
||||||
scheduler.start()
|
scheduler.start()
|
||||||
|
|
||||||
yield
|
yield
|
||||||
|
|
||||||
scheduler.shutdown()
|
scheduler.shutdown()
|
||||||
flush_ips()
|
|
||||||
|
|
||||||
if not db.is_closed():
|
if not db.is_closed():
|
||||||
db.close()
|
db.close()
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
from linkpulse.routers import auth, misc
|
||||||
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))
|
|
||||||
|
|
||||||
|
|
||||||
|
# TODO: Apply migrations on startup in production environments
|
||||||
app = FastAPI(lifespan=lifespan, default_response_class=ORJSONResponse)
|
app = FastAPI(lifespan=lifespan, default_response_class=ORJSONResponse)
|
||||||
|
app.include_router(auth.router)
|
||||||
|
app.include_router(misc.router)
|
||||||
|
|
||||||
setup_logging()
|
setup_logging()
|
||||||
|
|
||||||
@@ -123,79 +56,19 @@ logger = structlog.get_logger()
|
|||||||
if is_development:
|
if is_development:
|
||||||
from fastapi.middleware.cors import CORSMiddleware
|
from fastapi.middleware.cors import CORSMiddleware
|
||||||
|
|
||||||
|
origins = [
|
||||||
|
"http://localhost:8080",
|
||||||
|
"http://localhost:5173",
|
||||||
|
]
|
||||||
|
|
||||||
app.add_middleware(
|
app.add_middleware(
|
||||||
CORSMiddleware,
|
CORSMiddleware,
|
||||||
allow_origins=[
|
allow_origins=origins,
|
||||||
"http://localhost",
|
|
||||||
"http://localhost:5173",
|
|
||||||
],
|
|
||||||
allow_credentials=True,
|
allow_credentials=True,
|
||||||
allow_methods=["*"],
|
allow_methods=["*"],
|
||||||
allow_headers=["*"],
|
allow_headers=["*"],
|
||||||
)
|
)
|
||||||
|
logger.info("CORS Enabled", origins=origins)
|
||||||
|
|
||||||
app.add_middleware(LoggingMiddleware)
|
app.add_middleware(LoggingMiddleware)
|
||||||
app.add_middleware(CorrelationIdMiddleware)
|
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
|
|
||||||
]
|
|
||||||
}
|
|
||||||
|
|||||||
75
backend/linkpulse/dependencies.py
Normal file
75
backend/linkpulse/dependencies.py
Normal 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
|
||||||
@@ -31,12 +31,12 @@ def drop_color_message_key(_: Any, __: Any, event_dict: EventDict) -> EventDict:
|
|||||||
return event_dict
|
return event_dict
|
||||||
|
|
||||||
|
|
||||||
def setup_logging(
|
def setup_logging(json_logs: Optional[bool] = None, log_level: Optional[str] = None) -> None:
|
||||||
json_logs: Optional[bool] = None, log_level: Optional[str] = None
|
|
||||||
) -> None:
|
|
||||||
# Pull from environment variables, apply defaults if not set
|
# Pull from environment variables, apply defaults if not set
|
||||||
json_logs = json_logs or os.getenv("LOG_JSON_FORMAT", "true").lower() == "true"
|
if json_logs is None:
|
||||||
log_level = log_level or os.getenv("LOG_LEVEL", "INFO")
|
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):
|
def flatten(n):
|
||||||
"""
|
"""
|
||||||
@@ -158,8 +158,6 @@ def setup_logging(
|
|||||||
sys.__excepthook__(exc_type, exc_value, exc_traceback)
|
sys.__excepthook__(exc_type, exc_value, exc_traceback)
|
||||||
return
|
return
|
||||||
|
|
||||||
root_logger.error(
|
root_logger.error("Uncaught exception", exc_info=(exc_type, exc_value, exc_traceback))
|
||||||
"Uncaught exception", exc_info=(exc_type, exc_value, exc_traceback)
|
|
||||||
)
|
|
||||||
|
|
||||||
sys.excepthook = handle_exception
|
sys.excepthook = handle_exception
|
||||||
|
|||||||
@@ -36,11 +36,7 @@ class ExtendedRouter(Router):
|
|||||||
try:
|
try:
|
||||||
modules = models
|
modules = models
|
||||||
if isinstance(module, bool):
|
if isinstance(module, bool):
|
||||||
modules = [
|
modules = [m for _, m, ispkg in pkgutil.iter_modules([f"{router.CURDIR}"]) if ispkg]
|
||||||
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)]
|
models = [m for module in modules for m in router.load_models(module)]
|
||||||
|
|
||||||
except ImportError:
|
except ImportError:
|
||||||
@@ -48,7 +44,7 @@ class ExtendedRouter(Router):
|
|||||||
return None
|
return None
|
||||||
|
|
||||||
if self.ignore:
|
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:
|
for migration in self.diff:
|
||||||
self.run_one(migration, self.migrator, fake=True)
|
self.run_one(migration, self.migrator, fake=True)
|
||||||
@@ -91,9 +87,7 @@ def main(*args: str) -> None:
|
|||||||
diff = router.diff
|
diff = router.diff
|
||||||
|
|
||||||
if len(diff) == 0:
|
if len(diff) == 0:
|
||||||
logger.info(
|
logger.info("No migrations found, no pending migrations to apply. Creating initial migration.")
|
||||||
"No migrations found, no pending migrations to apply. Creating initial migration."
|
|
||||||
)
|
|
||||||
|
|
||||||
migration = router.create("initial", auto=target_models)
|
migration = router.create("initial", auto=target_models)
|
||||||
if not migration:
|
if not migration:
|
||||||
@@ -107,13 +101,9 @@ def main(*args: str) -> None:
|
|||||||
logger.info(
|
logger.info(
|
||||||
"Note: Selecting a migration will apply all migrations up to and including the selected migration."
|
"Note: Selecting a migration will apply all migrations up to and including the selected migration."
|
||||||
)
|
)
|
||||||
logger.info(
|
logger.info("e.g. Applying 004 while only 001 is applied would apply 002, 003, and 004.")
|
||||||
"e.g. Applying 004 while only 001 is applied would apply 002, 003, and 004."
|
|
||||||
)
|
|
||||||
|
|
||||||
choice = questionary.select(
|
choice = questionary.select("Select highest migration to apply:", choices=diff).ask()
|
||||||
"Select highest migration to apply:", choices=diff
|
|
||||||
).ask()
|
|
||||||
if choice is None:
|
if choice is None:
|
||||||
logger.warning(
|
logger.warning(
|
||||||
"For safety reasons, you won't be able to create migrations without applying the pending ones."
|
"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}")
|
logger.info(f"Migration created: {migration}")
|
||||||
|
|
||||||
if len(router.diff) == 1:
|
if len(router.diff) == 1:
|
||||||
if questionary.confirm(
|
if questionary.confirm("Do you want to apply this migration immediately?").ask():
|
||||||
"Do you want to apply this migration immediately?"
|
|
||||||
).ask():
|
|
||||||
router.run(migration)
|
router.run(migration)
|
||||||
logger.info("Done.")
|
logger.info("Done.")
|
||||||
logger.warning(
|
logger.warning("!!! Commit and push this migration file immediately!")
|
||||||
"!!! Commit and push this migration file immediately!"
|
|
||||||
)
|
|
||||||
else:
|
else:
|
||||||
raise RuntimeError(
|
raise RuntimeError(
|
||||||
"Changes anticipated with show() but no migration created with create(), model definition may have reverted."
|
"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:
|
else:
|
||||||
logger.info("No database changes detected.")
|
logger.info("No database changes detected.")
|
||||||
|
|
||||||
if len(current) > 5:
|
migration_squash_threshold: int = 15
|
||||||
|
if len(current) > migration_squash_threshold:
|
||||||
if questionary.confirm(
|
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,
|
default=False,
|
||||||
).ask():
|
).ask():
|
||||||
logger.info("Merging migrations...")
|
logger.info("Merging migrations...")
|
||||||
|
|||||||
@@ -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")
|
||||||
@@ -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')
|
||||||
63
backend/linkpulse/migrations/006_add_session_constraints.py
Normal file
63
backend/linkpulse/migrations/006_add_session_constraints.py
Normal 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",
|
||||||
|
)
|
||||||
@@ -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))
|
||||||
@@ -3,10 +3,14 @@ This module defines the database models for the LinkPulse backend.
|
|||||||
It also provides a base model with database connection details.
|
It also provides a base model with database connection details.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
import datetime
|
||||||
|
import secrets
|
||||||
from os import getenv
|
from os import getenv
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
import structlog
|
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
|
from playhouse.db_url import connect
|
||||||
|
|
||||||
logger = structlog.get_logger()
|
logger = structlog.get_logger()
|
||||||
@@ -26,7 +30,80 @@ class BaseModel(Model):
|
|||||||
database = connect(url=_get_database_url())
|
database = connect(url=_get_database_url())
|
||||||
|
|
||||||
|
|
||||||
class IPAddress(BaseModel):
|
class User(BaseModel):
|
||||||
ip = CharField(primary_key=True)
|
id = AutoField(primary_key=True)
|
||||||
last_seen = DateTimeField() # timezone naive
|
# arbitrary max length, but statistically reasonable and limits UI concerns/abuse cases
|
||||||
count = IntegerField(default=0)
|
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()
|
||||||
|
|||||||
0
backend/linkpulse/routers/__init__.py
Normal file
0
backend/linkpulse/routers/__init__.py
Normal file
164
backend/linkpulse/routers/auth.py
Normal file
164
backend/linkpulse/routers/auth.py
Normal 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)
|
||||||
33
backend/linkpulse/routers/misc.py
Normal file
33
backend/linkpulse/routers/misc.py
Normal 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}
|
||||||
0
backend/linkpulse/tests/__init__.py
Normal file
0
backend/linkpulse/tests/__init__.py
Normal file
15
backend/linkpulse/tests/random.py
Normal file
15
backend/linkpulse/tests/random.py
Normal 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"
|
||||||
16
backend/linkpulse/tests/test_app.py
Normal file
16
backend/linkpulse/tests/test_app.py
Normal 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
|
||||||
82
backend/linkpulse/tests/test_auth.py
Normal file
82
backend/linkpulse/tests/test_auth.py
Normal 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
|
||||||
22
backend/linkpulse/tests/test_depends.py
Normal file
22
backend/linkpulse/tests/test_depends.py
Normal 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
|
||||||
78
backend/linkpulse/tests/test_session.py
Normal file
78
backend/linkpulse/tests/test_session.py
Normal 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
|
||||||
12
backend/linkpulse/tests/test_user.py
Normal file
12
backend/linkpulse/tests/test_user.py
Normal 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"))
|
||||||
6
backend/linkpulse/tests/test_utilities.py
Normal file
6
backend/linkpulse/tests/test_utilities.py
Normal 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
|
||||||
@@ -3,8 +3,10 @@ This module provides utility functions for database connection, string manipulat
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
import os
|
import os
|
||||||
|
from datetime import datetime
|
||||||
from typing import Optional
|
from typing import Optional
|
||||||
|
|
||||||
|
import pytz
|
||||||
from fastapi import Request
|
from fastapi import Request
|
||||||
from peewee import PostgresqlDatabase
|
from peewee import PostgresqlDatabase
|
||||||
|
|
||||||
@@ -12,6 +14,13 @@ from peewee import PostgresqlDatabase
|
|||||||
is_development = os.getenv("ENVIRONMENT") == "development"
|
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:
|
def get_db() -> PostgresqlDatabase:
|
||||||
"""
|
"""
|
||||||
Acquires the database connector from the BaseModel class.
|
Acquires the database connector from the BaseModel class.
|
||||||
|
|||||||
564
backend/poetry.lock
generated
564
backend/poetry.lock
generated
@@ -70,6 +70,63 @@ tornado = ["tornado (>=4.3)"]
|
|||||||
twisted = ["twisted"]
|
twisted = ["twisted"]
|
||||||
zookeeper = ["kazoo"]
|
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]]
|
[[package]]
|
||||||
name = "asgi-correlation-id"
|
name = "asgi-correlation-id"
|
||||||
version = "4.3.4"
|
version = "4.3.4"
|
||||||
@@ -140,6 +197,85 @@ files = [
|
|||||||
{file = "certifi-2024.8.30.tar.gz", hash = "sha256:bec941d2aa8195e248a60b31ff9f0558284cf01a52591ceda73ea9afffd69fd9"},
|
{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]]
|
[[package]]
|
||||||
name = "charset-normalizer"
|
name = "charset-normalizer"
|
||||||
version = "3.4.0"
|
version = "3.4.0"
|
||||||
@@ -279,6 +415,80 @@ files = [
|
|||||||
{file = "colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44"},
|
{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]]
|
[[package]]
|
||||||
name = "curtsies"
|
name = "curtsies"
|
||||||
version = "0.4.2"
|
version = "0.4.2"
|
||||||
@@ -339,6 +549,72 @@ files = [
|
|||||||
{file = "cwcwidth-0.1.9.tar.gz", hash = "sha256:f19d11a0148d4a8cacd064c96e93bca8ce3415a186ae8204038f45e108db76b8"},
|
{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]]
|
[[package]]
|
||||||
name = "fastapi"
|
name = "fastapi"
|
||||||
version = "0.100.0"
|
version = "0.100.0"
|
||||||
@@ -504,6 +780,52 @@ files = [
|
|||||||
{file = "hpack-4.0.0.tar.gz", hash = "sha256:fc41de0c63e687ebffde81187a948221294896f6bdc0ae2312708df339430095"},
|
{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]]
|
[[package]]
|
||||||
name = "human-readable"
|
name = "human-readable"
|
||||||
version = "1.3.4"
|
version = "1.3.4"
|
||||||
@@ -563,6 +885,36 @@ files = [
|
|||||||
[package.extras]
|
[package.extras]
|
||||||
all = ["flake8 (>=7.1.1)", "mypy (>=1.11.2)", "pytest (>=8.3.2)", "ruff (>=0.6.2)"]
|
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]]
|
[[package]]
|
||||||
name = "jinxed"
|
name = "jinxed"
|
||||||
version = "1.3.0"
|
version = "1.3.0"
|
||||||
@@ -577,6 +929,35 @@ files = [
|
|||||||
[package.dependencies]
|
[package.dependencies]
|
||||||
ansicon = {version = "*", markers = "platform_system == \"Windows\""}
|
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]]
|
[[package]]
|
||||||
name = "memory-profiler"
|
name = "memory-profiler"
|
||||||
version = "0.61.0"
|
version = "0.61.0"
|
||||||
@@ -793,6 +1174,21 @@ tzdata = ">=2020.1"
|
|||||||
[package.extras]
|
[package.extras]
|
||||||
test = ["time-machine (>=2.6.0)"]
|
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]]
|
[[package]]
|
||||||
name = "priority"
|
name = "priority"
|
||||||
version = "2.0.0"
|
version = "2.0.0"
|
||||||
@@ -864,6 +1260,35 @@ files = [
|
|||||||
{file = "psycopg2-2.9.10.tar.gz", hash = "sha256:12ec0b40b0273f95296233e8750441339298e6a572f7039da5b260e3c8b60e11"},
|
{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]]
|
[[package]]
|
||||||
name = "pydantic"
|
name = "pydantic"
|
||||||
version = "2.9.2"
|
version = "2.9.2"
|
||||||
@@ -1002,6 +1427,64 @@ files = [
|
|||||||
[package.extras]
|
[package.extras]
|
||||||
windows-terminal = ["colorama (>=0.4.6)"]
|
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]]
|
[[package]]
|
||||||
name = "python-dateutil"
|
name = "python-dateutil"
|
||||||
version = "2.9.0.post0"
|
version = "2.9.0.post0"
|
||||||
@@ -1261,6 +1744,85 @@ files = [
|
|||||||
{file = "wcwidth-0.2.13.tar.gz", hash = "sha256:72ea0c06399eb286d978fdedb6923a9eb47e1c486ce63e9b4e64fc18303972b5"},
|
{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]]
|
[[package]]
|
||||||
name = "wsproto"
|
name = "wsproto"
|
||||||
version = "1.2.0"
|
version = "1.2.0"
|
||||||
@@ -1278,4 +1840,4 @@ h11 = ">=0.9.0,<1"
|
|||||||
[metadata]
|
[metadata]
|
||||||
lock-version = "2.0"
|
lock-version = "2.0"
|
||||||
python-versions = "^3.12"
|
python-versions = "^3.12"
|
||||||
content-hash = "6cab1d930ad04560919f18c779fe4f9f2d28aba84d4385ed01aff1c876e453b6"
|
content-hash = "9dff2a47bb95e65f15616bb82926eb81d418456c5ec60db46dfe06056c643e31"
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
[tool.poetry]
|
[tool.poetry]
|
||||||
name = "linkpulse"
|
name = "linkpulse"
|
||||||
version = "0.2.2"
|
version = "0.3.0"
|
||||||
description = ""
|
description = ""
|
||||||
authors = ["Xevion <xevion@xevion.dev>"]
|
authors = ["Xevion <xevion@xevion.dev>"]
|
||||||
license = "GNU GPL v3"
|
license = "GNU GPL v3"
|
||||||
@@ -28,13 +28,23 @@ uvicorn = "^0.32.0"
|
|||||||
asgi-correlation-id = "^4.3.4"
|
asgi-correlation-id = "^4.3.4"
|
||||||
orjson = "^3.10.10"
|
orjson = "^3.10.10"
|
||||||
hypercorn = "^0.17.3"
|
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]
|
[tool.poetry.group.dev.dependencies]
|
||||||
memory-profiler = "^0.61.0"
|
memory-profiler = "^0.61.0"
|
||||||
bpython = "^0.24"
|
bpython = "^0.24"
|
||||||
types-pytz = "^2024.2.0.20241003"
|
types-pytz = "^2024.2.0.20241003"
|
||||||
|
pytest = "^8.3.3"
|
||||||
|
httpx = "^0.27.2"
|
||||||
|
pytest-cov = "^6.0.0"
|
||||||
|
|
||||||
[build-system]
|
[build-system]
|
||||||
requires = ["poetry-core"]
|
requires = ["poetry-core"]
|
||||||
build-backend = "poetry.core.masonry.api"
|
build-backend = "poetry.core.masonry.api"
|
||||||
|
|
||||||
|
[tool.black]
|
||||||
|
line-length = 110
|
||||||
4
backend/pytest.ini
Normal file
4
backend/pytest.ini
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
[pytest]
|
||||||
|
filterwarnings =
|
||||||
|
ignore::DeprecationWarning
|
||||||
|
addopts = --show-capture=stderr
|
||||||
@@ -8,7 +8,16 @@ fi
|
|||||||
|
|
||||||
# Default to development mode if not defined
|
# Default to development mode if not defined
|
||||||
export ENVIRONMENT=${ENVIRONMENT:-development}
|
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
|
# Check if Railway CLI is available
|
||||||
RAILWAY_AVAILABLE=false
|
RAILWAY_AVAILABLE=false
|
||||||
@@ -43,11 +52,11 @@ if $RAILWAY_AVAILABLE; then
|
|||||||
fi
|
fi
|
||||||
|
|
||||||
if $DATABASE_DEFINED; then
|
if $DATABASE_DEFINED; then
|
||||||
$COMMAND $@
|
$COMMAND
|
||||||
else
|
else
|
||||||
if $RAILWAY_AVAILABLE; then
|
if $RAILWAY_AVAILABLE; then
|
||||||
if $PROJECT_LINKED; 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
|
else
|
||||||
echo "error: Railway project not linked."
|
echo "error: Railway project not linked."
|
||||||
echo "Run 'railway link' to link the project."
|
echo "Run 'railway link' to link the project."
|
||||||
|
|||||||
30
frontend/.gitignore
vendored
30
frontend/.gitignore
vendored
@@ -1,6 +1,24 @@
|
|||||||
.eslintcache
|
# Logs
|
||||||
.pnpm-debug.log
|
logs
|
||||||
node_modules/
|
*.log
|
||||||
coverage/
|
npm-debug.log*
|
||||||
dist/
|
yarn-debug.log*
|
||||||
tsconfig.tsbuildinfo
|
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?
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
coverage/
|
coverage/
|
||||||
dist/
|
dist/
|
||||||
pnpm-lock.yaml
|
pnpm-lock.yaml
|
||||||
|
src/routeTree.gen.ts
|
||||||
|
|||||||
4
frontend/.prettierrc
Normal file
4
frontend/.prettierrc
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
{
|
||||||
|
"tabWidth": 2,
|
||||||
|
"useTabs": false
|
||||||
|
}
|
||||||
@@ -16,7 +16,10 @@
|
|||||||
# site block, listens on the $PORT environment variable, automatically assigned by railway
|
# site block, listens on the $PORT environment variable, automatically assigned by railway
|
||||||
:{$PORT} {
|
:{$PORT} {
|
||||||
respond /health 200
|
respond /health 200
|
||||||
encode gzip
|
encode {
|
||||||
|
zstd fastest
|
||||||
|
gzip 3
|
||||||
|
}
|
||||||
|
|
||||||
log {
|
log {
|
||||||
# access logs
|
# access logs
|
||||||
|
|||||||
18
frontend/Caddyfile.development
Normal file
18
frontend/Caddyfile.development
Normal 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
21
frontend/components.json
Normal 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
28
frontend/eslint.config.js
Normal 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 },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
);
|
||||||
@@ -1,16 +1,13 @@
|
|||||||
<!doctype html>
|
<!doctype html>
|
||||||
<html lang="en">
|
<html lang="en">
|
||||||
<head>
|
<head>
|
||||||
<meta charset="utf-8" />
|
<meta charset="UTF-8" />
|
||||||
<meta
|
<link rel="icon" type="image/svg+xml" href="/linkpulse.svg" />
|
||||||
name="viewport"
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
content="width=device-width, initial-scale=1, shrink-to-fit=no"
|
<title>Linkpulse</title>
|
||||||
/>
|
|
||||||
<meta name="theme-color" content="#fff" />
|
|
||||||
<title>App</title>
|
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div id="root" class="min-w-full min-h-screen"></div>
|
<div id="root"></div>
|
||||||
<script src="/src/index.tsx" type="module"></script>
|
<script type="module" src="/src/main.tsx"></script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
@@ -1,58 +1,52 @@
|
|||||||
{
|
{
|
||||||
"name": "linkpulse",
|
"name": "linkpulse",
|
||||||
"version": "0.0.1",
|
"private": true,
|
||||||
"author": "Xevion <xevion@xevion.dev>",
|
"version": "0.3.0",
|
||||||
"type": "module",
|
"author": {
|
||||||
"engines": {
|
"name": "Xevion",
|
||||||
"node": ">=22.0.0",
|
"url": "https://xevion.dev",
|
||||||
"pnpm": ">=9.0.0"
|
"email": "xevion@xevion.dev"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"type": "module",
|
||||||
"react": "^18.3.1",
|
"scripts": {
|
||||||
"react-dom": "^18.3.1"
|
"dev": "vite",
|
||||||
},
|
"build": "tsc -b && vite build",
|
||||||
"devDependencies": {
|
"lint": "eslint .",
|
||||||
"@ianvs/prettier-plugin-sort-imports": "^4.2.1",
|
"preview": "vite preview"
|
||||||
"@nkzw/eslint-config": "^1.16.0",
|
},
|
||||||
"@swc/core": "^1.6.5",
|
"dependencies": {
|
||||||
"@types/node": "^20.14.8",
|
"@radix-ui/react-label": "^2.1.0",
|
||||||
"@types/react": "^18.3.3",
|
"@radix-ui/react-slot": "^1.1.0",
|
||||||
"@types/react-dom": "^18.3.0",
|
"@tanstack/react-router": "^1.81.1",
|
||||||
"@vitejs/plugin-react": "^4.3.1",
|
"class-variance-authority": "^0.7.0",
|
||||||
"autoprefixer": "^10.4.19",
|
"clsx": "^2.1.1",
|
||||||
"eslint": "^8.57.0",
|
"lucide-react": "^0.456.0",
|
||||||
"npm-run-all2": "^6.2.0",
|
"react": "^18.3.1",
|
||||||
"postcss": "^8.4.38",
|
"react-dom": "^18.3.1",
|
||||||
"prettier": "^3.3.2",
|
"tailwind-merge": "^2.5.4",
|
||||||
"prettier-plugin-tailwindcss": "^0.6.5",
|
"tailwindcss-animate": "^1.0.7",
|
||||||
"tailwindcss": "^3.4.4",
|
"true-myth": "^8.0.1",
|
||||||
"ts-node": "^10.9.2",
|
"zustand": "^5.0.1"
|
||||||
"typescript": "^5.5.2",
|
},
|
||||||
"vite": "^5.3.1",
|
"devDependencies": {
|
||||||
"vitest": "^1.6.0"
|
"@eslint/js": "^9.13.0",
|
||||||
},
|
"@tanstack/router-devtools": "^1.81.1",
|
||||||
"scripts": {
|
"@tanstack/router-plugin": "^1.79.0",
|
||||||
"preinstall": "command -v git >/dev/null 2>&1 && git config core.hooksPath git-hooks || true",
|
"@types/node": "^22.9.0",
|
||||||
"build": "vite build",
|
"@types/react": "^18.3.12",
|
||||||
"dev:update-deps": "rm -rf pnpm-lock.yaml node_modules/ **/node_modules && pnpm install",
|
"@types/react-dom": "^18.3.1",
|
||||||
"dev": "vite dev",
|
"@vitejs/plugin-react-swc": "^3.5.0",
|
||||||
"format": "prettier --write .",
|
"autoprefixer": "^10.4.20",
|
||||||
"lint:format": "prettier --cache --check .",
|
"eslint": "^9.13.0",
|
||||||
"lint": "eslint --cache .",
|
"eslint-plugin-react-hooks": "^5.0.0",
|
||||||
"test": "npm-run-all --parallel tsc:check vitest:run lint lint:format",
|
"eslint-plugin-react-refresh": "^0.4.14",
|
||||||
"tsc:check": "tsc",
|
"globals": "^15.11.0",
|
||||||
"vitest:run": "vitest run"
|
"postcss": "^8.4.47",
|
||||||
},
|
"prettier": "^3.3.3",
|
||||||
"browserslist": [
|
"tailwindcss": "^3.4.14",
|
||||||
">0.2%",
|
"typescript": "~5.6.2",
|
||||||
"not dead",
|
"typescript-eslint": "^8.11.0",
|
||||||
"not op_mini all"
|
"vite": "^5.4.10",
|
||||||
],
|
"vitest": "^2.1.4"
|
||||||
"pnpm": {
|
|
||||||
"updateConfig": {
|
|
||||||
"ignoreDependencies": [
|
|
||||||
"eslint"
|
|
||||||
]
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|||||||
4107
frontend/pnpm-lock.yaml
generated
4107
frontend/pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
@@ -1,6 +1,6 @@
|
|||||||
module.exports = {
|
export default {
|
||||||
plugins: {
|
plugins: {
|
||||||
autoprefixer: {},
|
|
||||||
tailwindcss: {},
|
tailwindcss: {},
|
||||||
|
autoprefixer: {},
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
export default {
|
export default {
|
||||||
plugins: [
|
plugins: [
|
||||||
'@ianvs/prettier-plugin-sort-imports',
|
"@ianvs/prettier-plugin-sort-imports",
|
||||||
'prettier-plugin-tailwindcss',
|
"prettier-plugin-tailwindcss",
|
||||||
],
|
],
|
||||||
singleQuote: true,
|
singleQuote: true,
|
||||||
};
|
};
|
||||||
|
|||||||
5
frontend/public/linkpulse.svg
Normal file
5
frontend/public/linkpulse.svg
Normal 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 |
@@ -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);
|
|
||||||
}
|
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
import { expect, test } from 'vitest';
|
import { expect, test } from "vitest";
|
||||||
|
|
||||||
test('App', () => {
|
test("App", () => {
|
||||||
expect(1 + 1).toBe(2);
|
expect(1 + 1).toBe(2);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
125
frontend/src/components/auth/form.tsx
Normal file
125
frontend/src/components/auth/form.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
157
frontend/src/components/icons.tsx
Normal file
157
frontend/src/components/icons.tsx
Normal 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>
|
||||||
|
),
|
||||||
|
};
|
||||||
67
frontend/src/components/pages/authentication.tsx
Normal file
67
frontend/src/components/pages/authentication.tsx
Normal 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">
|
||||||
|
“This library has saved me countless hours of work and
|
||||||
|
helped me deliver stunning designs to my clients faster than
|
||||||
|
ever before.”
|
||||||
|
</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>
|
||||||
|
);
|
||||||
|
}
|
||||||
57
frontend/src/components/ui/button.tsx
Normal file
57
frontend/src/components/ui/button.tsx
Normal 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 };
|
||||||
22
frontend/src/components/ui/input.tsx
Normal file
22
frontend/src/components/ui/input.tsx
Normal 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 };
|
||||||
24
frontend/src/components/ui/label.tsx
Normal file
24
frontend/src/components/ui/label.tsx
Normal 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
137
frontend/src/index.css
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
81
frontend/src/lib/auth.ts
Normal 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
21
frontend/src/lib/state.ts
Normal 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 }),
|
||||||
|
}));
|
||||||
6
frontend/src/lib/utils.ts
Normal file
6
frontend/src/lib/utils.ts
Normal 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
28
frontend/src/main.tsx
Normal 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>,
|
||||||
|
);
|
||||||
|
}
|
||||||
162
frontend/src/routeTree.gen.ts
Normal file
162
frontend/src/routeTree.gen.ts
Normal 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 */
|
||||||
11
frontend/src/routes/__root.tsx
Normal file
11
frontend/src/routes/__root.tsx
Normal 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 />
|
||||||
|
</>
|
||||||
|
),
|
||||||
|
});
|
||||||
28
frontend/src/routes/dashboard.tsx
Normal file
28
frontend/src/routes/dashboard.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
13
frontend/src/routes/index.lazy.tsx
Normal file
13
frontend/src/routes/index.lazy.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
35
frontend/src/routes/login.tsx
Normal file
35
frontend/src/routes/login.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
40
frontend/src/routes/register.tsx
Normal file
40
frontend/src/routes/register.tsx
Normal 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
1
frontend/src/vite-env.d.ts
vendored
Normal file
@@ -0,0 +1 @@
|
|||||||
|
/// <reference types="vite/client" />
|
||||||
@@ -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'],
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
};
|
|
||||||
57
frontend/tailwind.config.js
Normal file
57
frontend/tailwind.config.js
Normal 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")],
|
||||||
|
};
|
||||||
30
frontend/tsconfig.app.json
Normal file
30
frontend/tsconfig.app.json
Normal 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"]
|
||||||
|
}
|
||||||
@@ -1,33 +1,13 @@
|
|||||||
{
|
{
|
||||||
|
"files": [],
|
||||||
|
"references": [
|
||||||
|
{ "path": "./tsconfig.app.json" },
|
||||||
|
{ "path": "./tsconfig.node.json" }
|
||||||
|
],
|
||||||
"compilerOptions": {
|
"compilerOptions": {
|
||||||
"allowJs": true,
|
"baseUrl": ".",
|
||||||
"allowImportingTsExtensions": true,
|
"paths": {
|
||||||
"esModuleInterop": true,
|
"@/*": ["./src/*"]
|
||||||
"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
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
28
frontend/tsconfig.node.json
Normal file
28
frontend/tsconfig.node.json
Normal 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"]
|
||||||
|
}
|
||||||
@@ -1,6 +1,14 @@
|
|||||||
import react from '@vitejs/plugin-react';
|
import { TanStackRouterVite } from "@tanstack/router-plugin/vite";
|
||||||
import { defineConfig } from 'vite';
|
import react from "@vitejs/plugin-react-swc";
|
||||||
|
import path from "path";
|
||||||
|
import { defineConfig } from "vite";
|
||||||
|
|
||||||
|
// https://vite.dev/config/
|
||||||
export default defineConfig({
|
export default defineConfig({
|
||||||
plugins: [react()],
|
plugins: [TanStackRouterVite(), react()],
|
||||||
|
resolve: {
|
||||||
|
alias: {
|
||||||
|
"@": path.resolve(__dirname, "./src"),
|
||||||
|
},
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user