21 Commits

Author SHA1 Message Date
ramvignesh-b d7dc2e8eb9 chore: update restart policy to unless-stopped for postgres and mailpit services in e2e docker-compose 2026-04-17 01:55:37 +05:30
ramvignesh-b 47e101c6fc feat: add caching for Playwright dependencies in CI workflow 2026-04-17 01:54:49 +05:30
ramvignesh-b 9935da0496 feat: add container runtime validation and force docker usage in CI environment 2026-04-17 01:52:04 +05:30
ramvignesh-b 05e4df2d7b refactor: improve container orchestration detection and fallback logic in e2e test script 2026-04-17 01:41:26 +05:30
ramvignesh-b 06585163d0 ci: improve compatibility for docker-compose execution 2026-04-17 01:30:57 +05:30
ramvignesh-b c40e3d20cb ci: add sll support and enhance e2e workflow 2026-04-17 01:22:03 +05:30
ramvignesh-b f5757b47de chore: set test timezone to Asia/Kolkata 2026-04-16 06:13:20 +05:30
ramvignesh-b 029b02b3c8 fix: update MAILPIT_API_URL protocol from https to http in e2e environment example 2026-04-16 05:57:52 +05:30
ramvignesh-b c5aeccf58f fix: force en-US locale in Intl formatters to ensure consistent date and time output 2026-04-16 05:56:52 +05:30
ramvignesh-b 1927dedf22 test: set TZ environment variable to Asia/Kolkata in vitest configuration 2026-04-16 05:54:26 +05:30
ramvignesh-b dd7f3e1fe9 fix: correct environment file paths and parallelize frontend dependency installation in CI workflow 2026-04-16 05:49:47 +05:30
ramvignesh-b b1d2c374b6 fix: correct certificate caching keys and fix environment file paths in CI workflows 2026-04-16 05:39:04 +05:30
ramvignesh-b 2e0c4e557d ci: implement certificate caching in workflow 2026-04-16 05:34:08 +05:30
ramvignesh-b 3f761cfe7e refactor: optimize CI workflow caching 2026-04-16 05:14:43 +05:30
ramvignesh-b 6ad8837145 feat: implement certificate caching in CI workflow to persist SSL files across jobs 2026-04-16 05:00:24 +05:30
ramvignesh-b ce8bb5c018 fix: correct mkcert command args 2026-04-16 04:41:38 +05:30
ramvignesh-b 7a05a6040e fix: use static ip in mkcert command 2026-04-16 04:39:29 +05:30
ramvignesh-b 587160811f feat: centralize SSL certificate generation into a reusable workflow job 2026-04-16 04:36:40 +05:30
ramvignesh-b 4195fce415 fix: add IPv6 loopback support to mkcert generation command in CI workflow 2026-04-16 04:32:59 +05:30
ramvignesh-b 4277298c47 Merge branch 'main' of https://github.com/ramvignesh-b/pi-ku into feature/ssl-integration 2026-04-16 04:30:55 +05:30
ramvignesh-b b08d505a5a feat: update E2E testing configuration to use ssl 2026-04-16 04:26:29 +05:30
83 changed files with 921 additions and 3274 deletions
+3 -4
View File
@@ -2,11 +2,11 @@
DB_NAME=piku_test_db DB_NAME=piku_test_db
DB_USER=test DB_USER=test
DB_PASSWORD=password123 DB_PASSWORD=password123
DB_HOST=127.0.0.1 DB_HOST=localhost
DB_PORT=5433 DB_PORT=5433
# SSL # SSL
SSL_ENABLED=true SSL_ENABLED=false
# DJANGO # DJANGO
DEBUG=True DEBUG=True
@@ -17,12 +17,11 @@ BACKEND_PORT=8001
# EMAIL # EMAIL
EMAIL_HOST=127.0.0.1 EMAIL_HOST=127.0.0.1
EMAIL_PORT=1026 EMAIL_PORT=1026
FROM_EMAIL="Test <test@pi-ku.app>"
EMAIL_HOST_USER= EMAIL_HOST_USER=
EMAIL_HOST_PASSWORD= EMAIL_HOST_PASSWORD=
FROM_EMAIL="Test <test@pi-ku.app>"
EMAIL_API_PORT=8026 EMAIL_API_PORT=8026
# FRONTEND # FRONTEND
FRONTEND_PORT=5199 FRONTEND_PORT=5199
FRONTEND_DOMAIN=127.0.0.1 FRONTEND_DOMAIN=127.0.0.1
VITE_API_URL=https://127.0.0.1:8001
+2 -12
View File
@@ -2,33 +2,23 @@
DB_NAME=piku DB_NAME=piku
DB_USER=user DB_USER=user
DB_PASSWORD=password123 DB_PASSWORD=password123
DB_HOST=127.0.0.1 DB_HOST=localhost
DB_PORT=5432 DB_PORT=5432
# SSL # SSL
SSL_ENABLED=true SSL_ENABLED=true
S3_ENABLED=false
# DJANGO # DJANGO
DEBUG=True DEBUG=True
SECRET_KEY=django-secret-key SECRET_KEY=django-secret-key
BACKEND_DOMAIN=127.0.0.1 BACKEND_DOMAIN=127.0.0.1
BACKEND_PORT=8000 BACKEND_PORT=8000
# S3
R2_ACCESS_KEY_ID=
R2_SECRET_ACCESS_KEY=
R2_REGION_NAME=
R2_ENDPOINT_URL=
R2_PUBLIC_URL=
# EMAIL # EMAIL
EMAIL_HOST=127.0.0.1 EMAIL_HOST=127.0.0.1
EMAIL_PORT=1025 EMAIL_PORT=1025
EMAIL_HOST_USER= FROM_EMAIL=Pi Ku <no-reply@test.com>
EMAIL_HOST_PASSWORD=
FROM_EMAIL="Pi Ku <no-reply@test.com>"
# FRONTEND # FRONTEND
FRONTEND_PORT=5173 FRONTEND_PORT=5173
FRONTEND_DOMAIN=127.0.0.1 FRONTEND_DOMAIN=127.0.0.1
VITE_API_URL=https://127.0.0.1:8000
-4
View File
@@ -145,7 +145,3 @@ jobs:
name: playwright-report name: playwright-report
path: frontend/playwright-report/ path: frontend/playwright-report/
retention-days: 10 retention-days: 10
- name: Print Backend Logs on Failure
if: failure()
run: cat tmp/logs/backend.log || true
-14
View File
@@ -13,17 +13,3 @@ dist/
# Certificates # Certificates
certs/*.pem certs/*.pem
tmp/ tmp/
.idea/.gitignore
.idea/misc.xml
.idea/modules.xml
.idea/pi ku.iml
.idea/vcs.xml
.idea/inspectionProfiles/profiles_settings.xml
.idea/runConfigurations/pi_ku.xml
backend/.idea/.gitignore
backend/.idea/backend.iml
backend/.idea/misc.xml
backend/.idea/modules.xml
backend/.idea/vcs.xml
backend/.idea/inspectionProfiles/profiles_settings.xml
backend/.idea/runConfigurations/backend.xml
-1
View File
@@ -1 +0,0 @@
.venv
-18
View File
@@ -1,18 +0,0 @@
FROM astral/uv:python3.13-bookworm-slim
WORKDIR /app
# HACK: Force app to dump logs into the docker console immediately
ENV PYTHONUNBUFFERED=1
COPY pyproject.toml uv.lock ./
RUN uv sync --frozen --no-dev
COPY . .
# Make the temp log dir writable since server is running rootless
RUN mkdir -p /app/logs && chmod -R 777 /app/logs
EXPOSE 8000
CMD ["sh", "-c", "uv run manage.py migrate && uv run gunicorn --bind 0.0.0.0:8000 --access-logfile - --error-logfile - --capture-output --log-level debug config.wsgi:application"]
-89
View File
@@ -1,89 +0,0 @@
import structlog
structlog.configure(
processors=[
structlog.contextvars.merge_contextvars,
structlog.stdlib.filter_by_level,
structlog.processors.TimeStamper(fmt="iso"),
structlog.stdlib.add_logger_name,
structlog.stdlib.add_log_level,
structlog.stdlib.PositionalArgumentsFormatter(),
structlog.processors.StackInfoRenderer(),
structlog.processors.format_exc_info,
structlog.processors.UnicodeDecoder(),
structlog.stdlib.ProcessorFormatter.wrap_for_formatter,
],
logger_factory=structlog.stdlib.LoggerFactory(),
cache_logger_on_first_use=True,
)
LOGGING = {
"version": 1,
"disable_existing_loggers": False,
"formatters": {
"json_formatter": {
"()": structlog.stdlib.ProcessorFormatter,
"processor": structlog.processors.JSONRenderer(),
},
"plain_console": {
"()": structlog.stdlib.ProcessorFormatter,
"processor": structlog.dev.ConsoleRenderer(colors=True),
},
"key_value": {
"()": structlog.stdlib.ProcessorFormatter,
"processor": structlog.processors.KeyValueRenderer(key_order=["timestamp", "level", "event", "logger"]),
},
},
"handlers": {
"console": {
"class": "logging.StreamHandler",
"formatter": "plain_console",
},
"json_file": {
"class": "logging.handlers.WatchedFileHandler",
"filename": "logs/json.log",
"formatter": "json_formatter",
},
"flat_line_file": {
"class": "logging.handlers.WatchedFileHandler",
"filename": "logs/flat_line.log",
"formatter": "key_value",
},
"letters_log": {
"class": "logging.handlers.WatchedFileHandler",
"filename": "logs/letters.log",
"formatter": "key_value",
},
"scheduler_log": {
"class": "logging.handlers.WatchedFileHandler",
"filename": "logs/scheduler.log",
"formatter": "key_value",
},
},
"loggers": {
"django_structlog": {
"handlers": ["console", "flat_line_file", "json_file"],
"level": "INFO",
"propagate": False,
},
"django.core.mail": {
"handlers": ["console", "flat_line_file", "json_file"],
"level": "DEBUG",
"propagate": False,
},
"letters": {
"handlers": ["console", "flat_line_file", "json_file", "letters_log"],
"level": "INFO",
"propagate": False,
},
"scheduler": {
"handlers": ["console", "scheduler_log"],
"level": "INFO",
"propagate": False,
},
"": {
"handlers": ["console", "flat_line_file", "json_file"],
"level": "INFO",
},
},
}
+17 -58
View File
@@ -21,26 +21,15 @@ BASE_DIR = Path(__file__).resolve().parent.parent
# Load dotenv files # Load dotenv files
env = environ.Env() env = environ.Env()
env_file = os.environ.get("PIKU_ENV_FILE", os.path.join(BASE_DIR.parent, ".env")) env_file = os.path.join(BASE_DIR.parent, ".env")
if os.path.exists(env_file): if os.path.exists(env_file):
environ.Env.read_env(env_file, overwrite=False) environ.Env.read_env(env_file, overwrite=False)
ALLOWED_HOSTS = env.list("ALLOWED_HOSTS", default=["127.0.0.1"])
ALLOWED_HOSTS.append(env("FRONTEND_DOMAIN", default="127.0.0.1"))
ALLOWED_HOSTS.append(env("BACKEND_DOMAIN", default="127.0.0.1"))
CSRF_TRUSTED_ORIGINS = env.list("CSRF_TRUSTED_ORIGINS", default=[]) SSL_ENABLED = env("SSL_ENABLED") == "true"
FRONTEND_URL = f"https://{env('FRONTEND_DOMAIN')}" if SSL_ENABLED else f"http://{env('FRONTEND_DOMAIN')}"
SSL_ENABLED = env.bool("SSL_ENABLED", default=False) if env("FRONTEND_PORT"):
URI_SCHEME = "https://" if SSL_ENABLED else "http://" FRONTEND_URL += f":{env('FRONTEND_PORT')}"
FRONTEND_URLS = []
if env("FRONTEND_URL", default=None):
FRONTEND_URLS.append(env("FRONTEND_URL"))
if env("FRONTEND_PORT", default=None):
FRONTEND_URLS.append(f"{URI_SCHEME}{env('FRONTEND_DOMAIN')}:{env('FRONTEND_PORT')}")
else:
FRONTEND_URLS.append(f"{URI_SCHEME}{env('FRONTEND_DOMAIN')}")
# Quick-start development settings - unsuitable for production # Quick-start development settings - unsuitable for production
# See https://docs.djangoproject.com/en/6.0/howto/deployment/checklist/ # See https://docs.djangoproject.com/en/6.0/howto/deployment/checklist/
@@ -49,19 +38,19 @@ else:
SECRET_KEY = env("SECRET_KEY") SECRET_KEY = env("SECRET_KEY")
# SECURITY WARNING: don't run with debug turned on in production! # SECURITY WARNING: don't run with debug turned on in production!
DEBUG = env.bool("DEBUG", default=False) DEBUG = env("DEBUG")
ALLOWED_HOSTS = [env("FRONTEND_DOMAIN")]
# Application definition # Application definition
INSTALLED_APPS = [ INSTALLED_APPS = [
"django_apscheduler",
"django.contrib.auth", "django.contrib.auth",
"django.contrib.contenttypes", "django.contrib.contenttypes",
"django.contrib.sessions", "django.contrib.sessions",
"django.contrib.staticfiles", "django.contrib.staticfiles",
"django_extensions", "django_extensions",
"django_structlog",
"rest_framework", "rest_framework",
"corsheaders", "corsheaders",
"users", "users",
@@ -78,14 +67,13 @@ MIDDLEWARE = [
"django.contrib.auth.middleware.AuthenticationMiddleware", "django.contrib.auth.middleware.AuthenticationMiddleware",
"django.contrib.messages.middleware.MessageMiddleware", "django.contrib.messages.middleware.MessageMiddleware",
"django.middleware.clickjacking.XFrameOptionsMiddleware", "django.middleware.clickjacking.XFrameOptionsMiddleware",
"django_structlog.middlewares.RequestMiddleware",
] ]
ROOT_URLCONF = "config.urls" ROOT_URLCONF = "config.urls"
WSGI_APPLICATION = "config.wsgi.application" WSGI_APPLICATION = "config.wsgi.application"
# Database # Database
# https://docs.djangoproject.com/en/6.0/ref/settings/#databases # https://docs.djangoproject.com/en/6.0/ref/settings/#databases
@@ -100,8 +88,7 @@ DATABASES = {
} }
} }
CORS_ALLOWED_ORIGINS = FRONTEND_URLS CORS_ALLOWED_ORIGINS = [FRONTEND_URL]
CSRF_TRUSTED_ORIGINS += FRONTEND_URLS
CORS_ALLOW_CREDENTIALS = True CORS_ALLOW_CREDENTIALS = True
AUTH_USER_MODEL = "users.User" AUTH_USER_MODEL = "users.User"
@@ -109,7 +96,6 @@ AUTH_USER_MODEL = "users.User"
REST_FRAMEWORK = { REST_FRAMEWORK = {
"DEFAULT_AUTHENTICATION_CLASSES": ("rest_framework_simplejwt.authentication.JWTAuthentication",), "DEFAULT_AUTHENTICATION_CLASSES": ("rest_framework_simplejwt.authentication.JWTAuthentication",),
"DEFAULT_PERMISSION_CLASSES": ("rest_framework.permissions.IsAuthenticated",), "DEFAULT_PERMISSION_CLASSES": ("rest_framework.permissions.IsAuthenticated",),
"DEFAULT_RENDERER_CLASSES": ("rest_framework.renderers.JSONRenderer",),
} }
SIMPLE_JWT = { SIMPLE_JWT = {
@@ -126,8 +112,8 @@ NOTE: COOKIE_SAMESITE: Lax is used to allow cross-site redirection, like links
""" """
AUTH_COOKIE = { AUTH_COOKIE = {
"NAME": "refresh_token", "NAME": "refresh_token",
"DOMAIN": None if DEBUG else env("FRONTEND_DOMAIN"), "DOMAIN": None,
"SECURE": SSL_ENABLED if DEBUG else True, "SECURE": SSL_ENABLED,
"HTTPONLY": True, "HTTPONLY": True,
"SAMESITE": "Lax", "SAMESITE": "Lax",
} }
@@ -135,13 +121,11 @@ AUTH_COOKIE = {
# Email config # Email config
EMAIL_BACKEND = "django.core.mail.backends.smtp.EmailBackend" EMAIL_BACKEND = "django.core.mail.backends.smtp.EmailBackend"
EMAIL_HOST = env("EMAIL_HOST") EMAIL_HOST = env("EMAIL_HOST")
EMAIL_PORT = env.int("EMAIL_PORT") EMAIL_PORT = env("EMAIL_PORT")
EMAIL_HOST_USER = env("EMAIL_HOST_USER") EMAIL_USE_TLS = not DEBUG
EMAIL_HOST_PASSWORD = env("EMAIL_HOST_PASSWORD")
EMAIL_USE_TLS = env.bool("EMAIL_USE_TLS", default=False)
EMAIL_USE_SSL = env.bool("EMAIL_USE_SSL", default=False)
FROM_EMAIL = env("FROM_EMAIL") FROM_EMAIL = env("FROM_EMAIL")
# Password validation # Password validation
# https://docs.djangoproject.com/en/6.0/ref/settings/#auth-password-validators # https://docs.djangoproject.com/en/6.0/ref/settings/#auth-password-validators
@@ -160,6 +144,7 @@ AUTH_PASSWORD_VALIDATORS = [
}, },
] ]
# Internationalization # Internationalization
# https://docs.djangoproject.com/en/6.0/topics/i18n/ # https://docs.djangoproject.com/en/6.0/topics/i18n/
@@ -171,36 +156,10 @@ USE_I18N = True
USE_TZ = True USE_TZ = True
# Static files (CSS, JavaScript, Images) # Static files (CSS, JavaScript, Images)
# https://docs.djangoproject.com/en/6.0/howto/static-files/ # https://docs.djangoproject.com/en/6.0/howto/static-files/
STATIC_URL = "static/" STATIC_URL = "static/"
MEDIA_URL = "/media/" MEDIA_URL = "/media/"
if env.bool("S3_ENABLED", default=False):
MEDIA_URL = f"{env('R2_PUBLIC_URL')}/media/"
# HACK: S3 auto pre-pends the url scheme forcefully and this prevents double https
R2_HOST = env("R2_PUBLIC_URL").replace("https://", "")
STORAGES = {
"default": {
"BACKEND": "storages.backends.s3.S3Storage",
"OPTIONS": {
"access_key": env("R2_ACCESS_KEY_ID"),
"secret_key": env("R2_SECRET_ACCESS_KEY"),
"bucket_name": env("R2_STORAGE_BUCKET_NAME"),
"region_name": env("R2_REGION_NAME"),
"endpoint_url": env("R2_ENDPOINT_URL"),
"location": "media",
"signature_version": "s3v4",
"file_overwrite": False,
"custom_domain": R2_HOST,
"querystring_auth": False,
},
},
"staticfiles": {
"BACKEND": "django.contrib.staticfiles.storage.StaticFilesStorage",
},
}
DEFAULT_FILE_STORAGE = "storages.backends.s3.S3Storage"
MEDIA_ROOT = BASE_DIR / "media" MEDIA_ROOT = BASE_DIR / "media"
-14
View File
@@ -1,19 +1,5 @@
import os
from django.apps import AppConfig from django.apps import AppConfig
class LettersConfig(AppConfig): class LettersConfig(AppConfig):
name = "letters" name = "letters"
def ready(self):
"""
Start the scheduler only when the server is starting.
NOTE: If we don't check for RUN_MAIN, the scheduler triggers for all django operations (migration, test etc.)
"""
if not (os.environ.get("RUN_MAIN") == "true" or os.environ.get("WERKZEUG_RUN_MAIN") == "true"):
return
from .tasks import start_scheduler
start_scheduler()
@@ -1,17 +0,0 @@
# Generated by Django 6.0.4 on 2026-04-17 07:43
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("letters", "0007_alter_letter_public_id"),
]
operations = [
migrations.AddField(
model_name="letter",
name="notified_at",
field=models.DateTimeField(blank=True, null=True),
),
]
@@ -1,22 +0,0 @@
# Generated by Django 6.0.4 on 2026-04-17 18:08
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("letters", "0008_letter_notified_at"),
]
operations = [
migrations.AlterField(
model_name="letter",
name="notified_at",
field=models.DateTimeField(blank=True, db_index=True, null=True),
),
migrations.AlterField(
model_name="letter",
name="unlock_at",
field=models.DateTimeField(blank=True, db_index=True, null=True),
),
]
+1 -13
View File
@@ -1,5 +1,4 @@
import uuid import uuid
from datetime import UTC, datetime
from django.conf import settings from django.conf import settings
from django.core.exceptions import ValidationError from django.core.exceptions import ValidationError
@@ -25,11 +24,10 @@ class Letter(models.Model):
updated_at = models.DateTimeField(auto_now=True) updated_at = models.DateTimeField(auto_now=True)
encrypted_content = models.TextField(null=True, blank=True) encrypted_content = models.TextField(null=True, blank=True)
encrypted_metadata = models.TextField(null=True, blank=True) encrypted_metadata = models.TextField(null=True, blank=True)
unlock_at = models.DateTimeField(null=True, blank=True, db_index=True) unlock_at = models.DateTimeField(null=True, blank=True)
sealed_at = models.DateTimeField(null=True, blank=True) sealed_at = models.DateTimeField(null=True, blank=True)
opened_at = models.DateTimeField(null=True, blank=True) opened_at = models.DateTimeField(null=True, blank=True)
burned_at = models.DateTimeField(null=True, blank=True) burned_at = models.DateTimeField(null=True, blank=True)
notified_at = models.DateTimeField(null=True, blank=True, db_index=True)
encrypted_dek = models.TextField(null=True, blank=True) encrypted_dek = models.TextField(null=True, blank=True)
def clean(self): def clean(self):
@@ -40,16 +38,6 @@ class Letter(models.Model):
if self.type == Letter.Type.VAULT and self.status == Letter.Status.SEALED and not self.unlock_at: if self.type == Letter.Type.VAULT and self.status == Letter.Status.SEALED and not self.unlock_at:
raise ValidationError("A sealed VAULT letter must have an unlock_date.") raise ValidationError("A sealed VAULT letter must have an unlock_date.")
def save(self, *args, **kwargs):
"""
Override save method to auto set BURNED and SEALED timestamps.
"""
if self.status == Letter.Status.BURNED:
self.burned_at = datetime.now(UTC)
if self.status == Letter.Status.SEALED:
self.sealed_at = datetime.now(UTC)
super().save(*args, **kwargs)
def __str__(self): def __str__(self):
return f"{self.type} - {self.status}" return f"{self.type} - {self.status}"
-23
View File
@@ -1,5 +1,3 @@
from datetime import UTC, datetime, timedelta
from rest_framework import serializers from rest_framework import serializers
from letters.models import Letter, LetterImage from letters.models import Letter, LetterImage
@@ -36,25 +34,6 @@ class LetterSerializer(serializers.ModelSerializer):
] ]
read_only_fields = ["created_at", "updated_at"] read_only_fields = ["created_at", "updated_at"]
def to_representation(self, instance):
fields = super().to_representation(instance)
if fields["type"] == Letter.Type.VAULT and fields["status"] == Letter.Status.SEALED:
try:
unlock_datetime = datetime.fromisoformat(fields["unlock_at"]).replace(tzinfo=UTC)
if unlock_datetime - datetime.now(tz=UTC) > timedelta(seconds=0):
fields["encrypted_content"] = None
fields["images"] = None
fields["encrypted_dek"] = None
except (ValueError, TypeError):
pass
if fields["status"] == Letter.Status.BURNED:
fields["encrypted_content"] = None
fields["images"] = None
fields["encrypted_dek"] = None
return fields
def validate(self, data): def validate(self, data):
""" """
Validates the requirmnt of DEK when encrypted content and metadata are stored. Validates the requirmnt of DEK when encrypted content and metadata are stored.
@@ -63,6 +42,4 @@ class LetterSerializer(serializers.ModelSerializer):
raise serializers.ValidationError( raise serializers.ValidationError(
"encrypted_dek is required when encrypted_content and encrypted_metadata are present" "encrypted_dek is required when encrypted_content and encrypted_metadata are present"
) )
if data.get("type") == Letter.Type.VAULT and not data.get("unlock_at"):
raise serializers.ValidationError("unlock_at is required for vault letters")
return data return data
-55
View File
@@ -1,55 +0,0 @@
from datetime import UTC, datetime
import structlog
from apscheduler.schedulers.background import BackgroundScheduler
from django.core.mail import send_mail
from config import settings
from letters.models import Letter
logger = structlog.get_logger(__name__)
def get_vault_letters_to_notify():
"""
Identifies the vault letters that have been recently unlocked and not notified
"""
return Letter.objects.filter(unlock_at__lt=datetime.now(UTC), notified_at=None)
def notify_unlocked_letter(letter):
"""
Notifies the author of the letter via email and if successful, updates the notified_at field for the letter.
"""
author = letter.user.get_username()
try:
send_mail(subject="", message="", from_email=settings.FROM_EMAIL, recipient_list=[author], fail_silently=False)
letter.notified_at = datetime.now(UTC)
letter.save()
except Exception:
logger.exception(f"Failed to notify {author} of unlocked letter")
def vault_unlock_notification_polling_scheduler():
"""
Orchestrates the vault polling logic.
"""
letters_to_notify = get_vault_letters_to_notify()
for letter in letters_to_notify:
notify_unlocked_letter(letter)
def start_scheduler():
"""
Starts the background scheduler for polling and notifying vault letters.
"""
logger.info("Starting vault polling scheduler...")
scheduler = BackgroundScheduler()
scheduler.add_job(
vault_unlock_notification_polling_scheduler,
trigger="interval",
minutes=1,
id="letter_polling",
replace_existing=True,
)
scheduler.start()
-169
View File
@@ -1,7 +1,3 @@
from datetime import UTC, datetime, timedelta
from unittest.mock import ANY, patch
from django.conf import settings
from django.contrib.auth import get_user_model from django.contrib.auth import get_user_model
from django.core.files.base import ContentFile from django.core.files.base import ContentFile
from django.test import TestCase from django.test import TestCase
@@ -212,102 +208,6 @@ class LetterAPITest(APITestCase):
self.assertFalse(default_storage.exists("encrypted-images/old2.bin")) self.assertFalse(default_storage.exists("encrypted-images/old2.bin"))
self.assertEqual(response.status_code, 200) self.assertEqual(response.status_code, 200)
def test_vault_letters_does_not_return_letter_content_before_the_unlock_date(self):
"""
Test that the vault letters does not return letter content (images and encrypted_content)
before the unlock date.
"""
from datetime import datetime, timedelta
letter = Letter.objects.create(
user=self.user,
type="VAULT",
status="SEALED",
public_id="4281edcc-5459-4ff2-bb5e-669fb44e0757",
encrypted_content="enc_content==",
encrypted_metadata="enc_meta==",
encrypted_dek="enc_dek==",
unlock_at=datetime.now(UTC),
)
from freezegun import freeze_time
past_datetime = datetime.now(UTC) - timedelta(seconds=1)
future_datetime = datetime.now(UTC) + timedelta(seconds=1)
with freeze_time(past_datetime):
response = self.client.get(f"/api/letters/{letter.public_id}/")
self.assertEqual(response.status_code, 200)
self.assertEqual(response.data["encrypted_content"], None)
self.assertEqual(response.data["encrypted_metadata"], "enc_meta==")
self.assertEqual(response.data["encrypted_dek"], None)
with freeze_time(future_datetime):
response = self.client.get(f"/api/letters/{letter.public_id}/")
self.assertEqual(response.status_code, 200)
self.assertEqual(response.data["encrypted_content"], "enc_content==")
self.assertEqual(response.data["encrypted_metadata"], "enc_meta==")
self.assertEqual(response.data["encrypted_dek"], "enc_dek==")
def test_burn_letter(self):
"""
Test that a sealed letter can only be burned but not updated.
"""
letter = Letter.objects.create(
user=self.user,
type="KEPT",
status="SEALED",
public_id="4281edcc-5459-4ff2-bb5e-669fb44e0757",
encrypted_content="enc_content==",
encrypted_metadata="enc_meta==",
encrypted_dek="enc_dek==",
)
response_update_content = self.client.patch(
self.url + letter.public_id + "/",
{
"encrypted_content": "enc_content_new==",
"encrypted_metadata": "enc_meta_new==",
"encrypted_dek": "enc_dek_new==",
},
)
self.assertEqual(response_update_content.status_code, 400)
self.assertEqual(response_update_content.data["error"], "Sealed letters can only be burned or sent.")
self.assertEqual(Letter.objects.get().encrypted_content, "enc_content==")
from datetime import UTC, datetime
from freezegun import freeze_time
current_time = datetime.now(UTC)
with freeze_time(current_time):
response_burn = self.client.patch(self.url + letter.public_id + "/", {"status": "BURNED"})
self.assertEqual(response_burn.status_code, 200)
self.assertEqual(Letter.objects.count(), 1)
self.assertEqual(Letter.objects.get().status, "BURNED")
self.assertEqual(Letter.objects.get().burned_at, current_time)
def test_send_sealed_letter(self):
"""
Test that a sealed letter can be sent.
"""
letter = Letter.objects.create(
user=self.user,
type="KEPT",
status="SEALED",
public_id="4281edcc-5459-4ff2-bb5e-669fb44e0757",
encrypted_content="enc_content==",
encrypted_metadata="enc_meta==",
encrypted_dek="enc_dek==",
)
response_sent = self.client.patch(self.url + letter.public_id + "/", {"type": "SENT"})
self.assertEqual(response_sent.status_code, 200)
self.assertEqual(Letter.objects.count(), 1)
self.assertEqual(Letter.objects.get().type, "SENT")
class LetterImageModelTest(TestCase): class LetterImageModelTest(TestCase):
def setUp(self): def setUp(self):
@@ -339,72 +239,3 @@ class LetterImageModelTest(TestCase):
self.assertEqual(LetterImage.objects.count(), 1) self.assertEqual(LetterImage.objects.count(), 1)
self.letter.delete() self.letter.delete()
self.assertEqual(LetterImage.objects.count(), 0) self.assertEqual(LetterImage.objects.count(), 0)
class LetterTaskTest(TestCase):
def setUp(self):
self.user = User.objects.create_user(email="task@pi-ku.app", password="password1234")
def test_get_vault_letters_to_be_notified(self):
"""
Test that the task can successfully retrieve the letters whose unlock date is passed and haven't been notified.
"""
from letters.tasks import get_vault_letters_to_notify
Letter.objects.create(
user=self.user, type="VAULT", status="SEALED", unlock_at=datetime.now(UTC) + timedelta(seconds=1)
)
Letter.objects.create(user=self.user, type="VAULT", status="SEALED", unlock_at=datetime.now(UTC))
Letter.objects.create(
user=self.user, type="VAULT", status="SEALED", unlock_at=datetime.now(UTC) - timedelta(seconds=1)
)
Letter.objects.create(
user=self.user,
type="VAULT",
status="SEALED",
unlock_at=datetime.now(UTC) - timedelta(hours=1),
notified_at=datetime.now(UTC) - timedelta(minutes=59),
)
Letter.objects.create(
user=self.user, type="VAULT", status="SEALED", unlock_at=datetime.now(UTC) + timedelta(seconds=1)
)
Letter.objects.create(
user=self.user,
type="KEPT",
status="SEALED",
)
unlocked_letters = get_vault_letters_to_notify()
self.assertEqual(len(unlocked_letters), 2)
def test_notify_unlocked_letter(self):
"""
Test that the task successfully notifies the user via email and updates the database field.
"""
from letters.tasks import notify_unlocked_letter
letter_to_notify1 = Letter.objects.create(
user=self.user, type="VAULT", status="SEALED", unlock_at=datetime.now(UTC), notified_at=None
)
with patch("letters.tasks.send_mail") as mock_send_mail:
notify_unlocked_letter(letter_to_notify1)
mock_send_mail.assert_called_with(
subject=ANY,
message=ANY,
from_email=settings.FROM_EMAIL,
recipient_list=[self.user.email],
fail_silently=False,
)
self.assertIsNotNone(letter_to_notify1.notified_at)
letter_to_notify2 = Letter.objects.create(
user=self.user, type="VAULT", status="SEALED", unlock_at=datetime.now(UTC), notified_at=None
)
with patch("letters.tasks.send_mail") as mock_send_mail:
mock_send_mail.side_effect = Exception()
notify_unlocked_letter(letter_to_notify2)
self.assertIsNone(letter_to_notify2.notified_at)
-21
View File
@@ -62,24 +62,3 @@ class LetterDetailView(generics.RetrieveUpdateDestroyAPIView):
response_serializer = self.get_serializer(letter) response_serializer = self.get_serializer(letter)
return Response(response_serializer.data, status=201 if created else 200) return Response(response_serializer.data, status=201 if created else 200)
def patch(self, request, public_id):
"""
Updates an existing letter.
Can update type and status only when sealed, sent and burned.
"""
letter = Letter.objects.get(public_id=public_id, user=request.user)
if letter.status == Letter.Status.SEALED:
if (
len(request.data) > 1
or (request.data.get("status") != Letter.Status.BURNED and request.data.get("status") is not None)
or (request.data.get("type") != Letter.Type.SENT and request.data.get("type") is not None)
):
return Response({"error": "Sealed letters can only be burned or sent."}, status=400)
write_serializer = self.get_serializer(letter, data=request.data, partial=True)
write_serializer.is_valid(raise_exception=True)
write_serializer.save()
response_serializer = self.get_serializer(letter)
return Response(response_serializer.data, status=200)
-10
View File
@@ -5,25 +5,15 @@ description = "Django Rest Framework for handling requests for Pi Ku app"
readme = "README.md" readme = "README.md"
requires-python = ">=3.14" requires-python = ">=3.14"
dependencies = [ dependencies = [
"apscheduler>=3.11.2",
"boto3>=1.42.96",
"django>=6.0.4", "django>=6.0.4",
"django-apscheduler>=0.7.0",
"django-cors-headers>=4.9.0", "django-cors-headers>=4.9.0",
"django-environ>=0.13.0", "django-environ>=0.13.0",
"django-extensions>=4.1", "django-extensions>=4.1",
"django-storages>=1.14.6",
"django-structlog>=10.0.0",
"djangorestframework>=3.17.1", "djangorestframework>=3.17.1",
"djangorestframework-simplejwt>=5.5.1", "djangorestframework-simplejwt>=5.5.1",
"djangorestframework-stubs>=3.16.9",
"freezegun>=1.5.5",
"gunicorn>=25.3.0",
"psycopg2-binary>=2.9.11", "psycopg2-binary>=2.9.11",
"pyopenssl>=26.0.0", "pyopenssl>=26.0.0",
"rich>=15.0.0",
"ruff>=0.15.9", "ruff>=0.15.9",
"structlog>=25.5.0",
"werkzeug>=3.1.8", "werkzeug>=3.1.8",
] ]
-256
View File
@@ -1,256 +0,0 @@
# This file was autogenerated by uv via the following command:
# uv export --format requirements-txt --output-file requirements.txt
apscheduler==3.11.2 \
--hash=sha256:2a9966b052ec805f020c8c4c3ae6e6a06e24b1bf19f2e11d91d8cca0473eef41 \
--hash=sha256:ce005177f741409db4e4dd40a7431b76feb856b9dd69d57e0da49d6715bfd26d
# via
# django-apscheduler
# piku-backend
asgiref==3.11.1 \
--hash=sha256:5f184dc43b7e763efe848065441eac62229c9f7b0475f41f80e207a114eda4ce \
--hash=sha256:e8667a091e69529631969fd45dc268fa79b99c92c5fcdda727757e52146ec133
# via
# django
# django-cors-headers
cffi==2.0.0 ; platform_python_implementation != 'PyPy' \
--hash=sha256:087067fa8953339c723661eda6b54bc98c5625757ea62e95eb4898ad5e776e9f \
--hash=sha256:0a1527a803f0a659de1af2e1fd700213caba79377e27e4693648c2923da066f9 \
--hash=sha256:12873ca6cb9b0f0d3a0da705d6086fe911591737a59f28b7936bdfed27c0d47c \
--hash=sha256:1fc9ea04857caf665289b7a75923f2c6ed559b8298a1b8c49e59f7dd95c8481e \
--hash=sha256:203a48d1fb583fc7d78a4c6655692963b860a417c0528492a6bc21f1aaefab25 \
--hash=sha256:24b6f81f1983e6df8db3adc38562c83f7d4a0c36162885ec7f7b77c7dcbec97b \
--hash=sha256:28a3a209b96630bca57cce802da70c266eb08c6e97e5afd61a75611ee6c64592 \
--hash=sha256:38100abb9d1b1435bc4cc340bb4489635dc2f0da7456590877030c9b3d40b0c1 \
--hash=sha256:44d1b5909021139fe36001ae048dbdde8214afa20200eda0f64c068cac5d5529 \
--hash=sha256:6c6c373cfc5c83a975506110d17457138c8c63016b563cc9ed6e056a82f13ce4 \
--hash=sha256:737fe7d37e1a1bffe70bd5754ea763a62a066dc5913ca57e957824b72a85e205 \
--hash=sha256:7553fb2090d71822f02c629afe6042c299edf91ba1bf94951165613553984512 \
--hash=sha256:7a66c7204d8869299919db4d5069a82f1561581af12b11b3c9f48c584eb8743d \
--hash=sha256:7cc09976e8b56f8cebd752f7113ad07752461f48a58cbba644139015ac24954c \
--hash=sha256:92b68146a71df78564e4ef48af17551a5ddd142e5190cdf2c5624d0c3ff5b2e8 \
--hash=sha256:9a67fc9e8eb39039280526379fb3a70023d77caec1852002b4da7e8b270c4dd9 \
--hash=sha256:afb8db5439b81cf9c9d0c80404b60c3cc9c3add93e114dcae767f1477cb53775 \
--hash=sha256:b1e74d11748e7e98e2f426ab176d4ed720a64412b6a15054378afdb71e0f37dc \
--hash=sha256:c654de545946e0db659b3400168c9ad31b5d29593291482c43e3564effbcee13 \
--hash=sha256:d68b6cef7827e8641e8ef16f4494edda8b36104d79773a334beaa1e3521430f6 \
--hash=sha256:d9b97165e8aed9272a6bb17c01e3cc5871a594a446ebedc996e2397a1c1ea8ef \
--hash=sha256:dbd5c7a25a7cb98f5ca55d258b103a2054f859a46ae11aaf23134f9cc0d356ad \
--hash=sha256:fc33c5141b55ed366cfaad382df24fe7dcbc686de5be719b207bb248e3053dc5
# via cryptography
cryptography==46.0.7 \
--hash=sha256:128c5edfe5e5938b86b03941e94fac9ee793a94452ad1365c9fc3f4f62216832 \
--hash=sha256:1d25aee46d0c6f1a501adcddb2d2fee4b979381346a78558ed13e50aa8a59067 \
--hash=sha256:24402210aa54baae71d99441d15bb5a1919c195398a87b563df84468160a65de \
--hash=sha256:35719dc79d4730d30f1c2b6474bd6acda36ae2dfae1e3c16f2051f215df33ce0 \
--hash=sha256:397655da831414d165029da9bc483bed2fe0e75dde6a1523ec2fe63f3c46046b \
--hash=sha256:420b1e4109cc95f0e5700eed79908cef9268265c773d3a66f7af1eef53d409ef \
--hash=sha256:42a1e5f98abb6391717978baf9f90dc28a743b7d9be7f0751a6f56a75d14065b \
--hash=sha256:462ad5cb1c148a22b2e3bcc5ad52504dff325d17daf5df8d88c17dda1f75f2a4 \
--hash=sha256:506c4ff91eff4f82bdac7633318a526b1d1309fc07ca76a3ad182cb5b686d6d3 \
--hash=sha256:5ad9ef796328c5e3c4ceed237a183f5d41d21150f972455a9d926593a1dcb308 \
--hash=sha256:5d1c02a14ceb9148cc7816249f64f623fbfee39e8c03b3650d842ad3f34d637e \
--hash=sha256:5e51be372b26ef4ba3de3c167cd3d1022934bc838ae9eaad7e644986d2a3d163 \
--hash=sha256:60627cf07e0d9274338521205899337c5d18249db56865f943cbe753aa96f40f \
--hash=sha256:65814c60f8cc400c63131584e3e1fad01235edba2614b61fbfbfa954082db0ee \
--hash=sha256:73510b83623e080a2c35c62c15298096e2a5dc8d51c3b4e1740211839d0dea77 \
--hash=sha256:7bbc6ccf49d05ac8f7d7b5e2e2c33830d4fe2061def88210a126d130d7f71a85 \
--hash=sha256:80406c3065e2c55d7f49a9550fe0c49b3f12e5bfff5dedb727e319e1afb9bf99 \
--hash=sha256:84d4cced91f0f159a7ddacad249cc077e63195c36aac40b4150e7a57e84fffe7 \
--hash=sha256:8a469028a86f12eb7d2fe97162d0634026d92a21f3ae0ac87ed1c4a447886c83 \
--hash=sha256:91bbcb08347344f810cbe49065914fe048949648f6bd5c2519f34619142bbe85 \
--hash=sha256:935ce7e3cfdb53e3536119a542b839bb94ec1ad081013e9ab9b7cfd478b05006 \
--hash=sha256:9694078c5d44c157ef3162e3bf3946510b857df5a3955458381d1c7cfc143ddb \
--hash=sha256:a1529d614f44b863a7b480c6d000fe93b59acee9c82ffa027cfadc77521a9f5e \
--hash=sha256:abad9dac36cbf55de6eb49badd4016806b3165d396f64925bf2999bcb67837ba \
--hash=sha256:b36a4695e29fe69215d75960b22577197aca3f7a25b9cf9d165dcfe9d80bc325 \
--hash=sha256:b7b412817be92117ec5ed95f880defe9cf18a832e8cafacf0a22337dc1981b4d \
--hash=sha256:c5b1ccd1239f48b7151a65bc6dd54bcfcc15e028c8ac126d3fada09db0e07ef1 \
--hash=sha256:cbd5fb06b62bd0721e1170273d3f4d5a277044c47ca27ee257025146c34cbdd1 \
--hash=sha256:cdf1a610ef82abb396451862739e3fc93b071c844399e15b90726ef7470eeaf2 \
--hash=sha256:cdfbe22376065ffcf8be74dc9a909f032df19bc58a699456a21712d6e5eabfd0 \
--hash=sha256:d151173275e1728cf7839aaa80c34fe550c04ddb27b34f48c232193df8db5842 \
--hash=sha256:d23c8ca48e44ee015cd0a54aeccdf9f09004eba9fc96f38c911011d9ff1bd457 \
--hash=sha256:d5f7520159cd9c2154eb61eb67548ca05c5774d39e9c2c4339fd793fe7d097b2 \
--hash=sha256:db0f493b9181c7820c8134437eb8b0b4792085d37dbb24da050476ccb664e59c \
--hash=sha256:e06acf3c99be55aa3b516397fe42f5855597f430add9c17fa46bf2e0fb34c9bb \
--hash=sha256:e4cfd68c5f3e0bfdad0d38e023239b96a2fe84146481852dffbcca442c245aa5 \
--hash=sha256:ea42cbe97209df307fdc3b155f1b6fa2577c0defa8f1f7d3be7d31d189108ad4 \
--hash=sha256:ebd6daf519b9f189f85c479427bbd6e9c9037862cf8fe89ee35503bd209ed902 \
--hash=sha256:f247c8c1a1fb45e12586afbb436ef21ff1e80670b2861a90353d9b025583d246 \
--hash=sha256:fbfd0e5f273877695cb93baf14b185f4878128b250cc9f8e617ea0c025dfb022 \
--hash=sha256:fcd8eac50d9138c1d7fc53a653ba60a2bee81a505f9f8850b6b2888555a45d0e \
--hash=sha256:fdd1736fed309b4300346f88f74cd120c27c56852c3838cab416e7a166f67298 \
--hash=sha256:ffca7aa1d00cf7d6469b988c581598f2259e46215e0140af408966a24cf086ce
# via pyopenssl
django==6.0.4 \
--hash=sha256:14359c809fc16e8f81fd2b59d7d348e4d2d799da6840b10522b6edf7b8afc1da \
--hash=sha256:8cfa2572b3f2768b2e84983cf3c4811877a01edb64e817986ec5d60751c113ac
# via
# django-apscheduler
# django-cors-headers
# django-extensions
# django-stubs
# django-stubs-ext
# djangorestframework
# djangorestframework-simplejwt
# piku-backend
django-apscheduler==0.7.0 \
--hash=sha256:30d61a2ba98615922fc2c9782f84bba342ec0c5ed63384d686d71ea90a1a4318 \
--hash=sha256:869d489775420245c9455d55e35f663c856a33ebfc996d92938f786ffb8730ce
# via piku-backend
django-cors-headers==4.9.0 \
--hash=sha256:15c7f20727f90044dcee2216a9fd7303741a864865f0c3657e28b7056f61b449 \
--hash=sha256:fe5d7cb59fdc2c8c646ce84b727ac2bca8912a247e6e68e1fb507372178e59e8
# via piku-backend
django-environ==0.13.0 \
--hash=sha256:37799d14cd78222c6fd8298e48bfe17965ff8e586091ad66a463e52e0e7b799e \
--hash=sha256:6c401e4c219442c2c4588c2116d5292b5484a6f69163ed09cd41f3943bfb645f
# via piku-backend
django-extensions==4.1 \
--hash=sha256:0699a7af28f2523bf8db309a80278519362cd4b6e1fd0a8cd4bf063e1e023336 \
--hash=sha256:7b70a4d28e9b840f44694e3f7feb54f55d495f8b3fa6c5c0e5e12bcb2aa3cdeb
# via piku-backend
django-stubs==6.0.2 \
--hash=sha256:56d43b5e3741563af0063e5b6283f908c625b0439aa06314268673699d1bdccd \
--hash=sha256:c3bc84d80421758f3b2ad9e1358e001d719388a8eb106e67c873e606216108d4
# via djangorestframework-stubs
django-stubs-ext==6.0.2 \
--hash=sha256:70b7b7ae837e7a6036e2facb64435550bf7cf8143c1a6e802864d4824ce6058c \
--hash=sha256:b35bdec1995bf49765cc39fa89aa7c23f120a23d0cb0152ab7fb4e48ff7d667b
# via django-stubs
djangorestframework==3.17.1 \
--hash=sha256:a6def5f447fe78ff853bff1d47a3c59bf38f5434b031780b351b0c73a62db1a5 \
--hash=sha256:c3c74dd3e83a5a3efc37b3c18d92bd6f86a6791c7b7d4dff62bb068500e76457
# via
# djangorestframework-simplejwt
# piku-backend
djangorestframework-simplejwt==5.5.1 \
--hash=sha256:2c30f3707053d384e9f315d11c2daccfcb548d4faa453111ca19a542b732e469 \
--hash=sha256:e72c5572f51d7803021288e2057afcbd03f17fe11d484096f40a460abc76e87f
# via piku-backend
djangorestframework-stubs==3.16.9 \
--hash=sha256:27b3e245d5f9c22ff6988d9e54388249f98f88608cc2b365b71e9f39dd096958 \
--hash=sha256:b1abb97490c90c85eabcd09b8ecbadae1b9360f21ad3021abf830227c0129697
# via piku-backend
freezegun==1.5.5 \
--hash=sha256:ac7742a6cc6c25a2c35e9292dfd554b897b517d2dec26891a2e8debf205cb94a \
--hash=sha256:cd557f4a75cf074e84bc374249b9dd491eaeacd61376b9eb3c423282211619d2
# via piku-backend
gunicorn==25.3.0 \
--hash=sha256:cacea387dab08cd6776501621c295a904fe8e3b7aae9a1a3cbb26f4e7ed54660 \
--hash=sha256:f74e1b2f9f76f6cd1ca01198968bd2dd65830edc24b6e8e4d78de8320e2fe889
# via piku-backend
markupsafe==3.0.3 \
--hash=sha256:0eb9ff8191e8498cca014656ae6b8d61f39da5f95b488805da4bb029cccbfbaf \
--hash=sha256:1085e7fbddd3be5f89cc898938f42c0b3c711fdcb37d75221de2666af647c175 \
--hash=sha256:1353ef0c1b138e1907ae78e2f6c63ff67501122006b0f9abad68fda5f4ffc6ab \
--hash=sha256:1b52b4fb9df4eb9ae465f8d0c228a00624de2334f216f178a995ccdcf82c4634 \
--hash=sha256:2713baf880df847f2bece4230d4d094280f4e67b1e813eec43b4c0e144a34ffe \
--hash=sha256:32001d6a8fc98c8cb5c947787c5d08b0a50663d139f1305bac5885d98d9b40fa \
--hash=sha256:457a69a9577064c05a97c41f4e65148652db078a3a509039e64d3467b9e7ef97 \
--hash=sha256:4faffd047e07c38848ce017e8725090413cd80cbc23d86e55c587bf979e579c9 \
--hash=sha256:5678211cb9333a6468fb8d8be0305520aa073f50d17f089b5b4b477ea6e67fdc \
--hash=sha256:5a7d5dc5140555cf21a6fefbdbf8723f06fcd2f63ef108f2854de715e4422cb4 \
--hash=sha256:722695808f4b6457b320fdc131280796bdceb04ab50fe1795cd540799ebe1698 \
--hash=sha256:729586769a26dbceff69f7a7dbbf59ab6572b99d94576a5592625d5b411576b9 \
--hash=sha256:915c04ba3851909ce68ccc2b8e2cd691618c4dc4c4232fb7982bca3f41fd8c3d \
--hash=sha256:bdc919ead48f234740ad807933cdf545180bfbe9342c2bb451556db2ed958581 \
--hash=sha256:c47a551199eb8eb2121d4f0f15ae0f923d31350ab9280078d1e5f12b249e0026 \
--hash=sha256:e56b7d45a839a697b5eb268c82a71bd8c7f6c94d6fd50c3d577fa39a9f1409f5 \
--hash=sha256:e8afc3f2ccfa24215f8cb28dcf43f0113ac3c37c2f0f0806d8c70e4228c5cf4d \
--hash=sha256:eaa9599de571d72e2daf60164784109f19978b327a3910d3e9de8c97b5b70cfe \
--hash=sha256:ec15a59cf5af7be74194f7ab02d0f59a62bdcf1a537677ce67a2537c9b87fcda \
--hash=sha256:f190daf01f13c72eac4efd5c430a8de82489d9cff23c364c3ea822545032993e \
--hash=sha256:f34c41761022dd093b4b6896d4810782ffbabe30f2d443ff5f083e0cbbb8c737 \
--hash=sha256:f3e98bb3798ead92273dc0e5fd0f31ade220f59a266ffd8a4f6065e0a3ce0523 \
--hash=sha256:fed51ac40f757d41b7c48425901843666a6677e3e8eb0abcff09e4ba6e664f50
# via werkzeug
packaging==26.1 \
--hash=sha256:5d9c0669c6285e491e0ced2eee587eaf67b670d94a19e94e3984a481aba6802f \
--hash=sha256:f042152b681c4bfac5cae2742a55e103d27ab2ec0f3d88037136b6bfe7c9c5de
# via gunicorn
psycopg2-binary==2.9.11 \
--hash=sha256:32770a4d666fbdafab017086655bcddab791d7cb260a16679cc5a7338b64343b \
--hash=sha256:4012c9c954dfaccd28f94e84ab9f94e12df76b4afb22331b1f0d3154893a6316 \
--hash=sha256:47f212c1d3be608a12937cc131bd85502954398aaa1320cb4c14421a0ffccf4c \
--hash=sha256:92e3b669236327083a2e33ccfa0d320dd01b9803b3e14dd986a4fc54aa00f4e1 \
--hash=sha256:9b52a3f9bb540a3e4ec0f6ba6d31339727b2950c9772850d6545b7eae0b9d7c5 \
--hash=sha256:9bd81e64e8de111237737b29d68039b9c813bdf520156af36d26819c9a979e5f \
--hash=sha256:b6aed9e096bf63f9e75edf2581aa9a7e7186d97ab5c177aa6c87797cd591236c \
--hash=sha256:c3cb3a676873d7506825221045bd70e0427c905b9c8ee8d6acd70cfcbd6e576d \
--hash=sha256:db4fd476874ccfdbb630a54426964959e58da4c61c9feba73e6094d51303d7d8 \
--hash=sha256:e0deeb03da539fa3577fcb0b3f2554a97f7e5477c246098dbb18091a4a01c16f \
--hash=sha256:e35b7abae2b0adab776add56111df1735ccc71406e56203515e228a8dc07089f \
--hash=sha256:fcf21be3ce5f5659daefd2b3b3b6e4727b028221ddc94e6c1523425579664747
# via piku-backend
pycparser==3.0 ; implementation_name != 'PyPy' and platform_python_implementation != 'PyPy' \
--hash=sha256:600f49d217304a5902ac3c37e1281c9fe94e4d0489de643a9504c5cdfdfc6b29 \
--hash=sha256:b727414169a36b7d524c1c3e31839a521725078d7b2ff038656844266160a992
# via cffi
pyjwt==2.12.1 \
--hash=sha256:28ca37c070cad8ba8cd9790cd940535d40274d22f80ab87f3ac6a713e6e8454c \
--hash=sha256:c74a7a2adf861c04d002db713dd85f84beb242228e671280bf709d765b03672b
# via djangorestframework-simplejwt
pyopenssl==26.0.0 \
--hash=sha256:df94d28498848b98cc1c0ffb8ef1e71e40210d3b0a8064c9d29571ed2904bf81 \
--hash=sha256:f293934e52936f2e3413b89c6ce36df66a0b34ae1ea3a053b8c5020ff2f513fc
# via piku-backend
python-dateutil==2.9.0.post0 \
--hash=sha256:37dd54208da7e1cd875388217d5e00ebd4179249f90fb72437e91a35459a0ad3 \
--hash=sha256:a8b2bc7bffae282281c8140a97d3aa9c14da0b136dfe83f850eea9a5f7470427
# via freezegun
ruff==0.15.9 \
--hash=sha256:058d8e99e1bfe79d8a0def0b481c56059ee6716214f7e425d8e737e412d69677 \
--hash=sha256:0694e601c028fd97dc5c6ee244675bc241aeefced7ef80cd9c6935a871078f53 \
--hash=sha256:29cbb1255a9797903f6dde5ba0188c707907ff44a9006eb273b5a17bfa0739a2 \
--hash=sha256:2b0c7c341f68adb01c488c3b7d4b49aa8ea97409eae6462d860a79cf55f431b6 \
--hash=sha256:45a70921b80e1c10cf0b734ef09421f71b5aa11d27404edc89d7e8a69505e43d \
--hash=sha256:4965bac6ac9ea86772f4e23587746f0b7a395eccabb823eb8bfacc3fa06069f7 \
--hash=sha256:55cc15eee27dc0eebdfcb0d185a6153420efbedc15eb1d38fe5e685657b0f840 \
--hash=sha256:6d3fcbca7388b066139c523bda744c822258ebdcfbba7d24410c3f454cc9af71 \
--hash=sha256:6efbe303983441c51975c243e26dff328aca11f94b70992f35b093c2e71801e1 \
--hash=sha256:7b34a9766aeec27a222373d0b055722900fbc0582b24f39661aa96f3fe6ad901 \
--hash=sha256:89dd695bc72ae76ff484ae54b7e8b0f6b50f49046e198355e44ea656e521fef9 \
--hash=sha256:8e1ddb11dbd61d5983fa2d7d6370ef3eb210951e443cace19594c01c72abab4c \
--hash=sha256:9439a342adb8725f32f92732e2bafb6d5246bd7a5021101166b223d312e8fc59 \
--hash=sha256:9c5e6faf9d97c8edc43877c3f406f47446fc48c40e1442d58cfcdaba2acea745 \
--hash=sha256:a6537f6eed5cda688c81073d46ffdfb962a5f29ecb6f7e770b2dc920598997ed \
--hash=sha256:bde6ff36eaf72b700f32b7196088970bf8fdb2b917b7accd8c371bfc0fd573ec \
--hash=sha256:ce187224ef1de1bd225bc9a152ac7102a6171107f026e81f317e4257052916d5 \
--hash=sha256:eaf05aad70ca5b5a0a4b0e080df3a6b699803916d88f006efd1f5b46302daab8
# via piku-backend
six==1.17.0 \
--hash=sha256:4721f391ed90541fddacab5acf947aa0d3dc7d27b2e1e8eda2be8970586c3274 \
--hash=sha256:ff70335d468e7eb6ec65b95b99d3a2836546063f63acc5171de367e834932a81
# via python-dateutil
sqlparse==0.5.5 \
--hash=sha256:12a08b3bf3eec877c519589833aed092e2444e68240a3577e8e26148acc7b1ba \
--hash=sha256:e20d4a9b0b8585fdf63b10d30066c7c94c5d7a7ec47c889a2d83a3caa93ff28e
# via django
types-pyyaml==6.0.12.20260408 \
--hash=sha256:92a73f2b8d7f39ef392a38131f76b970f8c66e4c42b3125ae872b7c93b556307 \
--hash=sha256:fbc42037d12159d9c801ebfcc79ebd28335a7c13b08a4cfbc6916df78fee9384
# via
# django-stubs
# djangorestframework-stubs
typing-extensions==4.15.0 \
--hash=sha256:0cea48d173cc12fa28ecabc3b837ea3cf6f38c6d1136f85cbaaf598984861466 \
--hash=sha256:f0fa19c6845758ab08074a0cfa8b7aecb71c999ca73d62883bc25cc018c4e548
# via
# django-stubs
# django-stubs-ext
# djangorestframework-stubs
tzdata==2026.1 ; sys_platform == 'win32' \
--hash=sha256:4b1d2be7ac37ceafd7327b961aa3a54e467efbdb563a23655fbfe0d39cfc42a9 \
--hash=sha256:67658a1903c75917309e753fdc349ac0efd8c27db7a0cb406a25be4840f87f98
# via
# django
# tzlocal
tzlocal==5.3.1 \
--hash=sha256:cceffc7edecefea1f595541dbd6e990cb1ea3d19bf01b2809f362a03dd7921fd \
--hash=sha256:eb1a66c3ef5847adf7a834f1be0800581b683b5608e74f86ecbcef8ab91bb85d
# via apscheduler
werkzeug==3.1.8 \
--hash=sha256:63a77fb8892bf28ebc3178683445222aa500e48ebad5ec77b0ad80f8726b1f50 \
--hash=sha256:9bad61a4268dac112f1c5cd4630a56ede601b6ed420300677a869083d70a4c44
# via piku-backend
+1 -2
View File
@@ -12,8 +12,7 @@ class Command(BaseCommand):
If SSL is enabled, use runserver_plus command. If SSL is enabled, use runserver_plus command.
If SSL is not enabled, use runserver command. If SSL is not enabled, use runserver command.
""" """
ssl_enabled = os.getenv("SSL_ENABLED", "false").lower().strip() == "true" ssl_enabled = os.getenv("SSL_ENABLED", "false").lower() == "true"
domain = os.getenv("BACKEND_DOMAIN", "127.0.0.1") domain = os.getenv("BACKEND_DOMAIN", "127.0.0.1")
port = os.getenv("BACKEND_PORT", "8000") port = os.getenv("BACKEND_PORT", "8000")
addrport = f"{domain}:{port}" addrport = f"{domain}:{port}"
+1 -1
View File
@@ -8,7 +8,7 @@ from django.utils.http import urlsafe_base64_encode
def send_activation_email(user): def send_activation_email(user):
token = default_token_generator.make_token(user) token = default_token_generator.make_token(user)
uid = urlsafe_base64_encode(force_bytes(user.public_id)) uid = urlsafe_base64_encode(force_bytes(user.public_id))
activation_url = f"{settings.FRONTEND_URLS[0]}/activate/{uid}/{token}" activation_url = f"{settings.FRONTEND_URL}/activate/{uid}/{token}"
subject = "Activate Your Piku Account" subject = "Activate Your Piku Account"
message = f"""Hi {user.full_name}, message = f"""Hi {user.full_name},
-329
View File
@@ -2,18 +2,6 @@ version = 1
revision = 3 revision = 3
requires-python = ">=3.14" requires-python = ">=3.14"
[[package]]
name = "apscheduler"
version = "3.11.2"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "tzlocal" },
]
sdist = { url = "https://files.pythonhosted.org/packages/07/12/3e4389e5920b4c1763390c6d371162f3784f86f85cd6d6c1bfe68eef14e2/apscheduler-3.11.2.tar.gz", hash = "sha256:2a9966b052ec805f020c8c4c3ae6e6a06e24b1bf19f2e11d91d8cca0473eef41", size = 108683, upload-time = "2025-12-22T00:39:34.884Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/9f/64/2e54428beba8d9992aa478bb8f6de9e4ecaa5f8f513bcfd567ed7fb0262d/apscheduler-3.11.2-py3-none-any.whl", hash = "sha256:ce005177f741409db4e4dd40a7431b76feb856b9dd69d57e0da49d6715bfd26d", size = 64439, upload-time = "2025-12-22T00:39:33.303Z" },
]
[[package]] [[package]]
name = "asgiref" name = "asgiref"
version = "3.11.1" version = "3.11.1"
@@ -23,34 +11,6 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/5c/0a/a72d10ed65068e115044937873362e6e32fab1b7dce0046aeb224682c989/asgiref-3.11.1-py3-none-any.whl", hash = "sha256:e8667a091e69529631969fd45dc268fa79b99c92c5fcdda727757e52146ec133", size = 24345, upload-time = "2026-02-03T13:30:13.039Z" }, { url = "https://files.pythonhosted.org/packages/5c/0a/a72d10ed65068e115044937873362e6e32fab1b7dce0046aeb224682c989/asgiref-3.11.1-py3-none-any.whl", hash = "sha256:e8667a091e69529631969fd45dc268fa79b99c92c5fcdda727757e52146ec133", size = 24345, upload-time = "2026-02-03T13:30:13.039Z" },
] ]
[[package]]
name = "boto3"
version = "1.42.96"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "botocore" },
{ name = "jmespath" },
{ name = "s3transfer" },
]
sdist = { url = "https://files.pythonhosted.org/packages/a6/2d/69fb3acd50bab83fb295c167d33c4b653faeb5fb0f42bfca4d9b69d6fb68/boto3-1.42.96.tar.gz", hash = "sha256:b38a9e4a3fbbee9017252576f1379780d0a5814768676c08df2f539d31fcdd68", size = 113203, upload-time = "2026-04-24T19:47:18.677Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/2b/9d/b3f617d011c42eb804d993103b8fa9acdce153e181a3042f58bfe33d7cb4/boto3-1.42.96-py3-none-any.whl", hash = "sha256:2f4566da2c209a98bdbfc874d813ef231c84ad24e4f815e9bc91de5f63351a24", size = 140557, upload-time = "2026-04-24T19:47:15.824Z" },
]
[[package]]
name = "botocore"
version = "1.42.96"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "jmespath" },
{ name = "python-dateutil" },
{ name = "urllib3" },
]
sdist = { url = "https://files.pythonhosted.org/packages/61/77/2c333622a1d47cf5bf73cdcab0cb6c92addafbef2ec05f81b9f75687d9e5/botocore-1.42.96.tar.gz", hash = "sha256:75b3b841ffacaa944f645196655a21ca777591dd8911e732bfb6614545af0250", size = 15263344, upload-time = "2026-04-24T19:47:05.283Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/45/56/152c3a859ca1b9d77ed16deac3cf81682013677c68cf5715698781fc81bd/botocore-1.42.96-py3-none-any.whl", hash = "sha256:db2c3e2006628be6fde81a24124a6563c363d6982fb92728837cf174bad9d98a", size = 14945920, upload-time = "2026-04-24T19:47:00.323Z" },
]
[[package]] [[package]]
name = "cffi" name = "cffi"
version = "2.0.0" version = "2.0.0"
@@ -151,19 +111,6 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/e9/47/3d61d611609764aa71a37f7037b870e7bfb22937366974c4fd46cada7bab/django-6.0.4-py3-none-any.whl", hash = "sha256:14359c809fc16e8f81fd2b59d7d348e4d2d799da6840b10522b6edf7b8afc1da", size = 8368342, upload-time = "2026-04-07T13:55:37.999Z" }, { url = "https://files.pythonhosted.org/packages/e9/47/3d61d611609764aa71a37f7037b870e7bfb22937366974c4fd46cada7bab/django-6.0.4-py3-none-any.whl", hash = "sha256:14359c809fc16e8f81fd2b59d7d348e4d2d799da6840b10522b6edf7b8afc1da", size = 8368342, upload-time = "2026-04-07T13:55:37.999Z" },
] ]
[[package]]
name = "django-apscheduler"
version = "0.7.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "apscheduler" },
{ name = "django" },
]
sdist = { url = "https://files.pythonhosted.org/packages/64/6b/873899c2da113187b74f0cccdf4c16660e07bfbbcae72621c4758e0958bf/django_apscheduler-0.7.0.tar.gz", hash = "sha256:30d61a2ba98615922fc2c9782f84bba342ec0c5ed63384d686d71ea90a1a4318", size = 473051, upload-time = "2024-09-28T04:54:09.98Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/e0/19/c3d2dea21a6afdc93689b9f769ff3694cac810e4a09c24ab423dd1613e6c/django_apscheduler-0.7.0-py3-none-any.whl", hash = "sha256:869d489775420245c9455d55e35f663c856a33ebfc996d92938f786ffb8730ce", size = 24690, upload-time = "2024-09-28T04:54:06.884Z" },
]
[[package]] [[package]]
name = "django-cors-headers" name = "django-cors-headers"
version = "4.9.0" version = "4.9.0"
@@ -198,73 +145,6 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/64/96/d967ca440d6a8e3861120f51985d8e5aec79b9a8bdda16041206adfe7adc/django_extensions-4.1-py3-none-any.whl", hash = "sha256:0699a7af28f2523bf8db309a80278519362cd4b6e1fd0a8cd4bf063e1e023336", size = 232980, upload-time = "2025-04-11T01:15:37.701Z" }, { url = "https://files.pythonhosted.org/packages/64/96/d967ca440d6a8e3861120f51985d8e5aec79b9a8bdda16041206adfe7adc/django_extensions-4.1-py3-none-any.whl", hash = "sha256:0699a7af28f2523bf8db309a80278519362cd4b6e1fd0a8cd4bf063e1e023336", size = 232980, upload-time = "2025-04-11T01:15:37.701Z" },
] ]
[[package]]
name = "django-ipware"
version = "7.0.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "python-ipware" },
]
sdist = { url = "https://files.pythonhosted.org/packages/23/64/c7e4791edf01ba483cce444770b3e6a930ba12195ba1eeb37b5bf6dce8a8/django-ipware-7.0.1.tar.gz", hash = "sha256:d9ec43d2bf7cdf216fed8d494a084deb5761a54860a53b2e74346a4f384cff47", size = 6827, upload-time = "2024-04-19T20:02:49.257Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/11/33/bf539925b102d68200da5b1d3eacb8aa5d5d9a065972e8b8724d0d53bb0d/django_ipware-7.0.1-py2.py3-none-any.whl", hash = "sha256:db16bbee920f661ae7f678e4270460c85850f03c6761a4eaeb489bdc91f64709", size = 6425, upload-time = "2024-04-19T20:02:47.469Z" },
]
[[package]]
name = "django-storages"
version = "1.14.6"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "django" },
]
sdist = { url = "https://files.pythonhosted.org/packages/ff/d6/2e50e378fff0408d558f36c4acffc090f9a641fd6e084af9e54d45307efa/django_storages-1.14.6.tar.gz", hash = "sha256:7a25ce8f4214f69ac9c7ce87e2603887f7ae99326c316bc8d2d75375e09341c9", size = 87587, upload-time = "2025-04-02T02:34:55.103Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/1f/21/3cedee63417bc5553eed0c204be478071c9ab208e5e259e97287590194f1/django_storages-1.14.6-py3-none-any.whl", hash = "sha256:11b7b6200e1cb5ffcd9962bd3673a39c7d6a6109e8096f0e03d46fab3d3aabd9", size = 33095, upload-time = "2025-04-02T02:34:53.291Z" },
]
[[package]]
name = "django-structlog"
version = "10.0.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "asgiref" },
{ name = "django" },
{ name = "django-ipware" },
{ name = "structlog" },
]
sdist = { url = "https://files.pythonhosted.org/packages/72/a9/102f316fb60dbec46642168979c4f57c9d8140fe43624ddca1ca6106274a/django_structlog-10.0.0.tar.gz", hash = "sha256:4e3fa4a930697fb9b649470e389419bb73b916a1ecf4f4bf2f8727f5cbfdb002", size = 23054, upload-time = "2025-10-22T21:20:21.14Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/6c/83/d7245a2a2bb46ae65ecff00686181d632553b131ea0c5cbfcbdb8f89c190/django_structlog-10.0.0-py3-none-any.whl", hash = "sha256:4f9db3cb7b308df6aa4afe1353d9c19d5bac757022ddbbb5c24f3d0d6a91a240", size = 18159, upload-time = "2025-10-22T21:20:19.804Z" },
]
[[package]]
name = "django-stubs"
version = "6.0.2"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "django" },
{ name = "django-stubs-ext" },
{ name = "types-pyyaml" },
{ name = "typing-extensions" },
]
sdist = { url = "https://files.pythonhosted.org/packages/03/b2/f0214d86180f937c8e3358ff831b20f0634d95bd77436b18861c647e15bc/django_stubs-6.0.2.tar.gz", hash = "sha256:56d43b5e3741563af0063e5b6283f908c625b0439aa06314268673699d1bdccd", size = 274742, upload-time = "2026-04-01T08:27:35.092Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/49/e7/8f2aaa22eac7fa18db3aca0e7b651ccf5ac79a2021bf67e75a16934a7076/django_stubs-6.0.2-py3-none-any.whl", hash = "sha256:c3bc84d80421758f3b2ad9e1358e001d719388a8eb106e67c873e606216108d4", size = 538234, upload-time = "2026-04-01T08:27:33.411Z" },
]
[[package]]
name = "django-stubs-ext"
version = "6.0.2"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "django" },
{ name = "typing-extensions" },
]
sdist = { url = "https://files.pythonhosted.org/packages/52/e0/f2e6caf627d176a51fba1ca9c34082c7ea10d3f521ff2c828532ca99fa91/django_stubs_ext-6.0.2.tar.gz", hash = "sha256:70b7b7ae837e7a6036e2facb64435550bf7cf8143c1a6e802864d4824ce6058c", size = 6751, upload-time = "2026-04-01T08:27:01.987Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/2b/d2/9cb93cd1ef94ddc97c26c902ff75a859f5f154051fec98cf8242649b26ce/django_stubs_ext-6.0.2-py3-none-any.whl", hash = "sha256:b35bdec1995bf49765cc39fa89aa7c23f120a23d0cb0152ab7fb4e48ff7d667b", size = 10446, upload-time = "2026-04-01T08:27:00.847Z" },
]
[[package]] [[package]]
name = "djangorestframework" name = "djangorestframework"
version = "3.17.1" version = "3.17.1"
@@ -291,65 +171,6 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/60/94/fdfb7b2f0b16cd3ed4d4171c55c1c07a2d1e3b106c5978c8ad0c15b4a48b/djangorestframework_simplejwt-5.5.1-py3-none-any.whl", hash = "sha256:2c30f3707053d384e9f315d11c2daccfcb548d4faa453111ca19a542b732e469", size = 107674, upload-time = "2025-07-21T16:52:07.493Z" }, { url = "https://files.pythonhosted.org/packages/60/94/fdfb7b2f0b16cd3ed4d4171c55c1c07a2d1e3b106c5978c8ad0c15b4a48b/djangorestframework_simplejwt-5.5.1-py3-none-any.whl", hash = "sha256:2c30f3707053d384e9f315d11c2daccfcb548d4faa453111ca19a542b732e469", size = 107674, upload-time = "2025-07-21T16:52:07.493Z" },
] ]
[[package]]
name = "djangorestframework-stubs"
version = "3.16.9"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "django-stubs" },
{ name = "types-pyyaml" },
{ name = "typing-extensions" },
]
sdist = { url = "https://files.pythonhosted.org/packages/30/84/fa0e31f763ee35152a418c2a456efdd8047a9da0f5909110147b70382191/djangorestframework_stubs-3.16.9.tar.gz", hash = "sha256:b1abb97490c90c85eabcd09b8ecbadae1b9360f21ad3021abf830227c0129697", size = 32798, upload-time = "2026-03-31T22:40:23.626Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/5a/be/e53e3b89eaa30c21e036ae4d2ee88a92ef8cb43678400901748ddad870c5/djangorestframework_stubs-3.16.9-py3-none-any.whl", hash = "sha256:27b3e245d5f9c22ff6988d9e54388249f98f88608cc2b365b71e9f39dd096958", size = 57239, upload-time = "2026-03-31T22:40:22.314Z" },
]
[[package]]
name = "freezegun"
version = "1.5.5"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "python-dateutil" },
]
sdist = { url = "https://files.pythonhosted.org/packages/95/dd/23e2f4e357f8fd3bdff613c1fe4466d21bfb00a6177f238079b17f7b1c84/freezegun-1.5.5.tar.gz", hash = "sha256:ac7742a6cc6c25a2c35e9292dfd554b897b517d2dec26891a2e8debf205cb94a", size = 35914, upload-time = "2025-08-09T10:39:08.338Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/5e/2e/b41d8a1a917d6581fc27a35d05561037b048e47df50f27f8ac9c7e27a710/freezegun-1.5.5-py3-none-any.whl", hash = "sha256:cd557f4a75cf074e84bc374249b9dd491eaeacd61376b9eb3c423282211619d2", size = 19266, upload-time = "2025-08-09T10:39:06.636Z" },
]
[[package]]
name = "gunicorn"
version = "25.3.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "packaging" },
]
sdist = { url = "https://files.pythonhosted.org/packages/c4/f4/e78fa054248fab913e2eab0332c6c2cb07421fca1ce56d8fe43b6aef57a4/gunicorn-25.3.0.tar.gz", hash = "sha256:f74e1b2f9f76f6cd1ca01198968bd2dd65830edc24b6e8e4d78de8320e2fe889", size = 634883, upload-time = "2026-03-27T00:00:26.092Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/43/c8/8aaf447698c4d59aa853fd318eed300b5c9e44459f242ab8ead6c9c09792/gunicorn-25.3.0-py3-none-any.whl", hash = "sha256:cacea387dab08cd6776501621c295a904fe8e3b7aae9a1a3cbb26f4e7ed54660", size = 208403, upload-time = "2026-03-27T00:00:27.386Z" },
]
[[package]]
name = "jmespath"
version = "1.1.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/d3/59/322338183ecda247fb5d1763a6cbe46eff7222eaeebafd9fa65d4bf5cb11/jmespath-1.1.0.tar.gz", hash = "sha256:472c87d80f36026ae83c6ddd0f1d05d4e510134ed462851fd5f754c8c3cbb88d", size = 27377, upload-time = "2026-01-22T16:35:26.279Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/14/2f/967ba146e6d58cf6a652da73885f52fc68001525b4197effc174321d70b4/jmespath-1.1.0-py3-none-any.whl", hash = "sha256:a5663118de4908c91729bea0acadca56526eb2698e83de10cd116ae0f4e97c64", size = 20419, upload-time = "2026-01-22T16:35:24.919Z" },
]
[[package]]
name = "markdown-it-py"
version = "4.0.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "mdurl" },
]
sdist = { url = "https://files.pythonhosted.org/packages/5b/f5/4ec618ed16cc4f8fb3b701563655a69816155e79e24a17b651541804721d/markdown_it_py-4.0.0.tar.gz", hash = "sha256:cb0a2b4aa34f932c007117b194e945bd74e0ec24133ceb5bac59009cda1cb9f3", size = 73070, upload-time = "2025-08-11T12:57:52.854Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/94/54/e7d793b573f298e1c9013b8c4dade17d481164aa517d1d7148619c2cedbf/markdown_it_py-4.0.0-py3-none-any.whl", hash = "sha256:87327c59b172c5011896038353a81343b6754500a08cd7a4973bb48c6d578147", size = 87321, upload-time = "2025-08-11T12:57:51.923Z" },
]
[[package]] [[package]]
name = "markupsafe" name = "markupsafe"
version = "3.0.3" version = "3.0.3"
@@ -380,72 +201,34 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/70/bc/6f1c2f612465f5fa89b95bead1f44dcb607670fd42891d8fdcd5d039f4f4/markupsafe-3.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:32001d6a8fc98c8cb5c947787c5d08b0a50663d139f1305bac5885d98d9b40fa", size = 14146, upload-time = "2025-09-27T18:37:28.327Z" }, { url = "https://files.pythonhosted.org/packages/70/bc/6f1c2f612465f5fa89b95bead1f44dcb607670fd42891d8fdcd5d039f4f4/markupsafe-3.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:32001d6a8fc98c8cb5c947787c5d08b0a50663d139f1305bac5885d98d9b40fa", size = 14146, upload-time = "2025-09-27T18:37:28.327Z" },
] ]
[[package]]
name = "mdurl"
version = "0.1.2"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/d6/54/cfe61301667036ec958cb99bd3efefba235e65cdeb9c84d24a8293ba1d90/mdurl-0.1.2.tar.gz", hash = "sha256:bb413d29f5eea38f31dd4754dd7377d4465116fb207585f97bf925588687c1ba", size = 8729, upload-time = "2022-08-14T12:40:10.846Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/b3/38/89ba8ad64ae25be8de66a6d463314cf1eb366222074cfda9ee839c56a4b4/mdurl-0.1.2-py3-none-any.whl", hash = "sha256:84008a41e51615a49fc9966191ff91509e3c40b939176e643fd50a5c2196b8f8", size = 9979, upload-time = "2022-08-14T12:40:09.779Z" },
]
[[package]]
name = "packaging"
version = "26.1"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/df/de/0d2b39fb4af88a0258f3bac87dfcbb48e73fbdea4a2ed0e2213f9a4c2f9a/packaging-26.1.tar.gz", hash = "sha256:f042152b681c4bfac5cae2742a55e103d27ab2ec0f3d88037136b6bfe7c9c5de", size = 215519, upload-time = "2026-04-14T21:12:49.362Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/7a/c2/920ef838e2f0028c8262f16101ec09ebd5969864e5a64c4c05fad0617c56/packaging-26.1-py3-none-any.whl", hash = "sha256:5d9c0669c6285e491e0ced2eee587eaf67b670d94a19e94e3984a481aba6802f", size = 95831, upload-time = "2026-04-14T21:12:47.56Z" },
]
[[package]] [[package]]
name = "piku-backend" name = "piku-backend"
version = "0.1.0" version = "0.1.0"
source = { virtual = "." } source = { virtual = "." }
dependencies = [ dependencies = [
{ name = "apscheduler" },
{ name = "boto3" },
{ name = "django" }, { name = "django" },
{ name = "django-apscheduler" },
{ name = "django-cors-headers" }, { name = "django-cors-headers" },
{ name = "django-environ" }, { name = "django-environ" },
{ name = "django-extensions" }, { name = "django-extensions" },
{ name = "django-storages" },
{ name = "django-structlog" },
{ name = "djangorestframework" }, { name = "djangorestframework" },
{ name = "djangorestframework-simplejwt" }, { name = "djangorestframework-simplejwt" },
{ name = "djangorestframework-stubs" },
{ name = "freezegun" },
{ name = "gunicorn" },
{ name = "psycopg2-binary" }, { name = "psycopg2-binary" },
{ name = "pyopenssl" }, { name = "pyopenssl" },
{ name = "rich" },
{ name = "ruff" }, { name = "ruff" },
{ name = "structlog" },
{ name = "werkzeug" }, { name = "werkzeug" },
] ]
[package.metadata] [package.metadata]
requires-dist = [ requires-dist = [
{ name = "apscheduler", specifier = ">=3.11.2" },
{ name = "boto3", specifier = ">=1.42.96" },
{ name = "django", specifier = ">=6.0.4" }, { name = "django", specifier = ">=6.0.4" },
{ name = "django-apscheduler", specifier = ">=0.7.0" },
{ name = "django-cors-headers", specifier = ">=4.9.0" }, { name = "django-cors-headers", specifier = ">=4.9.0" },
{ name = "django-environ", specifier = ">=0.13.0" }, { name = "django-environ", specifier = ">=0.13.0" },
{ name = "django-extensions", specifier = ">=4.1" }, { name = "django-extensions", specifier = ">=4.1" },
{ name = "django-storages", specifier = ">=1.14.6" },
{ name = "django-structlog", specifier = ">=10.0.0" },
{ name = "djangorestframework", specifier = ">=3.17.1" }, { name = "djangorestframework", specifier = ">=3.17.1" },
{ name = "djangorestframework-simplejwt", specifier = ">=5.5.1" }, { name = "djangorestframework-simplejwt", specifier = ">=5.5.1" },
{ name = "djangorestframework-stubs", specifier = ">=3.16.9" },
{ name = "freezegun", specifier = ">=1.5.5" },
{ name = "gunicorn", specifier = ">=25.3.0" },
{ name = "psycopg2-binary", specifier = ">=2.9.11" }, { name = "psycopg2-binary", specifier = ">=2.9.11" },
{ name = "pyopenssl", specifier = ">=26.0.0" }, { name = "pyopenssl", specifier = ">=26.0.0" },
{ name = "rich", specifier = ">=15.0.0" },
{ name = "ruff", specifier = ">=0.15.9" }, { name = "ruff", specifier = ">=0.15.9" },
{ name = "structlog", specifier = ">=25.5.0" },
{ name = "werkzeug", specifier = ">=3.1.8" }, { name = "werkzeug", specifier = ">=3.1.8" },
] ]
@@ -477,15 +260,6 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/0c/c3/44f3fbbfa403ea2a7c779186dc20772604442dde72947e7d01069cbe98e3/pycparser-3.0-py3-none-any.whl", hash = "sha256:b727414169a36b7d524c1c3e31839a521725078d7b2ff038656844266160a992", size = 48172, upload-time = "2026-01-21T14:26:50.693Z" }, { url = "https://files.pythonhosted.org/packages/0c/c3/44f3fbbfa403ea2a7c779186dc20772604442dde72947e7d01069cbe98e3/pycparser-3.0-py3-none-any.whl", hash = "sha256:b727414169a36b7d524c1c3e31839a521725078d7b2ff038656844266160a992", size = 48172, upload-time = "2026-01-21T14:26:50.693Z" },
] ]
[[package]]
name = "pygments"
version = "2.20.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/c3/b2/bc9c9196916376152d655522fdcebac55e66de6603a76a02bca1b6414f6c/pygments-2.20.0.tar.gz", hash = "sha256:6757cd03768053ff99f3039c1a36d6c0aa0b263438fcab17520b30a303a82b5f", size = 4955991, upload-time = "2026-03-29T13:29:33.898Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/f4/7e/a72dd26f3b0f4f2bf1dd8923c85f7ceb43172af56d63c7383eb62b332364/pygments-2.20.0-py3-none-any.whl", hash = "sha256:81a9e26dd42fd28a23a2d169d86d7ac03b46e2f8b59ed4698fb4785f946d0176", size = 1231151, upload-time = "2026-03-29T13:29:30.038Z" },
]
[[package]] [[package]]
name = "pyjwt" name = "pyjwt"
version = "2.12.1" version = "2.12.1"
@@ -507,40 +281,6 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/fb/7d/d4f7d908fa8415571771b30669251d57c3cf313b36a856e6d7548ae01619/pyopenssl-26.0.0-py3-none-any.whl", hash = "sha256:df94d28498848b98cc1c0ffb8ef1e71e40210d3b0a8064c9d29571ed2904bf81", size = 57969, upload-time = "2026-03-15T14:28:24.864Z" }, { url = "https://files.pythonhosted.org/packages/fb/7d/d4f7d908fa8415571771b30669251d57c3cf313b36a856e6d7548ae01619/pyopenssl-26.0.0-py3-none-any.whl", hash = "sha256:df94d28498848b98cc1c0ffb8ef1e71e40210d3b0a8064c9d29571ed2904bf81", size = 57969, upload-time = "2026-03-15T14:28:24.864Z" },
] ]
[[package]]
name = "python-dateutil"
version = "2.9.0.post0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "six" },
]
sdist = { url = "https://files.pythonhosted.org/packages/66/c0/0c8b6ad9f17a802ee498c46e004a0eb49bc148f2fd230864601a86dcf6db/python-dateutil-2.9.0.post0.tar.gz", hash = "sha256:37dd54208da7e1cd875388217d5e00ebd4179249f90fb72437e91a35459a0ad3", size = 342432, upload-time = "2024-03-01T18:36:20.211Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/ec/57/56b9bcc3c9c6a792fcbaf139543cee77261f3651ca9da0c93f5c1221264b/python_dateutil-2.9.0.post0-py2.py3-none-any.whl", hash = "sha256:a8b2bc7bffae282281c8140a97d3aa9c14da0b136dfe83f850eea9a5f7470427", size = 229892, upload-time = "2024-03-01T18:36:18.57Z" },
]
[[package]]
name = "python-ipware"
version = "3.0.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/9e/60/da4426c3e9aee56f08b24091a9e85a0414260f928f97afd0013dfbd0332f/python_ipware-3.0.0.tar.gz", hash = "sha256:9117b1c4dddcb5d5ca49e6a9617de2fc66aec2ef35394563ac4eecabdf58c062", size = 16609, upload-time = "2024-04-19T20:00:58.938Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/08/bd/ccd7416fdb30f104ddf6cfd8ee9f699441c7d9880a26f9b3089438adee05/python_ipware-3.0.0-py3-none-any.whl", hash = "sha256:fc936e6e7ec9fcc107f9315df40658f468ac72f739482a707181742882e36b60", size = 10761, upload-time = "2024-04-19T20:00:57.171Z" },
]
[[package]]
name = "rich"
version = "15.0.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "markdown-it-py" },
{ name = "pygments" },
]
sdist = { url = "https://files.pythonhosted.org/packages/c0/8f/0722ca900cc807c13a6a0c696dacf35430f72e0ec571c4275d2371fca3e9/rich-15.0.0.tar.gz", hash = "sha256:edd07a4824c6b40189fb7ac9bc4c52536e9780fbbfbddf6f1e2502c31b068c36", size = 230680, upload-time = "2026-04-12T08:24:00.75Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/82/3b/64d4899d73f91ba49a8c18a8ff3f0ea8f1c1d75481760df8c68ef5235bf5/rich-15.0.0-py3-none-any.whl", hash = "sha256:33bd4ef74232fb73fe9279a257718407f169c09b78a87ad3d296f548e27de0bb", size = 310654, upload-time = "2026-04-12T08:24:02.83Z" },
]
[[package]] [[package]]
name = "ruff" name = "ruff"
version = "0.15.9" version = "0.15.9"
@@ -566,27 +306,6 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/03/36/76704c4f312257d6dbaae3c959add2a622f63fcca9d864659ce6d8d97d3d/ruff-0.15.9-py3-none-win_arm64.whl", hash = "sha256:0694e601c028fd97dc5c6ee244675bc241aeefced7ef80cd9c6935a871078f53", size = 11005870, upload-time = "2026-04-02T18:17:15.773Z" }, { url = "https://files.pythonhosted.org/packages/03/36/76704c4f312257d6dbaae3c959add2a622f63fcca9d864659ce6d8d97d3d/ruff-0.15.9-py3-none-win_arm64.whl", hash = "sha256:0694e601c028fd97dc5c6ee244675bc241aeefced7ef80cd9c6935a871078f53", size = 11005870, upload-time = "2026-04-02T18:17:15.773Z" },
] ]
[[package]]
name = "s3transfer"
version = "0.16.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "botocore" },
]
sdist = { url = "https://files.pythonhosted.org/packages/46/29/af14f4ef3c11a50435308660e2cc68761c9a7742475e0585cd4396b91777/s3transfer-0.16.1.tar.gz", hash = "sha256:8e424355754b9ccb32467bdc568edf55be82692ef2002d934b1311dbb3b9e524", size = 154801, upload-time = "2026-04-22T20:36:06.475Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/03/19/90d7d4ed51932c022d53f1d02d564b62d10e272692a1f9b76425c1ad2a02/s3transfer-0.16.1-py3-none-any.whl", hash = "sha256:61bcd00ccb83b21a0fe7e91a553fff9729d46c83b4e0106e7c314a733891f7c2", size = 86825, upload-time = "2026-04-22T20:36:04.992Z" },
]
[[package]]
name = "six"
version = "1.17.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/94/e7/b2c673351809dca68a0e064b6af791aa332cf192da575fd474ed7d6f16a2/six-1.17.0.tar.gz", hash = "sha256:ff70335d468e7eb6ec65b95b99d3a2836546063f63acc5171de367e834932a81", size = 34031, upload-time = "2024-12-04T17:35:28.174Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/b7/ce/149a00dd41f10bc29e5921b496af8b574d8413afcd5e30dfa0ed46c2cc5e/six-1.17.0-py2.py3-none-any.whl", hash = "sha256:4721f391ed90541fddacab5acf947aa0d3dc7d27b2e1e8eda2be8970586c3274", size = 11050, upload-time = "2024-12-04T17:35:26.475Z" },
]
[[package]] [[package]]
name = "sqlparse" name = "sqlparse"
version = "0.5.5" version = "0.5.5"
@@ -596,33 +315,6 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/49/4b/359f28a903c13438ef59ebeee215fb25da53066db67b305c125f1c6d2a25/sqlparse-0.5.5-py3-none-any.whl", hash = "sha256:12a08b3bf3eec877c519589833aed092e2444e68240a3577e8e26148acc7b1ba", size = 46138, upload-time = "2025-12-19T07:17:46.573Z" }, { url = "https://files.pythonhosted.org/packages/49/4b/359f28a903c13438ef59ebeee215fb25da53066db67b305c125f1c6d2a25/sqlparse-0.5.5-py3-none-any.whl", hash = "sha256:12a08b3bf3eec877c519589833aed092e2444e68240a3577e8e26148acc7b1ba", size = 46138, upload-time = "2025-12-19T07:17:46.573Z" },
] ]
[[package]]
name = "structlog"
version = "25.5.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/ef/52/9ba0f43b686e7f3ddfeaa78ac3af750292662284b3661e91ad5494f21dbc/structlog-25.5.0.tar.gz", hash = "sha256:098522a3bebed9153d4570c6d0288abf80a031dfdb2048d59a49e9dc2190fc98", size = 1460830, upload-time = "2025-10-27T08:28:23.028Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/a8/45/a132b9074aa18e799b891b91ad72133c98d8042c70f6240e4c5f9dabee2f/structlog-25.5.0-py3-none-any.whl", hash = "sha256:a8453e9b9e636ec59bd9e79bbd4a72f025981b3ba0f5837aebf48f02f37a7f9f", size = 72510, upload-time = "2025-10-27T08:28:21.535Z" },
]
[[package]]
name = "types-pyyaml"
version = "6.0.12.20260408"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/74/73/b759b1e413c31034cc01ecdfb96b38115d0ab4db55a752a3929f0cd449fd/types_pyyaml-6.0.12.20260408.tar.gz", hash = "sha256:92a73f2b8d7f39ef392a38131f76b970f8c66e4c42b3125ae872b7c93b556307", size = 17735, upload-time = "2026-04-08T04:30:50.974Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/1c/f0/c391068b86abb708882c6d75a08cd7d25b2c7227dab527b3a3685a3c635b/types_pyyaml-6.0.12.20260408-py3-none-any.whl", hash = "sha256:fbc42037d12159d9c801ebfcc79ebd28335a7c13b08a4cfbc6916df78fee9384", size = 20339, upload-time = "2026-04-08T04:30:50.113Z" },
]
[[package]]
name = "typing-extensions"
version = "4.15.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/72/94/1a15dd82efb362ac84269196e94cf00f187f7ed21c242792a923cdb1c61f/typing_extensions-4.15.0.tar.gz", hash = "sha256:0cea48d173cc12fa28ecabc3b837ea3cf6f38c6d1136f85cbaaf598984861466", size = 109391, upload-time = "2025-08-25T13:49:26.313Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/18/67/36e9267722cc04a6b9f15c7f3441c2363321a3ea07da7ae0c0707beb2a9c/typing_extensions-4.15.0-py3-none-any.whl", hash = "sha256:f0fa19c6845758ab08074a0cfa8b7aecb71c999ca73d62883bc25cc018c4e548", size = 44614, upload-time = "2025-08-25T13:49:24.86Z" },
]
[[package]] [[package]]
name = "tzdata" name = "tzdata"
version = "2026.1" version = "2026.1"
@@ -632,27 +324,6 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/b0/70/d460bd685a170790ec89317e9bd33047988e4bce507b831f5db771e142de/tzdata-2026.1-py2.py3-none-any.whl", hash = "sha256:4b1d2be7ac37ceafd7327b961aa3a54e467efbdb563a23655fbfe0d39cfc42a9", size = 348952, upload-time = "2026-04-03T11:25:20.313Z" }, { url = "https://files.pythonhosted.org/packages/b0/70/d460bd685a170790ec89317e9bd33047988e4bce507b831f5db771e142de/tzdata-2026.1-py2.py3-none-any.whl", hash = "sha256:4b1d2be7ac37ceafd7327b961aa3a54e467efbdb563a23655fbfe0d39cfc42a9", size = 348952, upload-time = "2026-04-03T11:25:20.313Z" },
] ]
[[package]]
name = "tzlocal"
version = "5.3.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "tzdata", marker = "sys_platform == 'win32'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/8b/2e/c14812d3d4d9cd1773c6be938f89e5735a1f11a9f184ac3639b93cef35d5/tzlocal-5.3.1.tar.gz", hash = "sha256:cceffc7edecefea1f595541dbd6e990cb1ea3d19bf01b2809f362a03dd7921fd", size = 30761, upload-time = "2025-03-05T21:17:41.549Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/c2/14/e2a54fabd4f08cd7af1c07030603c3356b74da07f7cc056e600436edfa17/tzlocal-5.3.1-py3-none-any.whl", hash = "sha256:eb1a66c3ef5847adf7a834f1be0800581b683b5608e74f86ecbcef8ab91bb85d", size = 18026, upload-time = "2025-03-05T21:17:39.857Z" },
]
[[package]]
name = "urllib3"
version = "2.6.3"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/c7/24/5f1b3bdffd70275f6661c76461e25f024d5a38a46f04aaca912426a2b1d3/urllib3-2.6.3.tar.gz", hash = "sha256:1b62b6884944a57dbe321509ab94fd4d3b307075e0c2eae991ac71ee15ad38ed", size = 435556, upload-time = "2026-01-07T16:24:43.925Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/39/08/aaaad47bc4e9dc8c725e68f9d04865dbcb2052843ff09c97b08904852d84/urllib3-2.6.3-py3-none-any.whl", hash = "sha256:bf272323e553dfb2e87d9bfd225ca7b0f467b919d7bbd355436d3fd37cb0acd4", size = 131584, upload-time = "2026-01-07T16:24:42.685Z" },
]
[[package]] [[package]]
name = "werkzeug" name = "werkzeug"
version = "3.1.8" version = "3.1.8"
+1 -1
View File
@@ -42,7 +42,7 @@
"noUnusedVariables": "error" "noUnusedVariables": "error"
} }
}, },
"includes": ["**", "!backend"] "includes": ["**/src", "!backend"]
}, },
"assist": { "assist": {
"actions": { "actions": {
-1
View File
@@ -1,4 +1,3 @@
name: piku_e2e
services: services:
db: db:
image: postgres:16-alpine image: postgres:16-alpine
+2
View File
@@ -2,6 +2,7 @@ services:
db: db:
# postgres database # postgres database
image: postgres:16-alpine image: postgres:16-alpine
container_name: piku_db
environment: environment:
POSTGRES_DB: ${DB_NAME} POSTGRES_DB: ${DB_NAME}
POSTGRES_USER: ${DB_USER} POSTGRES_USER: ${DB_USER}
@@ -15,6 +16,7 @@ services:
mailpit: mailpit:
# email testing # email testing
image: axllent/mailpit image: axllent/mailpit
container_name: piku_mail
ports: ports:
- "8025:8025" # Web UI - "8025:8025" # Web UI
- "${EMAIL_PORT}:1025" # SMTP - "${EMAIL_PORT}:1025" # SMTP
-6
View File
@@ -1,6 +0,0 @@
node_modules
test-results
playwright-report
dist
coverage
-32
View File
@@ -1,32 +0,0 @@
FROM oven/bun:1 AS bun
WORKDIR /app
COPY package.json bun.lock* ./
RUN bun install --frozen-lockfile
COPY . .
ARG BACKEND_DOMAIN
ARG BACKEND_PORT
ARG SSL_ENABLED
ARG VITE_API_URL
ENV BACKEND_DOMAIN=$BACKEND_DOMAIN
ENV BACKEND_PORT=$BACKEND_PORT
ENV SSL_ENABLED=$SSL_ENABLED
ENV VITE_API_URL=$VITE_API_URL
RUN bun run build:prod
FROM nginxinc/nginx-unprivileged:alpine-slim
RUN touch /tmp/access.log /tmp/error.log
RUN rm /etc/nginx/conf.d/*
COPY nginx.conf /etc/nginx/nginx.conf
COPY --from=bun /app/dist /usr/share/nginx/html
# transfer the ownership since nginx is running rootless
USER root
RUN chown -R nginx:nginx /usr/share/nginx/html
USER nginx
EXPOSE 8080
ENTRYPOINT ["nginx", "-e", "/tmp/error.log", "-g", "daemon off;"]
+34 -83
View File
@@ -45,8 +45,8 @@ test.describe("Letter Drafting (Real Backend)", () => {
await page.keyboard.type("This is a secret draft"); await page.keyboard.type("This is a secret draft");
await page.keyboard.press("Enter"); await page.keyboard.press("Enter");
await page.keyboard.type("It should persist."); await page.keyboard.type("It should persist.");
logger.info(">> [Draft] Clicking Draft..."); logger.info(">> [Draft] Clicking Store...");
await page.getByRole("button", { name: /draft/i }).click(); await page.getByRole("button", { name: /store/i }).click();
// Verify Success Modal/Alert // Verify Success Modal/Alert
await expect(page.getByText(/your letter is saved/i)).toBeVisible(); await expect(page.getByText(/your letter is saved/i)).toBeVisible();
@@ -76,9 +76,7 @@ test.describe("Letter Drafting (Real Backend)", () => {
await expect(canvasInput).toHaveValue(/It should persist/i); await expect(canvasInput).toHaveValue(/It should persist/i);
}); });
test("should seal a letter and navigate to Reader, then share on demand", async ({ test("should seal a letter and show sharing link", async ({ page }) => {
page,
}) => {
const timestamp = Date.now() + Math.random(); const timestamp = Date.now() + Math.random();
const email = `seal-${timestamp}@example.com`; const email = `seal-${timestamp}@example.com`;
const name = `Seal Author ${timestamp}`; const name = `Seal Author ${timestamp}`;
@@ -86,67 +84,39 @@ test.describe("Letter Drafting (Real Backend)", () => {
await AuthHelper.registerAndLogin(page, email, name, password); await AuthHelper.registerAndLogin(page, email, name, password);
logger.info(">> [Seal] Navigating to Editor via UI..."); logger.info(">> [Seal] Navigating to Editor via UI...");
await page.locator("#write-letter-btn").click(); await page.getByRole("button", { name: /write something/i }).click();
const recipientInput = page.locator("#recipient"); const recipientInput = page.locator("#recipient");
await recipientInput.waitFor({ state: "visible", timeout: 10000 }); await recipientInput.waitFor({ state: "visible", timeout: 20000 });
await recipientInput.fill("A Secret Guest"); await recipientInput.fill("A Secret Guest");
const canvasInput = page.getByLabel("Canvas text input"); const canvasInput = page.getByLabel("Canvas text input");
await canvasInput.focus(); await canvasInput.focus();
await canvasInput.fill("This letter will be sealed and shared."); await canvasInput.fill("This letter will be sealed and shared.");
// Click Seal (open menu, then confirm) // Click Seal
logger.info(">> [Seal] Clicking Seal..."); logger.info(">> [Seal] Clicking Seal...");
await page await page.getByRole("button", { name: /seal/i }).click();
.getByRole("button", { name: /seal/i })
.filter({ visible: true })
.click();
await page
.getByRole("button", { name: /seal/i })
.filter({ visible: true })
.click();
// Should show sealed confirmation modal // Verify "Sealed & Ready" modal
logger.info(">> [Seal] Verifying sealed modal..."); logger.info(">> [Seal] Verifying sharing modal...");
await expect(page.getByText(/your letter is sealed/i)).toBeVisible({ await expect(page.getByText(/sealed & ready/i)).toBeVisible();
timeout: 10000,
});
// Navigate to Reader via "View letter" // Verify sharing link contains a hash (the key)
await page.getByRole("button", { name: /view letter/i }).click(); const linkInput = page.locator("input[readOnly]");
// Should be on Reader URL
await expect(page).toHaveURL(/\/read\/[a-f0-9-]{36}$/, { timeout: 15000 });
// Open the envelope to reveal the letter
await expect(page.getByText(/breaking the seal/i)).toBeHidden({
timeout: 10000,
});
// Flip the envelope to show the seal
await page.locator("#env-front").click();
await page.waitForTimeout(2500); // Wait for flip transition
await page.getByAltText("Seal").click();
await page.waitForTimeout(1500);
await page.locator("#letter").click({ position: { x: 30, y: 15 } });
await expect(page.locator("#letter")).toBeHidden({ timeout: 20000 });
// Share on demand
logger.info(">> [Seal] Clicking Share button in Reader...");
await page.locator("#share-letter-btn").click();
// Verify share modal with a valid link
await expect(page.getByText(/send this letter/i)).toBeVisible();
const linkInput = page.locator("#share-link-input");
const linkValue = await linkInput.inputValue(); const linkValue = await linkInput.inputValue();
expect(linkValue).toContain("/read/"); expect(linkValue).toContain("/read/");
expect(linkValue).toContain("#"); expect(linkValue).toContain("#");
logger.info(`>> [Seal] Sharing link: ${linkValue}`);
logger.info(`>> [Seal] Sharing link generated: ${linkValue}`);
// Verify "Copy" button works
await expect(page.getByRole("button", { name: /copy/i })).toBeVisible(); await expect(page.getByRole("button", { name: /copy/i })).toBeVisible();
// Close modal
await page.getByRole("button", { name: /close/i }).click(); await page.getByRole("button", { name: /close/i }).click();
await expect(page.getByText(/send this letter/i)).toBeHidden(); await expect(page.getByText(/sealed & ready/i)).toBeHidden();
}); });
test("should allow author to access sealed letter from drawer without sharing key", async ({ test("should allow author to access sealed letter from drawer without sharing key", async ({
@@ -171,21 +141,16 @@ test.describe("Letter Drafting (Real Backend)", () => {
await canvasInput.focus(); await canvasInput.focus();
await canvasInput.fill(letterContent); await canvasInput.fill(letterContent);
// Click Seal (open menu, then confirm) // Click Seal
await page await page.getByRole("button", { name: /seal/i }).click();
.getByRole("button", { name: /seal/i }) await expect(page.getByText(/sealed & ready/i)).toBeVisible();
.filter({ visible: true })
.click();
await page
.getByRole("button", { name: /seal/i })
.filter({ visible: true })
.click();
// Sealed modal should appear — click "Keep it" to go to Drawer // Close modal
await expect(page.getByText(/your letter is sealed/i)).toBeVisible({ await page.getByRole("button", { name: /close/i }).click();
timeout: 10000,
}); // Navigate to Drawer - use ID or precise label
await page.getByRole("button", { name: /keep it/i }).click(); logger.info(">> [Drawer] Navigating to Drawer...");
await page.locator("button[aria-label='Open Drawer']").click();
// Open "Kept" section - search for the section with id='kept' and click its toggle button // Open "Kept" section - search for the section with id='kept' and click its toggle button
logger.info(">> [Drawer] Opening Kept section..."); logger.info(">> [Drawer] Opening Kept section...");
@@ -203,28 +168,14 @@ test.describe("Letter Drafting (Real Backend)", () => {
logger.info(">> [Drawer] Verifying Reader page..."); logger.info(">> [Drawer] Verifying Reader page...");
// Give it a bit more time for decryption // Give it a bit more time for decryption
await expect(page).toHaveURL(/\/read\/[a-f0-9-]{36}$/, { timeout: 15000 }); // UUID without hash await expect(page).toHaveURL(/\/read\/[a-f0-9-]{36}$/, { timeout: 15000 }); // UUID without hash
// Reveal and check decrypted content in Reader
await expect(page.getByText(/breaking the seal/i)).toBeHidden({ // Check decrypted content in Reader
await expect(page.getByText(/decrypting/i)).toBeHidden({
timeout: 10000, timeout: 10000,
}); });
// Check recipient on the front of the envelope await expect(
await expect(page.getByText(new RegExp(recipientName, "i"))).toBeVisible(); page.getByText(new RegExp(`A sealed letter for ${recipientName}`, "i")),
).toBeVisible();
// Flip the envelope to the back
await page.getByText(new RegExp(recipientName, "i")).click();
// Wait for flip transition (2s)
await page.waitForTimeout(2500);
// Reveal the letter: click seal then click letter
await page.getByAltText("Seal").click();
// Wait for flap transition
await page.waitForTimeout(1500);
// Click the letter to pull it out
await page.locator("#letter").click({ position: { x: 30, y: 15 } });
// Wait for reveal transition
await expect(page.locator("#letter")).toBeHidden({ timeout: 20000 });
// Also check if we are redirected to the Reader if we manually go to the Editor URL // Also check if we are redirected to the Reader if we manually go to the Editor URL
const readerUrl = page.url(); const readerUrl = page.url();
+1 -1
View File
@@ -23,7 +23,7 @@ export async function registerAndLogin(
// 1. Registration // 1. Registration
logger.info(`[Auth] Registering user: ${email}`); logger.info(`[Auth] Registering user: ${email}`);
await page.goto("/onboard"); await page.goto("/onboard");
await page.getByLabel(/pen name/i).fill(fullName); await page.getByLabel(/full name/i).fill(fullName);
await page.getByLabel("Email", { exact: true }).fill(email); await page.getByLabel("Email", { exact: true }).fill(email);
await page.getByLabel("Password", { exact: true }).fill(password); await page.getByLabel("Password", { exact: true }).fill(password);
await page.getByLabel(/confirm password/i).fill(password); await page.getByLabel(/confirm password/i).fill(password);
+1 -1
View File
@@ -23,7 +23,7 @@ export const MailpitHelper = {
}); });
if (response.ok()) { if (response.ok()) {
const data: { messages: MailpitMessage[] } = await response.json(); const data = await response.json();
if (data.messages?.length > 0) { if (data.messages?.length > 0) {
const msgId = data.messages[0].ID; const msgId = data.messages[0].ID;
const detailRes = await requestContext.get( const detailRes = await requestContext.get(
-23
View File
@@ -1,23 +0,0 @@
pid /tmp/nginx.pid;
events {
worker_connections 1024;
}
http {
include /etc/nginx/mime.types;
access_log /tmp/access.log;
error_log /tmp/error.log;
server {
listen 8080;
server_name _;
root /usr/share/nginx/html;
index index.html;
location / {
try_files $uri /index.html;
}
}
}
+2 -3
View File
@@ -5,8 +5,7 @@
"type": "module", "type": "module",
"scripts": { "scripts": {
"dev": "vite", "dev": "vite",
"build": "tsc -b & vite build", "build": "tsc -b && vite build",
"build:prod": "vite build --mode production",
"lint": "biome lint --write ./src", "lint": "biome lint --write ./src",
"format": "biome format --write ./src", "format": "biome format --write ./src",
"check": "biome check --write ./src", "check": "biome check --write ./src",
@@ -16,7 +15,7 @@
"test:watch": "vitest", "test:watch": "vitest",
"test:coverage": "vitest run --coverage", "test:coverage": "vitest run --coverage",
"test:e2e": "playwright test", "test:e2e": "playwright test",
"test:e2e:ui": "playwright test --ui --ui-host=0.0.0.0 --ui-port=43008" "test:e2e:ui": "playwright test --ui"
}, },
"dependencies": { "dependencies": {
"@fontsource-variable/jost": "^5.2.8", "@fontsource-variable/jost": "^5.2.8",
+2 -1
View File
@@ -14,6 +14,7 @@ const baseUrl = getBaseUrl(
env.FRONTEND_PORT, env.FRONTEND_PORT,
); );
console.log(baseUrl);
export default defineConfig({ export default defineConfig({
timeout: 60000, timeout: 60000,
expect: { expect: {
@@ -60,7 +61,7 @@ export default defineConfig({
/* Run your local dev server before starting the tests */ /* Run your local dev server before starting the tests */
webServer: { webServer: {
command: "npm run dev -- --mode e2e", command: "bun run dev -- --mode e2e",
url: getBaseUrl( url: getBaseUrl(
process.env.SSL_ENABLED === "true", process.env.SSL_ENABLED === "true",
process.env.FRONTEND_DOMAIN, process.env.FRONTEND_DOMAIN,
+24
View File
@@ -0,0 +1,24 @@
<svg xmlns="http://www.w3.org/2000/svg">
<symbol id="bluesky-icon" viewBox="0 0 16 17">
<g clip-path="url(#bluesky-clip)"><path fill="#08060d" d="M7.75 7.735c-.693-1.348-2.58-3.86-4.334-5.097-1.68-1.187-2.32-.981-2.74-.79C.188 2.065.1 2.812.1 3.251s.241 3.602.398 4.13c.52 1.744 2.367 2.333 4.07 2.145-2.495.37-4.71 1.278-1.805 4.512 3.196 3.309 4.38-.71 4.987-2.746.608 2.036 1.307 5.91 4.93 2.746 2.72-2.746.747-4.143-1.747-4.512 1.702.189 3.55-.4 4.07-2.145.156-.528.397-3.691.397-4.13s-.088-1.186-.575-1.406c-.42-.19-1.06-.395-2.741.79-1.755 1.24-3.64 3.752-4.334 5.099"/></g>
<defs><clipPath id="bluesky-clip"><path fill="#fff" d="M.1.85h15.3v15.3H.1z"/></clipPath></defs>
</symbol>
<symbol id="discord-icon" viewBox="0 0 20 19">
<path fill="#08060d" d="M16.224 3.768a14.5 14.5 0 0 0-3.67-1.153c-.158.286-.343.67-.47.976a13.5 13.5 0 0 0-4.067 0c-.128-.306-.317-.69-.476-.976A14.4 14.4 0 0 0 3.868 3.77C1.546 7.28.916 10.703 1.231 14.077a14.7 14.7 0 0 0 4.5 2.306q.545-.748.965-1.587a9.5 9.5 0 0 1-1.518-.74q.191-.14.372-.293c2.927 1.369 6.107 1.369 8.999 0q.183.152.372.294-.723.437-1.52.74.418.838.963 1.588a14.6 14.6 0 0 0 4.504-2.308c.37-3.911-.63-7.302-2.644-10.309m-9.13 8.234c-.878 0-1.599-.82-1.599-1.82 0-.998.705-1.82 1.6-1.82.894 0 1.614.82 1.599 1.82.001 1-.705 1.82-1.6 1.82m5.91 0c-.878 0-1.599-.82-1.599-1.82 0-.998.705-1.82 1.6-1.82.893 0 1.614.82 1.599 1.82 0 1-.706 1.82-1.6 1.82"/>
</symbol>
<symbol id="documentation-icon" viewBox="0 0 21 20">
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="m15.5 13.333 1.533 1.322c.645.555.967.833.967 1.178s-.322.623-.967 1.179L15.5 18.333m-3.333-5-1.534 1.322c-.644.555-.966.833-.966 1.178s.322.623.966 1.179l1.534 1.321"/>
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M17.167 10.836v-4.32c0-1.41 0-2.117-.224-2.68-.359-.906-1.118-1.621-2.08-1.96-.599-.21-1.349-.21-2.848-.21-2.623 0-3.935 0-4.983.369-1.684.591-3.013 1.842-3.641 3.428C3 6.449 3 7.684 3 10.154v2.122c0 2.558 0 3.838.706 4.726q.306.383.713.671c.76.536 1.79.64 3.581.66"/>
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M3 10a2.78 2.78 0 0 1 2.778-2.778c.555 0 1.209.097 1.748-.047.48-.129.854-.503.982-.982.145-.54.048-1.194.048-1.749a2.78 2.78 0 0 1 2.777-2.777"/>
</symbol>
<symbol id="github-icon" viewBox="0 0 19 19">
<path fill="#08060d" fill-rule="evenodd" d="M9.356 1.85C5.05 1.85 1.57 5.356 1.57 9.694a7.84 7.84 0 0 0 5.324 7.44c.387.079.528-.168.528-.376 0-.182-.013-.805-.013-1.454-2.165.467-2.616-.935-2.616-.935-.349-.91-.864-1.143-.864-1.143-.71-.48.051-.48.051-.48.787.051 1.2.805 1.2.805.695 1.194 1.817.857 2.268.649.064-.507.27-.857.49-1.052-1.728-.182-3.545-.857-3.545-3.87 0-.857.31-1.558.8-2.104-.078-.195-.349-1 .077-2.078 0 0 .657-.208 2.14.805a7.5 7.5 0 0 1 1.946-.26c.657 0 1.328.092 1.946.26 1.483-1.013 2.14-.805 2.14-.805.426 1.078.155 1.883.078 2.078.502.546.799 1.247.799 2.104 0 3.013-1.818 3.675-3.558 3.87.284.247.528.714.528 1.454 0 1.052-.012 1.896-.012 2.156 0 .208.142.455.528.377a7.84 7.84 0 0 0 5.324-7.441c.013-4.338-3.48-7.844-7.773-7.844" clip-rule="evenodd"/>
</symbol>
<symbol id="social-icon" viewBox="0 0 20 20">
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M12.5 6.667a4.167 4.167 0 1 0-8.334 0 4.167 4.167 0 0 0 8.334 0"/>
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M2.5 16.667a5.833 5.833 0 0 1 8.75-5.053m3.837.474.513 1.035c.07.144.257.282.414.309l.93.155c.596.1.736.536.307.965l-.723.73a.64.64 0 0 0-.152.531l.207.903c.164.715-.213.991-.84.618l-.872-.52a.63.63 0 0 0-.577 0l-.872.52c-.624.373-1.003.094-.84-.618l.207-.903a.64.64 0 0 0-.152-.532l-.723-.729c-.426-.43-.289-.864.306-.964l.93-.156a.64.64 0 0 0 .412-.31l.513-1.034c.28-.562.735-.562 1.012 0"/>
</symbol>
<symbol id="x-icon" viewBox="0 0 19 19">
<path fill="#08060d" fill-rule="evenodd" d="M1.893 1.98c.052.072 1.245 1.769 2.653 3.77l2.892 4.114c.183.261.333.48.333.486s-.068.089-.152.183l-.522.593-.765.867-3.597 4.087c-.375.426-.734.834-.798.905a1 1 0 0 0-.118.148c0 .01.236.017.664.017h.663l.729-.83c.4-.457.796-.906.879-.999a692 692 0 0 0 1.794-2.038c.034-.037.301-.34.594-.675l.551-.624.345-.392a7 7 0 0 1 .34-.374c.006 0 .93 1.306 2.052 2.903l2.084 2.965.045.063h2.275c1.87 0 2.273-.003 2.266-.021-.008-.02-1.098-1.572-3.894-5.547-2.013-2.862-2.28-3.246-2.273-3.266.008-.019.282-.332 2.085-2.38l2-2.274 1.567-1.782c.022-.028-.016-.03-.65-.03h-.674l-.3.342a871 871 0 0 1-1.782 2.025c-.067.075-.405.458-.75.852a100 100 0 0 1-.803.91c-.148.172-.299.344-.99 1.127-.304.343-.32.358-.345.327-.015-.019-.904-1.282-1.976-2.808L6.365 1.85H1.8zm1.782.91 8.078 11.294c.772 1.08 1.413 1.973 1.425 1.984.016.017.241.02 1.05.017l1.03-.004-2.694-3.766L7.796 5.75 5.722 2.852l-1.039-.004-1.039-.004z" clip-rule="evenodd"/>
</symbol>
</svg>

After

Width:  |  Height:  |  Size: 4.9 KiB

+10 -12
View File
@@ -1,21 +1,21 @@
import { lazy, Suspense, useEffect } from "react"; import { useEffect } from "react";
import { BrowserRouter, Navigate, Route, Routes } from "react-router-dom"; import { BrowserRouter, Navigate, Route, Routes } from "react-router-dom";
import { ProtectedRoute, PublicRoute } from "./components/RouteGuards"; import { ProtectedRoute, PublicRoute } from "./components/RouteGuards";
import SplashScreen from "./components/SplashScreen"; import SplashScreen from "./components/SplashScreen";
import { ROUTES } from "./config/routes"; import { ROUTES } from "./config/routes";
import { useAuth } from "./hooks/useAuth"; import { useAuth } from "./hooks/useAuth";
import Activate from "./pages/Activate";
import Drawer from "./pages/Drawer";
import Editor from "./pages/Editor";
// Pages
import Home from "./pages/Home";
import Login from "./pages/Login";
import Reader from "./pages/Reader";
import Register from "./pages/Register";
import VerifyEmail from "./pages/VerifyEmail";
let authInitialized = false; let authInitialized = false;
const Activate = lazy(() => import("./pages/Activate"));
const Drawer = lazy(() => import("./pages/Drawer"));
const Editor = lazy(() => import("./pages/Editor"));
const Home = lazy(() => import("./pages/Home"));
const Login = lazy(() => import("./pages/Login"));
const Reader = lazy(() => import("./pages/Reader"));
const Register = lazy(() => import("./pages/Register"));
const VerifyEmail = lazy(() => import("./pages/VerifyEmail"));
export default function App() { export default function App() {
const { initialize, isInitializing } = useAuth(); const { initialize, isInitializing } = useAuth();
@@ -32,7 +32,6 @@ export default function App() {
return ( return (
<BrowserRouter> <BrowserRouter>
<main className="min-h-screen bg-base-200 flex items-center justify-center w-full"> <main className="min-h-screen bg-base-200 flex items-center justify-center w-full">
<Suspense fallback={<SplashScreen />}>
<Routes> <Routes>
<Route path={ROUTES.HOME} element={<Home />} /> <Route path={ROUTES.HOME} element={<Home />} />
@@ -88,7 +87,6 @@ export default function App() {
<Route path={ROUTES.READ} element={<Reader />} /> <Route path={ROUTES.READ} element={<Reader />} />
<Route path="*" element={<Navigate to={ROUTES.HOME} replace />} /> <Route path="*" element={<Navigate to={ROUTES.HOME} replace />} />
</Routes> </Routes>
</Suspense>
</main> </main>
</BrowserRouter> </BrowserRouter>
); );
+13 -2
View File
@@ -1,5 +1,13 @@
import { HttpResponse, http } from "msw"; import { HttpResponse, http } from "msw";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import {
afterAll,
beforeAll,
beforeEach,
describe,
expect,
it,
vi,
} from "vitest";
import { mockUser } from "../../test/fixtures/user.fixture"; import { mockUser } from "../../test/fixtures/user.fixture";
import { server } from "../../test/mocks/server"; import { server } from "../../test/mocks/server";
import { useAuthStore } from "../store/useAuthStore"; import { useAuthStore } from "../store/useAuthStore";
@@ -13,10 +21,13 @@ beforeEach(() => {
user: null, user: null,
isInitializing: false, isInitializing: false,
}); });
});
beforeAll(() => {
vi.stubEnv("VITE_API_URL", VITE_API_URL); vi.stubEnv("VITE_API_URL", VITE_API_URL);
}); });
afterEach(() => { afterAll(() => {
vi.unstubAllEnvs(); vi.unstubAllEnvs();
}); });
Binary file not shown.

Before

Width:  |  Height:  |  Size: 8.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 7.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 44 KiB

+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="35.93" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 228"><path fill="#00D8FF" d="M210.483 73.824a171.49 171.49 0 0 0-8.24-2.597c.465-1.9.893-3.777 1.273-5.621c6.238-30.281 2.16-54.676-11.769-62.708c-13.355-7.7-35.196.329-57.254 19.526a171.23 171.23 0 0 0-6.375 5.848a155.866 155.866 0 0 0-4.241-3.917C100.759 3.829 77.587-4.822 63.673 3.233C50.33 10.957 46.379 33.89 51.995 62.588a170.974 170.974 0 0 0 1.892 8.48c-3.28.932-6.445 1.924-9.474 2.98C17.309 83.498 0 98.307 0 113.668c0 15.865 18.582 31.778 46.812 41.427a145.52 145.52 0 0 0 6.921 2.165a167.467 167.467 0 0 0-2.01 9.138c-5.354 28.2-1.173 50.591 12.134 58.266c13.744 7.926 36.812-.22 59.273-19.855a145.567 145.567 0 0 0 5.342-4.923a168.064 168.064 0 0 0 6.92 6.314c21.758 18.722 43.246 26.282 56.54 18.586c13.731-7.949 18.194-32.003 12.4-61.268a145.016 145.016 0 0 0-1.535-6.842c1.62-.48 3.21-.974 4.76-1.488c29.348-9.723 48.443-25.443 48.443-41.52c0-15.417-17.868-30.326-45.517-39.844Zm-6.365 70.984c-1.4.463-2.836.91-4.3 1.345c-3.24-10.257-7.612-21.163-12.963-32.432c5.106-11 9.31-21.767 12.459-31.957c2.619.758 5.16 1.557 7.61 2.4c23.69 8.156 38.14 20.213 38.14 29.504c0 9.896-15.606 22.743-40.946 31.14Zm-10.514 20.834c2.562 12.94 2.927 24.64 1.23 33.787c-1.524 8.219-4.59 13.698-8.382 15.893c-8.067 4.67-25.32-1.4-43.927-17.412a156.726 156.726 0 0 1-6.437-5.87c7.214-7.889 14.423-17.06 21.459-27.246c12.376-1.098 24.068-2.894 34.671-5.345a134.17 134.17 0 0 1 1.386 6.193ZM87.276 214.515c-7.882 2.783-14.16 2.863-17.955.675c-8.075-4.657-11.432-22.636-6.853-46.752a156.923 156.923 0 0 1 1.869-8.499c10.486 2.32 22.093 3.988 34.498 4.994c7.084 9.967 14.501 19.128 21.976 27.15a134.668 134.668 0 0 1-4.877 4.492c-9.933 8.682-19.886 14.842-28.658 17.94ZM50.35 144.747c-12.483-4.267-22.792-9.812-29.858-15.863c-6.35-5.437-9.555-10.836-9.555-15.216c0-9.322 13.897-21.212 37.076-29.293c2.813-.98 5.757-1.905 8.812-2.773c3.204 10.42 7.406 21.315 12.477 32.332c-5.137 11.18-9.399 22.249-12.634 32.792a134.718 134.718 0 0 1-6.318-1.979Zm12.378-84.26c-4.811-24.587-1.616-43.134 6.425-47.789c8.564-4.958 27.502 2.111 47.463 19.835a144.318 144.318 0 0 1 3.841 3.545c-7.438 7.987-14.787 17.08-21.808 26.988c-12.04 1.116-23.565 2.908-34.161 5.309a160.342 160.342 0 0 1-1.76-7.887Zm110.427 27.268a347.8 347.8 0 0 0-7.785-12.803c8.168 1.033 15.994 2.404 23.343 4.08c-2.206 7.072-4.956 14.465-8.193 22.045a381.151 381.151 0 0 0-7.365-13.322Zm-45.032-43.861c5.044 5.465 10.096 11.566 15.065 18.186a322.04 322.04 0 0 0-30.257-.006c4.974-6.559 10.069-12.652 15.192-18.18ZM82.802 87.83a323.167 323.167 0 0 0-7.227 13.238c-3.184-7.553-5.909-14.98-8.134-22.152c7.304-1.634 15.093-2.97 23.209-3.984a321.524 321.524 0 0 0-7.848 12.897Zm8.081 65.352c-8.385-.936-16.291-2.203-23.593-3.793c2.26-7.3 5.045-14.885 8.298-22.6a321.187 321.187 0 0 0 7.257 13.246c2.594 4.48 5.28 8.868 8.038 13.147Zm37.542 31.03c-5.184-5.592-10.354-11.779-15.403-18.433c4.902.192 9.899.29 14.978.29c5.218 0 10.376-.117 15.453-.343c-4.985 6.774-10.018 12.97-15.028 18.486Zm52.198-57.817c3.422 7.8 6.306 15.345 8.596 22.52c-7.422 1.694-15.436 3.058-23.88 4.071a382.417 382.417 0 0 0 7.859-13.026a347.403 347.403 0 0 0 7.425-13.565Zm-16.898 8.101a358.557 358.557 0 0 1-12.281 19.815a329.4 329.4 0 0 1-23.444.823c-7.967 0-15.716-.248-23.178-.732a310.202 310.202 0 0 1-12.513-19.846h.001a307.41 307.41 0 0 1-10.923-20.627a310.278 310.278 0 0 1 10.89-20.637l-.001.001a307.318 307.318 0 0 1 12.413-19.761c7.613-.576 15.42-.876 23.31-.876H128c7.926 0 15.743.303 23.354.883a329.357 329.357 0 0 1 12.335 19.695a358.489 358.489 0 0 1 11.036 20.54a329.472 329.472 0 0 1-11 20.722Zm22.56-122.124c8.572 4.944 11.906 24.881 6.52 51.026c-.344 1.668-.73 3.367-1.15 5.09c-10.622-2.452-22.155-4.275-34.23-5.408c-7.034-10.017-14.323-19.124-21.64-27.008a160.789 160.789 0 0 1 5.888-5.4c18.9-16.447 36.564-22.941 44.612-18.3ZM128 90.808c12.625 0 22.86 10.235 22.86 22.86s-10.235 22.86-22.86 22.86s-22.86-10.235-22.86-22.86s10.235-22.86 22.86-22.86Z"></path></svg>

After

Width:  |  Height:  |  Size: 4.0 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 8.5 KiB

+10 -10
View File
@@ -1,26 +1,26 @@
import { DotIcon } from "@phosphor-icons/react"; import { DotIcon } from "@phosphor-icons/react";
import "@fontsource/knewave/400.css"; import "@fontsource/knewave/400.css";
export default function Logo({ scale = 2 }) { export default function Logo() {
return ( return (
<div <span
role="img" role="img"
aria-label="Pi Ku" aria-label="Pi Ku"
className="inline-flex items-baseline justify-center leading-none select-none" className="inline-flex items-baseline justify-center leading-none select-none"
style={{ fontFamily: "'Knewave', serif", scale }} style={{ fontFamily: "'Knewave', serif" }}
> >
<span className={`text-xl font-light text-accent`}>&nbsp;Pi</span> <span className="text-2xl font-light text-accent">Pi</span>
<DotIcon <DotIcon
weight="fill" weight="fill"
size={6} size={12}
className={`text-primary translate-y-1 -mx-px`} className="text-accent translate-y-[0.3em] -mx-px"
/> />
<span className={`text-xl font-light text-accent`}>&nbsp;Ku</span> <span className="text-2xl font-light text-accent">Ku</span>
<DotIcon <DotIcon
weight="fill" weight="fill"
size={6} size={12}
className={`text-primary translate-y-1 -mx-px`} className="text-accent translate-y-[0.3em] -mx-px"
/> />
</div> </span>
); );
} }
+2 -2
View File
@@ -40,7 +40,7 @@ describe("ProtectedRoute", () => {
"/protected", "/protected",
); );
expect(screen.getByText(/Unsealing/i)).toBeInTheDocument(); expect(screen.getByText(/Unsealing.../i)).toBeInTheDocument();
expect(screen.queryByText("Secret")).not.toBeInTheDocument(); expect(screen.queryByText("Secret")).not.toBeInTheDocument();
}); });
@@ -90,7 +90,7 @@ describe("PublicRoute", () => {
</PublicRoute>, </PublicRoute>,
"/public", "/public",
); );
expect(screen.getByText(/Unsealing/i)).toBeInTheDocument(); expect(screen.getByText(/Unsealing.../i)).toBeInTheDocument();
expect(screen.queryByText("Login Page")).not.toBeInTheDocument(); expect(screen.queryByText("Login Page")).not.toBeInTheDocument();
}); });
+4 -12
View File
@@ -1,22 +1,14 @@
import { EnvelopeOpenIcon } from "@phosphor-icons/react";
import Logo from "./Logo"; import Logo from "./Logo";
export default function SplashScreen() { export default function SplashScreen() {
return ( return (
<div className="fixed w-screen h-screen inset-0 bg-base-100 flex flex-col items-center justify-center z-9999"> <div className="fixed inset-0 bg-base-100 flex flex-col items-center justify-center z-9999">
<div className="flex flex-col items-center gap-6 animate-pulse"> <div className="flex flex-col items-center gap-6 animate-pulse">
<Logo /> <Logo />
<div className="flex flex-col items-center gap-2"> <div className="flex flex-col items-center gap-2">
<EnvelopeOpenIcon <span className="loading loading-ring loading-lg text-primary" />
weight="thin" <p className="text-xs uppercase font-sans tracking-widest opacity-40">
className={"absolute text-primary/50"} Unsealing...
size={40}
/>
<span className="loading loading-ring loading-xl text-primary"></span>
...
<p className="text-xs uppercase font-sans tracking-[1em] opacity-40">
Unsealing
</p> </p>
</div> </div>
</div> </div>
@@ -1,59 +0,0 @@
import { LockIcon, LockKeyOpenIcon } from "@phosphor-icons/react";
import { useNavigate } from "react-router-dom";
import { PATHS } from "../../config/routes";
export function LetterItem({
preview,
timestamp,
id,
status,
unlock_at,
isLocked = false,
}: {
preview: string;
timestamp: string;
id: string;
status: "DRAFT" | "SEALED" | "BURNED";
unlock_at?: string;
isLocked?: boolean;
}) {
const navigate = useNavigate();
function handleNavigate(): void {
if (isLocked) return;
if (status === "SEALED") {
navigate(PATHS.read(id));
} else {
navigate(PATHS.write(id));
}
}
return (
<button
type="button"
onClick={handleNavigate}
className={`${isLocked ? "pointer-events-none" : ""} p-4 border-base-content/3 flex items-start gap-4 hover:bg-base-300 transition-all delay-75 duration-100 group text-left cursor-pointer w-9/12 mx-auto hover:scale-120 hover:h-24 hover:-translate-y-3 hover:pb-4 hover:border-x-5 hover:border-t-5 border-t-2 hover:-mb-2`}
>
<div className="text-[0.85rem] italic text-base-content/40 flex-1 truncate group-hover:text-base-content/60 transition-none animate-[opacity_200ms_linear_forwards]">
{preview}
</div>
{unlock_at ? (
<div className="flex flex-col items-end">
{isLocked ? (
<div className="font-sans text-xs badge badge-accent badge-soft rounded-2xl">
<LockIcon weight="duotone" size={16} />
Locked Until {unlock_at}
</div>
) : (
<div className="font-sans text-xs badge badge-primary badge-soft rounded-2xl">
<LockKeyOpenIcon weight="duotone" size={16} /> Unlocked
</div>
)}
</div>
) : (
<div className="font-sans text-[0.6rem] text-base-content/20 transition-none">
{timestamp}
</div>
)}
</button>
);
}
@@ -1,52 +0,0 @@
import { LockKeyIcon } from "@phosphor-icons/react";
interface PasskeyModalProps {
onUnlock: (password: string) => Promise<void>;
}
export function PasskeyModal({ onUnlock }: PasskeyModalProps) {
return (
<div className="modal modal-open bg-base-100/20 backdrop-blur-md z-100">
<div className="modal-box p-12 flex flex-col items-center">
<LockKeyIcon
size={48}
className="text-primary mx-auto mb-8 animate-pulse"
/>
<h3 className="font-bold text-lg font-display text-primary">
Authentication Required
</h3>
<p className="py-4 font-sans">
We need your passkey to open your letters
</p>
<div className="divider w-1/2 mx-auto text-xs text-neutral-content/30 mt-0"></div>
<p className="text-xs text-neutral-content/30 font-mono italic">
Your passkey is used to decrypt your data locally.
</p>
<div className="modal-action items-center gap-4">
<form
className="form-control w-full inline-flex"
onSubmit={async (e: React.SubmitEvent<HTMLFormElement>) => {
e.preventDefault();
const formData = new FormData(e.currentTarget);
const password = formData.get("password") as string;
if (!password) return;
await onUnlock(password);
}}
>
<input
name="password"
required
type="password"
placeholder="password"
className="font-sans validator input input-bordered rounded-r-none"
/>
<div className="validator-message text-xs text-error"></div>
<button type="submit" className="btn btn-primary rounded-l-none">
Unlock
</button>
</form>
</div>
</div>
</div>
);
}
@@ -1,54 +0,0 @@
import { LockIcon } from "@phosphor-icons/react";
import type { NavigateFunction } from "react-router-dom";
import { PATHS, ROUTES } from "../../config/routes";
interface PostSealModalProps {
sealedTargetId: string | null;
navigate: NavigateFunction;
}
export function PostSealModal({
sealedTargetId,
navigate,
}: PostSealModalProps) {
if (!sealedTargetId) return null;
return (
<div className="modal modal-open modal-middle bg-base-100/20 backdrop-blur-md z-1000">
<div className="modal-box flex flex-col items-center text-center gap-6">
<LockIcon size={32} weight="duotone" className="text-primary mt-3" />
<h3 className="font-serif text-2xl">Your letter is sealed</h3>
<p className="text-base-content/60">
It's encrypted and always safe in your drawer.
</p>
<p className="text-base-content font-sans">
When you're ready,
<br />
you can{" "}
<span className="text-primary font-bold font-display">read</span> it,{" "}
<span className="text-accent font-bold font-display">send</span> it to
someone, or{" "}
<span className="text-error font-bold font-display">burn</span> it to
release
</p>
<div className="modal-action w-full justify-center gap-3 mt-4 mb-4">
<button
type="button"
className="btn btn-ghost btn-sm"
onClick={() => navigate(ROUTES.DRAWER)}
>
Keep it to myself
</button>
<button
type="button"
className="btn btn-primary btn-sm"
onClick={() =>
navigate(PATHS.read(sealedTargetId), { replace: true })
}
>
View letter
</button>
</div>
</div>
</div>
);
}
-195
View File
@@ -1,195 +0,0 @@
import {
ImageIcon,
LockIcon,
QuestionIcon,
StampIcon,
TrayIcon,
VaultIcon,
} from "@phosphor-icons/react";
interface ToolBarProps {
fileInputRef: React.RefObject<HTMLInputElement | null>;
sealBtnClicked: boolean;
setSealBtnClicked: (v: boolean) => void;
onSave: (status: "SEALED" | "DRAFT" | "VAULT", date?: Date) => Promise<void>;
setConfirmModal: (v: "VAULT" | "SEAL" | null) => void;
}
export function ToolBar({
fileInputRef,
sealBtnClicked,
setSealBtnClicked,
onSave,
setConfirmModal,
}: ToolBarProps) {
return (
<div
id="writer-toolbar"
className="flex items-center justify-between mb-8 h-14 bg-base-100/50 backdrop-blur-md rounded-full border border-base-content/5 px-6"
>
<div className="flex gap-4">
<button
type="button"
className="btn btn-ghost btn-sm group"
onClick={() => fileInputRef.current?.click()}
>
<ImageIcon size={18} weight="bold" />
<span className="hidden md:inline group-hover:inline transition-all duration-1000">
Add Image
</span>
</button>
</div>
<div className="flex items-center gap-2">
<button
type="button"
className="btn btn-ghost btn-sm text-[10px] group tracking-[0.2em] uppercase font-bold text-base-content/60 hover:text-base-content"
title="Store in your private drawer"
onClick={() => onSave("DRAFT")}
>
<TrayIcon size={18} weight="bold" />
<span className="hidden md:inline group-hover:inline transition-all duration-1000">
Draft
</span>
</button>
<div className="w-px h-4 bg-base-content/10 mx-2" />
<button
type="button"
className={`btn btn-primary btn-sm rounded-full px-6 group ${sealBtnClicked ? "invisible" : "visible"}`}
onClick={() => setSealBtnClicked(true)}
>
<StampIcon
size={16}
weight="fill"
className="mr-1 group-hover:animate-bounce"
/>
<span
className={`hidden md:inline ${sealBtnClicked ? "inline" : ""} group-hover:inline transition-all duration-1000`}
>
Seal
</span>
</button>
</div>
<div
className={`flex-col items-center gap-2 absolute right-0 z-100000 bg-primary/20 rounded-full p-8 -m-2 ${sealBtnClicked ? "" : "hidden"}`}
>
<button
type="button"
className="btn btn-accent btn-sm rounded-full px-6 group"
onClick={() => onSave("SEALED")}
>
<StampIcon
size={16}
weight="fill"
className="mr-1 group-hover:animate-bounce"
/>
<span className="transition-all duration-1000">Seal</span>
</button>
<div className="w-full divider text-neutral-content/60 mt-2 mb-2">
or
</div>
<button
type="button"
className="btn btn-neutral btn-sm rounded-full px-6 group"
onClick={() => setConfirmModal("VAULT")}
>
<VaultIcon size={16} weight="fill" className="mr-1" />
<span className="transition-all duration-1000">Vault</span>
</button>
</div>
<button
type="button"
onClick={() => setSealBtnClicked(false)}
className={`bg-transparent cursor-pointer -mt-2 absolute z-1000001 right-0 text-primary ${sealBtnClicked ? "" : "hidden"}`}
>
<QuestionIcon weight="duotone" size={20} className={""} />
</button>
</div>
);
}
export function LetterHead() {
return (
<div className="flex items-center justify-center mb-8 h-14">
<div className="badge badge-outline border-primary/20 bg-primary/5 text-primary gap-2 p-4 rounded-full">
<LockIcon size={14} weight="fill" />
<span className="text-[10px] uppercase tracking-widest font-bold">
Sealed & View Only
</span>
</div>
</div>
);
}
interface VaultConfirmModalProps {
onSave: (status: "SEALED" | "DRAFT" | "VAULT", date?: Date) => Promise<void>;
setConfirmModal: (v: "VAULT" | "SEAL" | null) => void;
setUnlockDate: (d: Date | null) => void;
}
export function VaultConfirmModal({
onSave,
setConfirmModal,
setUnlockDate,
}: VaultConfirmModalProps) {
return (
<div className={"modal modal-open bg-base-100/20 backdrop-blur-md"}>
<div className="modal-box p-12 flex flex-col items-center">
<VaultIcon
size={48}
className="text-primary mx-auto mb-8 animate-pulse"
/>
<h3 className="font-serif text-3xl">Vault this letter?</h3>
<p className="text-base-content/60 text-sm text-center mt-4">
Vaulting locks the letter permanently and will be{" "}
<span className={"font-bold text-primary"}>mailed</span> to you
automatically on the unlock date.
<br />
<span className={"underline"}>
You cannot edit or view the contents of the letter until then.
</span>
</p>
<form
onSubmit={async (e) => {
e.preventDefault();
const formData = new FormData(e.currentTarget);
const unlockDateStr = formData.get("vault-date") as string;
const newUnlockDate = new Date(unlockDateStr);
setUnlockDate(newUnlockDate);
await onSave("VAULT", newUnlockDate);
setConfirmModal(null);
}}
id="vault-form"
>
<div className={"divider tracking-tightest font-display text-sm"}>
Set an unlock date
</div>
<input
required
type="date"
className="input input-bordered w-full"
name="vault-date"
/>
<button
className="btn btn-primary mt-4"
type="submit"
form="vault-form"
>
Vault
</button>
<button
type="button"
className="btn btn-ghost mt-4"
onClick={() => setConfirmModal(null)}
>
Cancel
</button>
</form>
</div>
</div>
);
}
@@ -1,99 +0,0 @@
import { CampfireIcon, FlameIcon, XCircleIcon } from "@phosphor-icons/react";
import { useEffect, useState } from "react";
export function BurnModal({
burnLetter,
isBurning,
setShowBurnModal,
setRevealState,
}) {
const [flameOn, setFlameOn] = useState(0);
const [rotate, setRotate] = useState(0);
const [burnClicked, setBurnClicked] = useState(false);
useEffect(() => {
if (!burnClicked) return;
if (flameOn === 100) {
setRevealState("sealed");
burnLetter();
}
const interval = setInterval(() => {
setFlameOn((prev) => prev + 1);
setRotate(Math.random() * 4 - 2);
}, 100);
return () => clearInterval(interval);
}, [burnClicked, flameOn, setRevealState, burnLetter]);
const burnStyle = flameOn < 30 ? "" : `contrast(${flameOn / 30})`;
return (
<div className="modal modal-open modal-middle bg-base-100/20 backdrop-blur-md">
<div
className={`modal-box flex flex-col items-center gap-4 py-8 text-center transition-all duration-200 ease-in-out ${burnClicked ? "animate-[pulse_15s_linear_infinite]" : ""}`}
style={
{
transform: `rotate(${rotate}deg)`,
} as React.CSSProperties
}
>
<button
type="button"
className="btn btn-sm btn-circle btn-ghost absolute right-2 top-2"
onClick={() => setShowBurnModal(false)}
aria-label="Close"
>
<XCircleIcon size={18} weight="bold" />
</button>
<CampfireIcon
size={48}
weight="duotone"
className="text-error animate-pulse"
/>
<h3 className="font-serif text-2xl">
Are you ready to burn this letter?
</h3>
<p className="text-sm font-sans text-base-content/80 mt-4">
Some words are meant to be unsaid, but they don't have to linger
forever.
<br />
Let the echoes of your unsaid be finally released.
</p>
<div className="mt-4 font-sans text-sm">
<span className="text-error">Press</span> and{" "}
<span className="text-error">hold</span> the{" "}
<span className="text-amber-300">flame</span> to proceed.
</div>
<div className="modal-action w-full justify-center gap-3 mt-2">
<div
className="absolute -mt-2 w-28 h-28 radial-progress pointer-events-none text-amber-200/60"
style={
{ "--value": flameOn, filter: burnStyle } as React.CSSProperties
}
role="progressbar"
></div>
<button
type="button"
className={`btn btn-error btn-dashed btn-circle w-24 h-24`}
style={
{
filter: burnStyle,
cursor: burnClicked ? "grabbing" : "grab",
} as React.CSSProperties
}
onMouseDown={() => setBurnClicked(true)}
onMouseUp={() => {
setFlameOn(0);
setBurnClicked(false);
}}
disabled={isBurning}
>
{isBurning ? (
<span className="loading loading-spinner loading-xs" />
) : (
<FlameIcon size={54} weight="duotone" />
)}
</button>
</div>
</div>
</div>
);
}
@@ -1,144 +0,0 @@
import { WavesIcon } from "@phosphor-icons/react";
import { useEffect, useRef, useState } from "react";
import stamp from "../../assets/envelope/stamp.png";
import waxSeal from "../../assets/envelope/waxSeal.png";
export interface EnvelopeRevealProps {
recipient?: string;
date?: string;
onRevealComplete: () => void;
ignite: boolean;
isFlip?: boolean;
}
export function EnvelopeReveal({
recipient,
date,
onRevealComplete,
ignite,
isFlip,
}: EnvelopeRevealProps) {
const [revealLetter, setRevealLetter] = useState(false);
const [isFlipped, setIsFlipped] = useState(!!isFlip);
useEffect(() => {
setIsFlipped(!!isFlip);
}, [isFlip]);
const [burn, setBurn] = useState<{ width: number; height: number }>({
width: 0,
height: 0,
});
const flapCheckbox = useRef<HTMLInputElement>(null);
useEffect(() => {
if (!ignite) {
setBurn({ width: 0, height: 0 });
return;
}
const burnInterval = setInterval(() => {
setBurn((prev) => ({ width: prev.width + 4, height: prev.height + 6 }));
}, 100);
return () => clearInterval(burnInterval);
}, [ignite]);
const handleClick = () => {
if (revealLetter) return;
setRevealLetter(true);
setTimeout(() => {
onRevealComplete();
}, 2500);
};
return (
<>
<div
className={`relative h-70 w-105 transform-3d transition-transform duration-2000 ${isFlipped ? "rotate-y-180" : ""}`}
>
<div
className={` flex backface-hidden rotate-y-180 justify-center transition-all duration-1000 ${isFlipped ? "" : "pointer-events-none"}`}
>
<div
id="env-top"
className="z-4 delay-500 transition-all duration-2000 absolute peer h-40 w-54 mt-0 bg-base-200 mask mask-triangle-2 scale-x-234 has-checked:scale-y-[-1] has-checked:-translate-y-full has-checked:z-1 has-checked:duration-1000"
>
<input
type="checkbox"
className="transition checkbox absolute h-full w-full text-transparent bg-transparent z-100"
ref={flapCheckbox}
/>
</div>
<img
className={
"translate-y-24 delay-2000 absolute z-6 peer-has-checked:pointer-events-none peer-has-checked:opacity-0 peer-has-checked:delay-0 transition-opacity duration-1000 cursor-pointer"
}
src={waxSeal}
alt="Seal"
onClick={() => flapCheckbox.current?.click()}
onKeyDown={() => flapCheckbox.current?.click()}
/>
<button
type="button"
id="letter"
className={`absolute mx-auto transition-all peer-has-checked:delay-800 peer-has-checked:duration-1000 duration-1000 mt-2 h-55 w-105 bg-paper peer-has-checked:-mt-12 hover:-mt-24 cursor-pointer ${revealLetter ? "duration-1000 peer-has-checked:duration-3000 w-screen max-w-4xl h-screen z-101 -translate-y-90" : "peer-has-checked:z-1"}`}
onClick={handleClick}
></button>
<div
id="env-right"
className="absolute h-70 w-105 bg-base-300 mask mask-triangle-3 -mr-48 z-3 pointer-events-none"
></div>
<div
id="env-left"
className="absolute h-70 w-105 bg-base-300 mask mask-triangle-4 -ml-48 z-3 pointer-events-none"
></div>
<button
type="button"
id="env-bottom"
className="absolute h-70 w-45 bg-base-200 mask mask-triangle-2 scale-y-[-1] mt-15 scale-x-240 z-3"
></button>
</div>
<button
id="env-front"
type="button"
className={`text-left p-10 absolute inset-0 backface-hidden w-110 bg-base-200 z-99 rounded-md -translate-x-2 ${isFlipped ? "pointer-events-none" : ""}`}
onClick={() => setIsFlipped((prev) => !prev)}
>
<span className={"text-neutral-content/60 font-xs font-display"}>
to
</span>
<h1 className="text-3xl font-bold text-base-content">{recipient}</h1>
<p className="text-base-content/60 font-display mt-8">{date}</p>
<img
src={stamp}
alt={"stamp"}
className={
"z-0 rotate-6 opacity-80 text-accent absolute mt-0 mr-1 top-4 right-0"
}
/>
<WavesIcon
className={"absolute mt-0 mr-12 top-18 right-8 text-primary"}
size={50}
/>
<WavesIcon
className={"absolute mt-0 mr-4 top-18 right-8 text-primary"}
size={50}
/>
</button>
</div>
{ignite && (
<div className="absolute w-115 h-70 z-100 overflow-hidden flex align-baseline -translate-y-70 -translate-x-5">
<div
className="absolute z-1000 border-2 border-amber-200 -bottom-3 -right-3 w-0 h-0 transition-all duration-500 bg-base-100 rounded-tl-full rounded-bl-full origin-bottom-right"
style={{
width: 2 * burn.width,
height: 2 * burn.height,
}}
></div>
</div>
)}
</>
);
}
@@ -1,36 +0,0 @@
import { useNavigate } from "react-router-dom";
import { ROUTES } from "../../config/routes";
export function PostActionOverlay({ revealState }) {
const navigate = useNavigate();
return (
<div
className={`flex flex-col items-center justify-center min-h-screen bg-base-100 ${revealState === "burned" ? "opacity-100" : "opacity-0"} transition-all delay-300 duration-1000`}
>
<h1
className={`text-6xl ${revealState === "burned" ? "opacity-100" : "opacity-0"} lg:text-9xl italic font-extralight text-base-content animate-[pulse_3s_ease-in-out_3]`}
>
It is done
</h1>
<div
className={`text-xl ${revealState === "burned" ? "opacity-100" : "opacity-0"} lg:text-4xl text-center font-extralight text-base-content font-display mt-8 delay-3000 transition-all duration-2000 tracking-wide`}
>
<p className="w-full">
May your <span className="italic text-primary">soul</span> find
solace,
<br />
just like your <span className="text-accent italic">unsaid</span>{" "}
words did.
</p>
<div className="divider mx-auto w-24 text-center"></div>
<button
type="button"
className="btn btn-ghost text-sm text-neutral-content/60 font-sans"
onClick={() => navigate(ROUTES.DRAWER)}
>
Turn the page
</button>
</div>
</div>
);
}
@@ -1,70 +0,0 @@
import {
EyeSlashIcon,
PaperPlaneTiltIcon,
XCircleIcon,
} from "@phosphor-icons/react";
export function ShareModal({ shareLink, setShareLink }) {
const copyToClipboard = async () => {
if (!shareLink) return;
await navigator.clipboard.writeText(shareLink);
};
return (
<div className="modal modal-open modal-middle bg-base-100/20 backdrop-blur-md z-100">
<div className="modal-box bg-base-100 border border-base-content/5 shadow-2xl relative">
<button
type="button"
className="btn btn-sm btn-circle btn-ghost absolute right-2 top-2"
onClick={() => setShareLink(null)}
aria-label="Close"
>
<XCircleIcon size={18} weight="bold" />
</button>
<div className="flex flex-col items-center justify-center text-center gap-6 py-4">
<div className="space-y-2">
<PaperPlaneTiltIcon
size={48}
weight="bold"
className="mb-4 text-primary mx-auto animate-[bounce_3s_ease-in-out_infinite]"
/>
<h3 className="font-serif text-3xl">Send this letter</h3>
<p className="text-base-content/80 text-sm font-sans mt-4">
You've carried these words long enough. Send your letter now, and
let the <span className="text-accent font-display">unsaid</span>{" "}
finally find its home.
</p>
<div className="divider mx-auto" />
<blockquote className="text-sm info text-neutral-content/60 font-sans">
The recipient will have the same viewing experience like you do
now.
</blockquote>
</div>
<div className="w-full flex items-center gap-2 bg-base-300 p-2 rounded-xl">
<input
id="share-link-input"
readOnly
value={shareLink ?? ""}
className="flex-1 bg-transparent text-xs font-mono px-2 overflow-hidden text-ellipsis whitespace-nowrap outline-none"
/>
<button
type="button"
onClick={copyToClipboard}
className="btn btn-primary font-sans btn-sm rounded-tl-xl rounded-bl-xl rounded-tr-full rounded-br-full"
>
Copy
</button>
</div>
<div className="flex flex-col gap-1 uppercase tracking-widest text-base-content/30 font-sans">
<p className="textarea-xs flex items-center justify-center">
<EyeSlashIcon weight="duotone" size={18} className="mr-2" />{" "}
Zero-Knowledge Share:
</p>
<p className="textarea-xs font-mono text-center">
The key never leaves your or the recipient's browser.
</p>
</div>
</div>
</div>
</div>
);
}
@@ -1,5 +1,3 @@
import { GearFineIcon } from "@phosphor-icons/react";
interface DrawerSectionProps { interface DrawerSectionProps {
id: string; id: string;
title: string; title: string;
@@ -20,12 +18,12 @@ export function DrawerSection({
return ( return (
<div <div
id={id} id={id}
className={`join-item group flex flex-col transition-colors duration-3000 ease-in-out ${isOpen ? "bg-base-300/30" : ""}`} className={`join-item group flex flex-col transition-colors ${isOpen ? "bg-base-300/30" : ""}`}
> >
<div <div
className={`transition-all duration-1500 ease-in-out bg-neutral/10 ${ className={`overflow-hidden transition-all duration-1000 ease-in-out bg-neutral/10 ${
isOpen isOpen
? "max-h-125 opacity-100 py-3 border-b border-base-content/5 overflow-visible" ? "max-h-125 opacity-100 py-3 border-b border-base-content/5"
: "max-h-0 opacity-0 pointer-events-none" : "max-h-0 opacity-0 pointer-events-none"
}`} }`}
> >
@@ -35,11 +33,11 @@ export function DrawerSection({
<button <button
type="button" type="button"
onClick={onClick} onClick={onClick}
className={`w-full p-[24px_28px] cursor-pointer flex items-center gap-5 transition-all duration-2000 ease-in-out outline-none focus-visible:ring-2 focus-visible:ring-primary/50 border border-base-content/10 text-left bg-linear-to-r from-transparent to-base-100/40`} className={`w-full p-[24px_28px] cursor-pointer flex items-center gap-5 transition-all duration-1000 outline-none focus-visible:ring-2 focus-visible:ring-primary/50 border border-base-content/10 text-left bg-linear-to-r from-transparent to-base-100/40`}
> >
<div className="flex-1"> <div className="flex-1">
<div <div
className={`font-sans text-xs tracking-[0.2em] uppercase transition-colors duration-800 ${ className={`font-sans text-xs tracking-[0.2em] uppercase transition-colors duration-300 ${
isOpen isOpen
? "text-base-content" ? "text-base-content"
: "text-base-content/40 group-hover:text-base-content/80" : "text-base-content/40 group-hover:text-base-content/80"
@@ -51,16 +49,6 @@ export function DrawerSection({
{count} {count}
</div> </div>
</div> </div>
{id === "vault" ? (
<GearFineIcon
className={
"-mt-3 group-hover:animate-[spin_8s_ease-in-out_1] group-hover:text-neutral-content text-neutral"
}
weight={"duotone"}
size={30}
/>
) : (
<div <div
className={`w-8 h-1 rounded-sm transition-all duration-300 bg-neutral ${ className={`w-8 h-1 rounded-sm transition-all duration-300 bg-neutral ${
isOpen isOpen
@@ -70,7 +58,6 @@ export function DrawerSection({
> >
<div className="absolute -top-1 left-1.75 w-5 h-px bg-base-content/5" /> <div className="absolute -top-1 left-1.75 w-5 h-px bg-base-content/5" />
</div> </div>
)}
</button> </button>
</div> </div>
); );
+38
View File
@@ -0,0 +1,38 @@
import { useNavigate } from "react-router-dom";
import { PATHS } from "../../config/routes";
export function LetterItem({
preview,
timestamp,
id,
status,
}: {
preview: string;
timestamp: string;
id: string;
status: "DRAFT" | "SEALED" | "BURNED";
}) {
const navigate = useNavigate();
function handleNavigate(): void {
if (status === "SEALED") {
navigate(PATHS.read(id));
} else {
navigate(PATHS.write(id));
}
}
return (
<button
type="button"
onClick={handleNavigate}
className="p-[16px_28px_16px_76px] border-b border-base-content/3 flex items-center gap-4 hover:bg-base-content/5 transition-colors group w-full text-left"
>
<div className="text-[0.85rem] italic text-base-content/40 flex-1 truncate group-hover:text-base-content/60">
{preview}
</div>
<div className="font-sans text-[0.6rem] text-base-content/20">
{timestamp}
</div>
</button>
);
}
+1 -1
View File
@@ -18,7 +18,7 @@ export const LogModal = ({
return status === "RESET" || !isOpen ? ( return status === "RESET" || !isOpen ? (
<div></div> <div></div>
) : ( ) : (
<div className="modal modal-open modal-middle bg-base-100/20 backdrop-blur-md z-100"> <div className="modal modal-open modal-bottom sm:modal-middle bg-base-100/20 backdrop-blur-md z-100">
<div className="modal-box bg-transparent border-none shadow-none relative"> <div className="modal-box bg-transparent border-none shadow-none relative">
<div <div
className={`alert ${status === "WARN" ? "alert-warning" : "alert-error"} flex flex-col items-center text-center gap-6 py-4`} className={`alert ${status === "WARN" ? "alert-warning" : "alert-error"} flex flex-col items-center text-center gap-6 py-4`}
+8
View File
@@ -6,6 +6,7 @@ import { mockUser } from "../../test/fixtures/user.fixture";
import { server } from "../../test/mocks/server"; import { server } from "../../test/mocks/server";
import { useAuthStore } from "../store/useAuthStore"; import { useAuthStore } from "../store/useAuthStore";
import { useKeyStore } from "../store/useKeyStore"; import { useKeyStore } from "../store/useKeyStore";
import { CryptoUtils } from "../utils/crypto";
import { import {
clearMasterKey, clearMasterKey,
loadMasterKey, loadMasterKey,
@@ -13,6 +14,7 @@ import {
} from "../utils/keystore"; } from "../utils/keystore";
import { useAuth } from "./useAuth"; import { useAuth } from "./useAuth";
vi.mock("../utils/crypto");
vi.mock("../utils/keystore"); vi.mock("../utils/keystore");
const VITE_API_URL = "http://piku-server"; const VITE_API_URL = "http://piku-server";
@@ -20,6 +22,12 @@ const VITE_API_URL = "http://piku-server";
beforeEach(() => { beforeEach(() => {
vi.clearAllMocks(); vi.clearAllMocks();
// hack to set up mock implementations using fixtures
vi.mocked(CryptoUtils.deriveKeyBundle).mockResolvedValue({
masterKey: mockMasterKey,
authHash: "mock-auth-hash",
});
vi.mocked(loadMasterKey).mockResolvedValue(mockMasterKey); vi.mocked(loadMasterKey).mockResolvedValue(mockMasterKey);
vi.mocked(saveMasterKey).mockResolvedValue("masterKey"); vi.mocked(saveMasterKey).mockResolvedValue("masterKey");
vi.mocked(clearMasterKey).mockResolvedValue(undefined); vi.mocked(clearMasterKey).mockResolvedValue(undefined);
+6 -2
View File
@@ -48,7 +48,9 @@ export const useAuth = () => {
try { try {
const masterKey = await loadMasterKey(); const masterKey = await loadMasterKey();
if (masterKey) setMasterKey(masterKey); if (masterKey) setMasterKey(masterKey);
} catch {} } catch {
console.error("Master key restoration failed");
}
// If session in memory, don't trigger refresh/me again // If session in memory, don't trigger refresh/me again
if (accessToken && user) { if (accessToken && user) {
@@ -80,7 +82,9 @@ export const useAuth = () => {
); );
await saveMasterKey(masterKey); await saveMasterKey(masterKey);
setMasterKey(masterKey); setMasterKey(masterKey);
} catch {} } catch {
console.error("Master key restoration failed");
}
}; };
return { return {
-113
View File
@@ -1,113 +0,0 @@
import { renderHook, waitFor } from "@testing-library/react";
import { HttpResponse, http } from "msw";
import { beforeEach, describe, expect, it } from "vitest";
import { server } from "../../test/mocks/server";
import { endpoints } from "../config/endpoints";
import { useKeyStore } from "../store/useKeyStore";
import { CryptoUtils } from "../utils/crypto";
import { useLetters } from "./useLetters";
describe("useLetters hook", () => {
let masterKey: CryptoKey;
let utils: CryptoUtils;
beforeEach(async () => {
utils = new CryptoUtils();
await utils.initialize();
const bundle = await CryptoUtils.deriveKeyBundle("password", "salt");
masterKey = bundle.masterKey;
useKeyStore.setState({ masterKey: null });
});
it("should indicate authentication is required when masterKey is missing", () => {
const { result } = renderHook(() => useLetters());
expect(result.current.isAuthRequired).toBe(true);
});
it("should fetch, decrypt, and categorize letters when masterKey is present", async () => {
useKeyStore.setState({ masterKey });
const draftPayload = { objects: [] };
const encryptedDraft = await utils.encryptMetadata(
{ recipient: "Draft Recipient" },
masterKey,
);
const lettersResponse = [
{
public_id: "letter-1",
type: "KEPT",
status: "DRAFT",
updated_at: new Date().toISOString(),
encrypted_metadata: encryptedDraft.encrypted_content,
encrypted_content: JSON.stringify(draftPayload),
encrypted_dek: encryptedDraft.encrypted_dek,
},
];
server.use(
http.get(`${import.meta.env.VITE_API_URL}${endpoints.LETTERS}`, () => {
return HttpResponse.json(lettersResponse);
}),
);
const { result } = renderHook(() => useLetters());
// Initially loading
expect(result.current.loading).toBe(true);
await waitFor(() => expect(result.current.loading).toBe(false));
expect(result.current.drafts).toHaveLength(1);
expect(result.current.drafts[0].metadata.recipient).toBe("Draft Recipient");
expect(result.current.kept).toHaveLength(0);
});
it("should sort letters by updated_at in descending order", async () => {
useKeyStore.setState({ masterKey });
const metadata = await utils.encryptMetadata(
{ recipient: "test" },
masterKey,
);
const now = new Date();
const older = new Date(now.getTime() - 10000);
const lettersResponse = [
{
public_id: "older",
type: "KEPT",
status: "SEALED",
updated_at: older.toISOString(),
encrypted_metadata: metadata.encrypted_content,
encrypted_content: "{}",
encrypted_dek: metadata.encrypted_dek,
},
{
public_id: "newer",
type: "KEPT",
status: "SEALED",
updated_at: now.toISOString(),
encrypted_metadata: metadata.encrypted_content,
encrypted_content: "{}",
encrypted_dek: metadata.encrypted_dek,
},
];
server.use(
http.get(`${import.meta.env.VITE_API_URL}${endpoints.LETTERS}`, () => {
return HttpResponse.json(lettersResponse);
}),
);
const { result } = renderHook(() => useLetters());
await waitFor(() => expect(result.current.loading).toBe(false));
expect(result.current.kept[0].public_id).toBe("newer");
expect(result.current.kept[1].public_id).toBe("older");
});
});
+3 -10
View File
@@ -10,7 +10,7 @@ export interface Letter {
status: "DRAFT" | "SEALED" | "BURNED"; status: "DRAFT" | "SEALED" | "BURNED";
updated_at: string; updated_at: string;
sealed_at?: string; sealed_at?: string;
unlock_at: string; unlock_at?: string;
encrypted_metadata: string; encrypted_metadata: string;
encrypted_content: string; encrypted_content: string;
encrypted_dek: string; encrypted_dek: string;
@@ -69,15 +69,7 @@ export function useLetters() {
api api
.get(endpoints.LETTERS) .get(endpoints.LETTERS)
.then((res) => decryptLetters(res.data, masterKey)) .then((res) => decryptLetters(res.data, masterKey))
.then((decrypted) => { .then(setLetters)
setLetters(
decrypted.sort(
(a, b) =>
new Date(b.updated_at).getTime() -
new Date(a.updated_at).getTime(),
),
);
})
.catch((_err) => {}) .catch((_err) => {})
.finally(() => setLoading(false)); .finally(() => setLoading(false));
}, [masterKey]); }, [masterKey]);
@@ -94,6 +86,7 @@ export function useLetters() {
return { return {
...drawerItems, ...drawerItems,
loading, loading,
refreshLetters: () => setLoading(true),
isAuthRequired, isAuthRequired,
}; };
} }
+18 -5
View File
@@ -7,39 +7,50 @@
prefersdark: true; prefersdark: true;
color-scheme: dark; color-scheme: dark;
--color-base-100: oklch(14% 0.012 35); /* ── Base surfaces ── */
--color-base-100: oklch(14% 0.012 35); /* was 0.018 hue 50 */
--color-base-200: oklch(18% 0.014 33); --color-base-200: oklch(18% 0.014 33);
--color-base-300: oklch(22% 0.016 32); --color-base-300: oklch(22% 0.016 32);
--color-base-content: oklch(82% 0.02 70); --color-base-content: oklch(
82% 0.02 70
); /* aged parchment, not crisp white */
/* ── Primary: old lamp gold — warm, incandescent ── */
--color-primary: oklch(67% 0.11 78); --color-primary: oklch(67% 0.11 78);
--color-primary-content: oklch(15% 0.03 70); --color-primary-content: oklch(15% 0.03 70);
/* ── Secondary: dusty plum ── */
--color-secondary: oklch(48% 0.08 305); --color-secondary: oklch(48% 0.08 305);
--color-secondary-content: oklch(92% 0.01 305); --color-secondary-content: oklch(92% 0.01 305);
/* ── Accent: muted lavender-clay ── */
--color-accent: oklch(55% 0.06 325); --color-accent: oklch(55% 0.06 325);
--color-accent-content: oklch(18% 0.03 295); --color-accent-content: oklch(18% 0.03 295);
/* ── Neutral: warm stone ── */
--color-neutral: oklch(28% 0.02 45); --color-neutral: oklch(28% 0.02 45);
--color-neutral-content: oklch(80% 0.015 60); --color-neutral-content: oklch(80% 0.015 60);
/* ── Semantic — desaturated, no alarm ── */
--color-info: oklch(60% 0.07 240); --color-info: oklch(60% 0.07 240);
--color-info-content: oklch(95% 0.01 240); --color-info-content: oklch(95% 0.01 240);
--color-success: oklch(60% 0.08 150); --color-success: oklch(60% 0.08 150);
--color-success-content: oklch(16% 0.03 150); --color-success-content: oklch(16% 0.03 150);
--color-warning: oklch(68% 0.08 72); --color-warning: oklch(68% 0.08 72); /* honey, not caution-sign amber */
--color-warning-content: oklch(18% 0.03 60); --color-warning-content: oklch(18% 0.03 60);
--color-error: oklch(55% 0.1 22); --color-error: oklch(55% 0.1 22);
--color-error-content: oklch(92% 0.01 22); --color-error-content: oklch(92% 0.01 22);
/* ── Shape ── */
--radius-selector: 0.5rem; --radius-selector: 0.5rem;
--radius-field: 0.375rem; --radius-field: 0.375rem;
--radius-box: 0.5rem; --radius-box: 0.5rem;
/* ── Effects ── */
--depth: 1; --depth: 1;
--noise: 0.03; --noise: 0.03;
/* ── Border ── */
--border: 1px; --border: 1px;
} }
@@ -47,10 +58,12 @@
--font-display: "Playwrite HR Lijeva Variable", cursive; --font-display: "Playwrite HR Lijeva Variable", cursive;
--font-sans: "Jost Variable", sans-serif; --font-sans: "Jost Variable", sans-serif;
--font-serif: "Playfair Display Variable", serif; --font-serif: "Playfair Display Variable", serif;
--color-glass-bg: rgba(28, --color-glass-bg: rgba(
28,
22, 22,
16, 16,
0.45); 0.45
); /* slightly deeper to match new base */
--shadow-warm: 0 20px 50px -12px rgba(30, 20, 12, 0.6); --shadow-warm: 0 20px 50px -12px rgba(30, 20, 12, 0.6);
--radius-xl: 1.5rem; --radius-xl: 1.5rem;
--color-paper: oklch(97% 0.008 80); --color-paper: oklch(97% 0.008 80);
+1 -4
View File
@@ -68,10 +68,7 @@ export default function Activate() {
type="button" type="button"
className="btn btn-primary w-full shadow-lg" className="btn btn-primary w-full shadow-lg"
onClick={() => onClick={() =>
navigate(ROUTES.LOGIN, { navigate(ROUTES.LOGIN, { state: { firstTime: true } })
state: { firstTime: true },
replace: true,
})
} }
> >
Start Writing Start Writing
+1 -52
View File
@@ -1,13 +1,10 @@
import { render, screen } from "@testing-library/react"; import { render, screen } from "@testing-library/react";
import { MemoryRouter } from "react-router-dom"; import { MemoryRouter } from "react-router-dom";
import { beforeEach, describe, expect, it, vi } from "vitest"; import { beforeEach, describe, expect, it } from "vitest";
import { mockUser } from "../../test/fixtures/user.fixture"; import { mockUser } from "../../test/fixtures/user.fixture";
import { useLetters } from "../hooks/useLetters";
import { useAuthStore } from "../store/useAuthStore"; import { useAuthStore } from "../store/useAuthStore";
import Drawer from "./Drawer"; import Drawer from "./Drawer";
vi.mock("../hooks/useLetters");
describe("Drawer Page", () => { describe("Drawer Page", () => {
beforeEach(() => { beforeEach(() => {
// Setup authenticated state for the test // Setup authenticated state for the test
@@ -16,15 +13,6 @@ describe("Drawer Page", () => {
accessToken: "fake-token", accessToken: "fake-token",
isInitializing: false, isInitializing: false,
}); });
vi.mocked(useLetters).mockReturnValue({
drafts: [],
kept: [],
sent: [],
vault: [],
loading: false,
isAuthRequired: false,
});
}); });
it("renders the cabinet sections and empty state message", () => { it("renders the cabinet sections and empty state message", () => {
@@ -39,43 +27,4 @@ describe("Drawer Page", () => {
expect(screen.getByText(/Vault/i)).toBeInTheDocument(); expect(screen.getByText(/Vault/i)).toBeInTheDocument();
expect(screen.getByText(/This drawer remains silent/i)).toBeInTheDocument(); expect(screen.getByText(/This drawer remains silent/i)).toBeInTheDocument();
}); });
it("renders the loading state", () => {
vi.mocked(useLetters).mockReturnValue({
drafts: [],
kept: [],
sent: [],
vault: [],
loading: true,
isAuthRequired: false,
});
render(
<MemoryRouter>
<Drawer />
</MemoryRouter>,
);
expect(screen.getByText(/Opening your cabinet/i)).toBeInTheDocument();
});
it("renders the authentication required modal when api requires auth", () => {
vi.mocked(useLetters).mockReturnValue({
drafts: [],
kept: [],
sent: [],
vault: [],
loading: false,
isAuthRequired: true,
});
render(
<MemoryRouter>
<Drawer />
</MemoryRouter>,
);
expect(screen.getByText(/Authentication Required/i)).toBeInTheDocument();
expect(screen.getByPlaceholderText(/password/i)).toBeInTheDocument();
});
}); });
+62 -26
View File
@@ -1,17 +1,12 @@
import { FeatherIcon } from "@phosphor-icons/react"; import { FeatherIcon, LockKeyIcon } from "@phosphor-icons/react";
import { useState } from "react"; import { useState } from "react";
import { useNavigate } from "react-router-dom"; import { useNavigate } from "react-router-dom";
import { DrawerSection } from "../components/drawer/DrawerSection.tsx";
import { LetterItem } from "../components/drawer/LetterItem.tsx";
import { PasskeyModal } from "../components/drawer/PasskeyModal.tsx";
import Logo from "../components/Logo"; import Logo from "../components/Logo";
import { DrawerSection } from "../components/ui/DrawerSection";
import { LetterItem } from "../components/ui/LetterItem";
import { PATHS } from "../config/routes"; import { PATHS } from "../config/routes";
import { useAuth } from "../hooks/useAuth"; import { useAuth } from "../hooks/useAuth";
import { useLetters } from "../hooks/useLetters"; import { useLetters } from "../hooks/useLetters";
import {
formatRelativeDate,
formatRelativeDateWithoutTime,
} from "../utils/dateFormat.ts";
export default function Drawer() { export default function Drawer() {
const { user, logout, unlock } = useAuth(); const { user, logout, unlock } = useAuth();
@@ -29,8 +24,54 @@ export default function Drawer() {
<div className="min-h-screen w-full bg-base-100 text-base-content flex flex-col items-center py-12 px-5 pb-32 font-serif transition-colors"> <div className="min-h-screen w-full bg-base-100 text-base-content flex flex-col items-center py-12 px-5 pb-32 font-serif transition-colors">
<div className="fixed inset-0 bg-[radial-gradient(circle_at_center,transparent_0%,rgba(0,0,0,0.5)_100%)] pointer-events-none z-0" /> <div className="fixed inset-0 bg-[radial-gradient(circle_at_center,transparent_0%,rgba(0,0,0,0.5)_100%)] pointer-events-none z-0" />
{isAuthRequired && <PasskeyModal onUnlock={unlock} />} {isAuthRequired && (
<header className="text-center mb-12 z-10 animate-in fade-in slide-in-from-top-4 duration-500"> <div className="modal modal-open bg-base-100/20 backdrop-blur-md">
<div className="modal-box p-12 flex flex-col items-center">
<LockKeyIcon
size={48}
className="text-primary mx-auto mb-8 animate-pulse"
/>
<h3 className="font-bold text-lg font-display text-primary">
Authentication Required
</h3>
<p className="py-4 font-sans">
We need your passkey to open your letters
</p>
<div className="divider w-1/2 mx-auto text-xs text-neutral-content/30 mt-0"></div>
<p className="text-xs text-neutral-content/30 font-mono italic">
P.S. We don't validate your input at the moment.
</p>
<div className="modal-action items-center gap-4">
<form
className="form-control w-full inline-flex"
onSubmit={async (e: React.SubmitEvent<HTMLFormElement>) => {
e.preventDefault();
const formData = new FormData(e.currentTarget);
const password = formData.get("password") as string;
if (!password) return;
unlock(password);
}}
>
<input
name="password"
required
type="password"
placeholder="password"
className="font-sans validator input input-bordered rounded-r-none"
/>
<div className="validator-message text-xs text-error"></div>
<button
type="submit"
className="btn btn-primary rounded-l-none"
>
Unlock
</button>
</form>
</div>
</div>
</div>
)}
<header className="text-center mb-12 z-10 animate-in fade-in slide-in-from-top-4 duration-1000">
<Logo /> <Logo />
<div className="font-sans text-xs tracking-[0.3em] uppercase text-base-content/40 mt-2"> <div className="font-sans text-xs tracking-[0.3em] uppercase text-base-content/40 mt-2">
Personal Archive Personal Archive
@@ -48,7 +89,7 @@ export default function Drawer() {
</div> </div>
</header> </header>
<div className="join join-vertical w-full max-w-120 bg-base-200 border border-base-content/10 shadow-2xl z-10 rounded-sm duration-500 delay-200 min-h-64 flex flex-col"> <div className="join join-vertical w-full max-w-120 bg-base-200 border border-base-content/10 shadow-2xl z-10 rounded-sm overflow-hidden animate-in fade-in slide-in-from-bottom-8 duration-1000 delay-200 fill-mode-backwards min-h-64 flex flex-col">
{loading ? ( {loading ? (
<div className="flex-1 flex flex-col items-center justify-center p-12 gap-4"> <div className="flex-1 flex flex-col items-center justify-center p-12 gap-4">
<span className="loading loading-ring loading-lg text-primary opacity-20"></span> <span className="loading loading-ring loading-lg text-primary opacity-20"></span>
@@ -71,7 +112,7 @@ export default function Drawer() {
status={draft.status} status={draft.status}
key={draft.public_id} key={draft.public_id}
preview={draft.metadata?.recipient || "Untitled Draft"} preview={draft.metadata?.recipient || "Untitled Draft"}
timestamp={formatRelativeDate(draft.updated_at)} timestamp={draft.updated_at}
/> />
))} ))}
</DrawerSection> </DrawerSection>
@@ -89,7 +130,7 @@ export default function Drawer() {
status={letter.status} status={letter.status}
key={letter.public_id} key={letter.public_id}
preview={letter.metadata?.recipient || "Someone dear..."} preview={letter.metadata?.recipient || "Someone dear..."}
timestamp={formatRelativeDate(letter.updated_at)} timestamp={letter.updated_at}
/> />
))} ))}
</DrawerSection> </DrawerSection>
@@ -106,7 +147,7 @@ export default function Drawer() {
status={letter.status} status={letter.status}
id={letter.public_id} id={letter.public_id}
preview={letter.metadata?.recipient || "Someone dear..."} preview={letter.metadata?.recipient || "Someone dear..."}
timestamp={formatRelativeDate(letter.updated_at)} timestamp={letter.updated_at}
/> />
))} ))}
{sent.length === 0 && ( {sent.length === 0 && (
@@ -128,11 +169,7 @@ export default function Drawer() {
status={letter.status} status={letter.status}
id={letter.public_id} id={letter.public_id}
preview={letter.metadata?.recipient || "Future Self"} preview={letter.metadata?.recipient || "Future Self"}
timestamp={formatRelativeDate(letter.updated_at)} timestamp={letter.updated_at}
unlock_at={formatRelativeDateWithoutTime(
letter.unlock_at || "",
)}
isLocked={letter.unlock_at > new Date().toISOString()}
/> />
))} ))}
</DrawerSection> </DrawerSection>
@@ -142,28 +179,27 @@ export default function Drawer() {
<button <button
type="button" type="button"
id="write-letter-btn" className="group mt-15 z-10 bg-transparent border border-dashed border-base-content/10 px-8 py-4 text-base-content/40 italic cursor-pointer transition-all hover:border-primary/40 hover:text-base-content/60 hover:bg-primary/5 hover:-translate-y-0.5 flex items-center gap-2 focus-visible:ring-2 focus-visible:ring-primary/50 duration-1000"
className="group mt-15 z-10 bg-transparent border border-dashed border-base-content/10 px-8 py-4 text-base-content/40 italic cursor-pointer transition-all hover:border-primary/40 hover:text-base-content/60 hover:bg-primary/5 hover:-translate-y-0.5 flex items-center gap-2 focus-visible:ring-2 focus-visible:ring-primary/50 duration-500" onClick={() => navigate(PATHS.write(""), { replace: true })}
onClick={() => navigate(PATHS.write(""))}
> >
<FeatherIcon <FeatherIcon
size={18} size={18}
weight="duotone" weight="duotone"
className="text-primary/30 transition-all duration-300 group-hover:text-primary" className="text-primary/30 transition-all duration-700 group-hover:text-primary"
/> />
Write something{" "} Write something{" "}
<span className="relative inline-flex"> <span className="relative inline-flex">
<span className="transition-opacity duration-500 opacity-80 group-hover:opacity-0"> <span className="transition-opacity duration-1500 opacity-80 group-hover:opacity-0">
. . . . . . . . . . . .
</span> </span>
<span className="absolute inset-0 text-primary transition-opacity duration-300 opacity-0 group-hover:opacity-100"> <span className="absolute inset-0 text-primary transition-opacity duration-1000 opacity-0 group-hover:opacity-100">
unsaid unsaid
</span> </span>
</span> </span>
</button> </button>
<footer className="mt-25 font-sans text-[0.6rem] tracking-[0.2em] uppercase text-base-content/10 z-10"> <footer className="mt-25 font-sans text-[0.6rem] tracking-[0.2em] uppercase text-base-content/10 z-10">
For your unsaid. Kept. Unsent.
</footer> </footer>
</div> </div>
); );
-168
View File
@@ -1,168 +0,0 @@
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
import { HttpResponse, http } from "msw";
import { MemoryRouter, Route, Routes } from "react-router-dom";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { mockMasterKey } from "../../test/fixtures/auth.fixture";
import { mockUser } from "../../test/fixtures/user.fixture";
import { server } from "../../test/mocks/server";
import { endpoints } from "../config/endpoints";
import { useAuthStore } from "../store/useAuthStore";
import { useKeyStore } from "../store/useKeyStore";
import Editor from "./Editor";
const API_URL = import.meta.env.VITE_API_URL;
// Mock ComposeCanvas to avoid Fabric.js issues and check readOnly prop
vi.mock("../components/editor/ComposeCanvas", () => ({
ComposeCanvas: vi.fn(({ readOnly }) => (
<div data-testid="canvas" data-readonly={readOnly}>
Canvas
</div>
)),
}));
// Mock CryptoUtils to avoid real crypto calls in UI tests
vi.mock("../utils/crypto", () => {
return {
CryptoUtils: class {
initialize = vi.fn().mockResolvedValue(undefined);
encryptLetter = vi.fn().mockResolvedValue({
encrypted_content: "enc-content",
encrypted_dek: "enc-dek",
sharingKey: "share-key",
});
encryptMetadata = vi.fn().mockResolvedValue({
encrypted_content: "enc-meta",
encrypted_dek: "enc-dek",
});
decryptMetadata = vi.fn().mockResolvedValue({ recipient: "Test User" });
decryptLetter = vi.fn().mockResolvedValue("{}");
extractSharingKey = vi.fn().mockResolvedValue("share-key");
},
};
});
describe("Editor Page", () => {
beforeEach(() => {
vi.clearAllMocks();
useAuthStore.setState({
user: mockUser,
accessToken: "fake-token",
isInitializing: false,
});
useKeyStore.setState({ masterKey: mockMasterKey });
});
it("should set canvas to readOnly when status is VAULT", async () => {
server.use(
http.get(`${API_URL}${endpoints.LETTERS}:id/`, () => {
return HttpResponse.json({
public_id: "test-id",
status: "DRAFT",
updated_at: new Date().toISOString(),
encrypted_content: "{}",
encrypted_metadata: "{}",
encrypted_dek: "wrapped-dek",
});
}),
http.put(`${API_URL}${endpoints.LETTERS}:id/`, () => {
return HttpResponse.json({ status: "success" });
}),
);
const { container } = render(
<MemoryRouter initialEntries={["/write/test-id"]}>
<Routes>
<Route path="/write/:public_id" element={<Editor />} />
</Routes>
</MemoryRouter>,
);
// Wait for initial load to complete
await waitFor(() => {
expect(screen.queryByText(/Opening your draft/i)).not.toBeInTheDocument();
});
// Initial state: DRAFT (not read-only)
const canvas = screen.getByTestId("canvas");
expect(canvas.getAttribute("data-readonly")).toBe("false");
// Click Seal in the main toolbar (it's in the div with id="writer-toolbar")
const toolbar = container.querySelector("#writer-toolbar");
const sealBtn = toolbar?.querySelector(".btn-primary");
if (!sealBtn) throw new Error("Seal button not found");
fireEvent.click(sealBtn);
// Click Vault to show confirm modal
const vaultBtn = screen.getByRole("button", { name: /vault/i });
fireEvent.click(vaultBtn);
// Set date and submit vault form
const dateInput = container.querySelector('input[name="vault-date"]');
if (!dateInput) throw new Error("Date input not found");
fireEvent.change(dateInput, { target: { value: "2026-12-31" } });
const confirmVaultBtn = container.querySelector(
'button[form="vault-form"]',
);
if (!confirmVaultBtn) throw new Error("Confirm vault button not found");
fireEvent.click(confirmVaultBtn);
// Wait for save to complete and check readOnly
await waitFor(() => {
expect(screen.getByText(/Your letter is saved/i)).toBeInTheDocument();
});
expect(canvas.getAttribute("data-readonly")).toBe("true");
expect(screen.getByLabelText(/recipient/i)).toBeDisabled();
});
it("should set canvas to readOnly when status is SEALED", async () => {
server.use(
http.get(`${API_URL}${endpoints.LETTERS}:id/`, () => {
return HttpResponse.json({
public_id: "test-id",
status: "DRAFT",
updated_at: new Date().toISOString(),
encrypted_content: "{}",
encrypted_metadata: "{}",
encrypted_dek: "wrapped-dek",
});
}),
http.put(`${API_URL}${endpoints.LETTERS}:id/`, () => {
return HttpResponse.json({ status: "success" });
}),
);
const { container } = render(
<MemoryRouter initialEntries={["/write/test-id"]}>
<Routes>
<Route path="/write/:public_id" element={<Editor />} />
</Routes>
</MemoryRouter>,
);
await waitFor(() => {
expect(screen.queryByText(/Opening your draft/i)).not.toBeInTheDocument();
});
const canvas = screen.getByTestId("canvas");
const toolbar = container.querySelector("#writer-toolbar");
const sealBtn = toolbar?.querySelector(".btn-primary");
if (!sealBtn) throw new Error("Seal button not found");
fireEvent.click(sealBtn);
// The secondary seal button appears (it has btn-accent class)
const secondarySealBtn = container.querySelector(".btn-accent");
if (!secondarySealBtn) throw new Error("Secondary seal button not found");
fireEvent.click(secondarySealBtn);
await waitFor(() => {
expect(screen.getByText(/Your letter is saved/i)).toBeInTheDocument();
});
expect(canvas.getAttribute("data-readonly")).toBe("true");
expect(screen.getByLabelText(/recipient/i)).toBeDisabled();
});
});
+131 -88
View File
@@ -1,7 +1,11 @@
import { import {
ClockIcon, ClockIcon,
DownloadSimpleIcon, DownloadSimpleIcon,
ImageIcon,
LockIcon,
SpinnerGapIcon, SpinnerGapIcon,
TrayIcon,
XCircleIcon,
XIcon, XIcon,
} from "@phosphor-icons/react"; } from "@phosphor-icons/react";
import { useEffect, useRef, useState } from "react"; import { useEffect, useRef, useState } from "react";
@@ -14,17 +18,10 @@ import { api } from "../api/apiClient";
import { import {
type CanvasTools, type CanvasTools,
ComposeCanvas, ComposeCanvas,
} from "../components/editor/ComposeCanvas"; } from "../components/ui/ComposeCanvas";
import { PostSealModal } from "../components/editor/PostSealModal";
import {
LetterHead,
ToolBar,
VaultConfirmModal,
} from "../components/editor/ToolBar";
import DateDisplay from "../components/ui/DateDisplay"; import DateDisplay from "../components/ui/DateDisplay";
import { LogModal } from "../components/ui/LogModal"; import { LogModal } from "../components/ui/LogModal";
import { Navbar } from "../components/ui/Navbar"; import { Navbar } from "../components/ui/Navbar";
import { endpoints } from "../config/endpoints"; import { endpoints } from "../config/endpoints";
import { PATHS } from "../config/routes"; import { PATHS } from "../config/routes";
import { useKeyStore } from "../store/useKeyStore"; import { useKeyStore } from "../store/useKeyStore";
@@ -38,12 +35,6 @@ const OVERLAY_FADE_MS = 250;
const SAVED_VISIBLE_MS = 1400; const SAVED_VISIBLE_MS = 1400;
const ERROR_VISIBLE_MS = 2400; const ERROR_VISIBLE_MS = 2400;
const toPlaceholderList = [
"Someone dear...",
"Somewhere near...",
"Something to bear...",
];
export default function Editor() { export default function Editor() {
const navigate = useNavigate(); const navigate = useNavigate();
const navigateRef = useRef<NavigateFunction>(navigate); const navigateRef = useRef<NavigateFunction>(navigate);
@@ -60,39 +51,21 @@ export default function Editor() {
}>({ status: "RESET", message: "", log: "" }); }>({ status: "RESET", message: "", log: "" });
const [isInitialLoading, setIsInitialLoading] = useState(false); const [isInitialLoading, setIsInitialLoading] = useState(false);
const [sealedTargetId, setSealedTargetId] = useState<string | null>(null); const [shareLink, setShareLink] = useState<string | null>(null);
const [lastSaved, setLastSaved] = useState<string | null>(null); const [lastSaved, setLastSaved] = useState<string | null>(null);
const [status, setLetterStatus] = useState<"DRAFT" | "SEALED" | "VAULT">( const [status, setStatus] = useState<"DRAFT" | "SEALED">("DRAFT");
"DRAFT",
);
const [isSaveDatePulsing, setIsSaveDatePulsing] = useState(false); const [isSaveDatePulsing, setIsSaveDatePulsing] = useState(false);
const [lastSavedPulseTick, setLastSavedPulseTick] = useState(0); const [lastSavedPulseTick, setLastSavedPulseTick] = useState(0);
const [sealBtnClicked, setSealBtnClicked] = useState<boolean>(false);
const [saveOverlay, setSaveOverlay] = useState<SaveOverlay>("idle"); const [saveOverlay, setSaveOverlay] = useState<SaveOverlay>("idle");
const [showSaveOverlay, setShowSaveOverlay] = useState(false); const [showSaveOverlay, setShowSaveOverlay] = useState(false);
const [confirmModal, setConfirmModal] = useState<"VAULT" | "SEAL" | null>(
null,
);
const [recipient, setRecipient] = useState(""); const [recipient, setRecipient] = useState("");
const [unlockDate, setUnlockDate] = useState<Date | null>(null);
const [placeholderIndex, setPlaceholderIndex] = useState(0);
const { masterKey } = useKeyStore(); const { masterKey } = useKeyStore();
const canvasRef = useRef<CanvasTools>(null); const canvasRef = useRef<CanvasTools>(null);
const fileInputRef = useRef<HTMLInputElement>(null); const fileInputRef = useRef<HTMLInputElement>(null);
// Placeholder rotation
useEffect(() => {
const interval = setInterval(() => {
setPlaceholderIndex((prev) => (prev + 1) % toPlaceholderList.length);
}, 4000);
return () => clearInterval(interval);
}, []);
useEffect(() => { useEffect(() => {
if (!(public_id && masterKey)) return; if (!(public_id && masterKey)) return;
if (justSavedRef.current) { if (justSavedRef.current) {
@@ -109,7 +82,7 @@ export default function Editor() {
const letterData = res.data; const letterData = res.data;
setLastSaved(formatRelativeDate(new Date(letterData.updated_at))); setLastSaved(formatRelativeDate(new Date(letterData.updated_at)));
setLetterStatus(letterData.status); setStatus(letterData.status);
if (letterData.status === "SEALED") { if (letterData.status === "SEALED") {
navigateRef.current(PATHS.read(public_id), { replace: true }); navigateRef.current(PATHS.read(public_id), { replace: true });
@@ -156,6 +129,8 @@ export default function Editor() {
}); });
} }
console.log(canvasData);
if (canvasRef.current) { if (canvasRef.current) {
await canvasRef.current.loadData(canvasData); await canvasRef.current.loadData(canvasData);
} }
@@ -217,12 +192,7 @@ export default function Editor() {
} }
}; };
const handleSave = async ( const handleSave = async (status: "SEALED" | "DRAFT"): Promise<void> => {
status: "SEALED" | "DRAFT" | "VAULT",
vaultDate?: Date,
): Promise<void> => {
setSealBtnClicked(false);
let targetId = public_id || letterIdRef.current; let targetId = public_id || letterIdRef.current;
if (!targetId) { if (!targetId) {
targetId = crypto.randomUUID(); targetId = crypto.randomUUID();
@@ -258,18 +228,9 @@ export default function Editor() {
); );
const formData = new FormData(); const formData = new FormData();
if (status === "VAULT") { formData.append("public_id", targetId);
const finalDate = vaultDate || unlockDate;
formData.append("type", "VAULT");
if (finalDate) {
formData.append("unlock_at", finalDate.toISOString());
}
formData.append("status", "SEALED");
} else {
formData.append("type", "KEPT"); formData.append("type", "KEPT");
formData.append("status", status); formData.append("status", status);
}
formData.append("public_id", targetId);
formData.append("encrypted_content", encrypted_letter.encrypted_content); formData.append("encrypted_content", encrypted_letter.encrypted_content);
formData.append("encrypted_dek", encrypted_letter.encrypted_dek); formData.append("encrypted_dek", encrypted_letter.encrypted_dek);
formData.append( formData.append(
@@ -290,20 +251,31 @@ export default function Editor() {
} }
setLastSaved(formatRelativeDate(new Date())); setLastSaved(formatRelativeDate(new Date()));
setLetterStatus(status); setStatus(status);
setLastSavedPulseTick((prev) => prev + 1); setLastSavedPulseTick((prev) => prev + 1);
if (status === "SEALED") { if (status === "SEALED" && encrypted_letter.sharingKey) {
setSealedTargetId(targetId); const link = `${window.location.origin}${PATHS.read(
} targetId,
)}#${encrypted_letter.sharingKey}`;
setShareLink(link);
setShowSaveOverlay(false);
setTimeout(() => setSaveOverlay("idle"), OVERLAY_FADE_MS);
} else {
setSaveOverlay("saved"); setSaveOverlay("saved");
setShowSaveOverlay(true); setShowSaveOverlay(true);
}
} catch (_error) { } catch (_error) {
setSaveOverlay("error"); setSaveOverlay("error");
setShowSaveOverlay(true); setShowSaveOverlay(true);
} }
}; };
const copyToClipboard = async () => {
if (!shareLink) return;
await navigator.clipboard.writeText(shareLink);
};
return ( return (
<> <>
<Navbar <Navbar
@@ -313,18 +285,18 @@ export default function Editor() {
isSaveDatePulsing ? "animate-pulse" : "" isSaveDatePulsing ? "animate-pulse" : ""
}`} }`}
> >
<div className="text-sm text-neutral-content/30 flex-col justify-end leading-none text-right">
<span className="text-[10px] uppercase tracking-widest font-bold">
Last Save
</span>
<br />
<span className="italic">{lastSaved}</span>
</div>
<ClockIcon <ClockIcon
size={16} size={16}
weight="bold" weight="bold"
className="text-neutral-content/30" className="text-neutral-content/30"
/> />
<p className="text-sm text-neutral-content/30 flex-col justify-end leading-none text-right">
<span className="text-[10px] uppercase tracking-widest font-bold">
Last Save
</span>
<br />
<span className="italic">{lastSaved}</span>
</p>
</div> </div>
} }
/> />
@@ -355,7 +327,53 @@ export default function Editor() {
</div> </div>
)} )}
{saveOverlay !== "idle" && ( {shareLink && (
<div className="modal modal-open modal-bottom sm:modal-middle bg-base-100/20 backdrop-blur-md z-100">
<div className="modal-box bg-base-100 border border-base-content/5 shadow-2xl relative">
<button
type="button"
className="btn btn-sm btn-circle btn-ghost absolute right-2 top-2"
onClick={() => setShareLink(null)}
aria-label="Close"
>
<XCircleIcon size={18} weight="bold" />
</button>
<div className="flex flex-col items-center text-center gap-6 py-4">
<div className="w-16 h-16 bg-primary/10 rounded-full flex items-center justify-center">
<LockIcon size={32} weight="fill" className="text-primary" />
</div>
<div className="space-y-2">
<h3 className="font-serif text-3xl">Sealed & Ready</h3>
<p className="text-base-content/60 text-sm max-w-xs">
This letter is now encrypted. Share this secret link with
your recipient.
</p>
</div>
<div className="w-full flex items-center gap-2 bg-base-300 p-2 rounded-xl group relative">
<input
readOnly
value={shareLink}
className="flex-1 bg-transparent text-xs font-mono px-2 overflow-hidden text-ellipsis whitespace-nowrap outline-none"
/>
<button
type="button"
onClick={copyToClipboard}
className="btn btn-primary btn-sm rounded-lg"
>
Copy
</button>
</div>
<p className="text-[10px] uppercase tracking-widest text-base-content/30">
Zero-Knowledge: The key is in the link, not our servers.
</p>
</div>
</div>
</div>
)}
{saveOverlay !== "idle" && !shareLink && (
<div <div
className={`modal modal-open bg-base-100/20 backdrop-blur-md transition-opacity duration-300 ${ className={`modal modal-open bg-base-100/20 backdrop-blur-md transition-opacity duration-300 ${
showSaveOverlay ? "opacity-100" : "opacity-0" showSaveOverlay ? "opacity-100" : "opacity-0"
@@ -411,17 +429,6 @@ export default function Editor() {
</div> </div>
)} )}
{confirmModal === "VAULT" && (
<VaultConfirmModal
onSave={handleSave}
setConfirmModal={setConfirmModal}
setUnlockDate={setUnlockDate}
/>
)}
{sealedTargetId && (
<PostSealModal sealedTargetId={sealedTargetId} navigate={navigate} />
)}
<div className="max-w-180 mx-auto px-1 md:px-0"> <div className="max-w-180 mx-auto px-1 md:px-0">
<div className="flex justify-between items-end mb-16 border-b border-base-content/5 pb-8 px-0"> <div className="flex justify-between items-end mb-16 border-b border-base-content/5 pb-8 px-0">
<div className="flex flex-col gap-2 flex-1"> <div className="flex flex-col gap-2 flex-1">
@@ -434,9 +441,9 @@ export default function Editor() {
<input <input
id="recipient" id="recipient"
type="text" type="text"
placeholder={toPlaceholderList[placeholderIndex]} placeholder="Someone dear..."
value={recipient} value={recipient}
disabled={status !== "DRAFT"} disabled={status === "SEALED"}
onChange={(e) => setRecipient(e.target.value)} onChange={(e) => setRecipient(e.target.value)}
className="bg-transparent border-none outline-none text-2xl md:text-3xl lg:text-4xl font-serif text-base-content placeholder:text-base-content/10 w-full disabled:opacity-50" className="bg-transparent border-none outline-none text-2xl md:text-3xl lg:text-4xl font-serif text-base-content placeholder:text-base-content/10 w-full disabled:opacity-50"
/> />
@@ -445,17 +452,18 @@ export default function Editor() {
</div> </div>
{status === "DRAFT" ? ( {status === "DRAFT" ? (
<ToolBar <div
fileInputRef={fileInputRef} id="writer-toolbar"
sealBtnClicked={sealBtnClicked} className="flex items-center justify-between mb-8 h-14 bg-base-100/50 backdrop-blur-md rounded-full border border-base-content/5 px-6"
setSealBtnClicked={setSealBtnClicked} >
onSave={handleSave} <div className="flex gap-4">
setConfirmModal={setConfirmModal} <button
/> type="button"
) : ( className="btn btn-ghost btn-sm"
<LetterHead /> onClick={() => fileInputRef.current?.click()}
)} >
<ImageIcon size={18} weight="bold" />
</button>
<input <input
type="file" type="file"
ref={fileInputRef} ref={fileInputRef}
@@ -463,8 +471,43 @@ export default function Editor() {
accept="image/*" accept="image/*"
className="hidden" className="hidden"
/> />
</div>
<ComposeCanvas ref={canvasRef} readOnly={status !== "DRAFT"} /> <div className="flex items-center gap-2">
<button
type="button"
className="btn btn-ghost btn-sm text-[10px] tracking-[0.2em] uppercase font-bold text-base-content/60 hover:text-base-content"
title="Store in your private drawer"
onClick={() => handleSave("DRAFT")}
>
<TrayIcon size={18} weight="bold" />
<span className="hidden md:inline">Store</span>
</button>
<div className="w-px h-4 bg-base-content/10 mx-2" />
<button
type="button"
className="btn btn-primary btn-sm rounded-full px-6"
onClick={() => handleSave("SEALED")}
>
<LockIcon size={14} weight="fill" className="mr-1" />
Seal
</button>
</div>
</div>
) : (
<div className="flex items-center justify-center mb-8 h-14">
<div className="badge badge-outline border-primary/20 bg-primary/5 text-primary gap-2 p-4 rounded-full">
<LockIcon size={14} weight="fill" />
<span className="text-[10px] uppercase tracking-widest font-bold">
Sealed & View Only
</span>
</div>
</div>
)}
<ComposeCanvas ref={canvasRef} readOnly={status === "SEALED"} />
</div> </div>
</section> </section>
</> </>
+3 -23
View File
@@ -44,19 +44,7 @@ describe("Login Page", () => {
expect(await screen.findByText(/technical issues/i)).toBeInTheDocument(); expect(await screen.findByText(/technical issues/i)).toBeInTheDocument();
}); });
it.each([ it("should redirect to the drawer when login is successful", async () => {
{
locationState: undefined,
nextRoute: "Drawer",
},
{
locationState: { redirectUrl: "/read/123" },
nextRoute: "Reader",
},
])("should redirect to the next route when login is successful", async ({
locationState,
nextRoute,
}) => {
const mockUser = { const mockUser = {
public_id: "user-123", public_id: "user-123",
email: "test@example.com", email: "test@example.com",
@@ -73,18 +61,10 @@ describe("Login Page", () => {
); );
render( render(
<MemoryRouter <MemoryRouter initialEntries={["/login"]}>
initialEntries={[
{
pathname: "/login",
state: locationState,
},
]}
>
<Routes> <Routes>
<Route path="/login" element={<Login />} /> <Route path="/login" element={<Login />} />
<Route path="/drawer" element={<div>Drawer</div>} /> <Route path="/drawer" element={<div>Drawer</div>} />
<Route path="/read/:publicId" element={<div>Reader</div>} />
</Routes> </Routes>
</MemoryRouter>, </MemoryRouter>,
); );
@@ -93,6 +73,6 @@ describe("Login Page", () => {
await userEvent.type(screen.getByLabelText(/password/i), "password123"); await userEvent.type(screen.getByLabelText(/password/i), "password123");
await userEvent.click(screen.getByRole("button", { name: /sign in/i })); await userEvent.click(screen.getByRole("button", { name: /sign in/i }));
expect(await screen.findByText(nextRoute)).toBeInTheDocument(); expect(await screen.findByText(/Drawer/i)).toBeInTheDocument();
}); });
}); });
+54 -55
View File
@@ -20,57 +20,6 @@ const loginSchema = z.object({
type LoginInputs = z.infer<typeof loginSchema>; type LoginInputs = z.infer<typeof loginSchema>;
function WelcomeModal({ setShowWelcome }) {
return (
<div className="modal modal-open backdrop-blur-sm transition-all duration-1000">
<div className="modal-box border border-primary/20 shadow-2xl p-8">
<div className="flex flex-col items-center text-center gap-4">
<div className="bg-primary/10 p-4 rounded-full animate-pulse">
<ShieldCheckIcon
size={48}
weight="duotone"
className="text-primary"
/>
</div>
<h3 className="font-display text-2xl font-bold text-primary">
Welcome to <Logo />!
</h3>
<p className="text-base-content/80 leading-relaxed">
To ensure <span className="font-bold">complete privacy</span>, all
your letters are{" "}
<span className="font-bold underline">
sealed with your password
</span>
, which only you have access to.
<br />
<span className="font-bold">
The server never sees it, and it's a solemn promise!
</span>
</p>
<div className="alert alert-warning bg-paper/20 border-paper/20 flex items-start gap-3 text-left py-3">
<WarningIcon size={24} weight="fill" className="shrink-0 mt-0.5" />
<p className="text-sm font-medium text-primary-content">
If you ever happen to forget your password, your letters are lost
to time, forever.
</p>
</div>
<div className="modal-action w-full">
<button
type="button"
onClick={() => setShowWelcome(false)}
className="btn btn-primary w-full shadow-lg"
>
I understand
</button>
</div>
</div>
</div>
</div>
);
}
export default function Login() { export default function Login() {
const navigate = useNavigate(); const navigate = useNavigate();
const location = useLocation(); const location = useLocation();
@@ -78,7 +27,6 @@ export default function Login() {
const [apiError, setApiError] = useState<string | null>(null); const [apiError, setApiError] = useState<string | null>(null);
const { setAuthStore } = useAuth(); const { setAuthStore } = useAuth();
const [showWelcome, setShowWelcome] = useState(!!location.state?.firstTime); const [showWelcome, setShowWelcome] = useState(!!location.state?.firstTime);
const nextRoute = location.state?.redirectUrl || ROUTES.DRAWER;
const { const {
register, register,
@@ -111,7 +59,7 @@ export default function Login() {
// store the auth related data // store the auth related data
await setAuthStore(authData.access, userData, masterKey); await setAuthStore(authData.access, userData, masterKey);
navigate(nextRoute, { replace: true }); navigate(ROUTES.DRAWER);
} catch (err) { } catch (err) {
let message = let message =
"Sorry, we're experiencing technical issues.\nPlease try again later."; "Sorry, we're experiencing technical issues.\nPlease try again later.";
@@ -126,10 +74,61 @@ export default function Login() {
return ( return (
<div className="flex flex-col gap-3"> <div className="flex flex-col gap-3">
{showWelcome && <WelcomeModal setShowWelcome={setShowWelcome} />} {showWelcome && (
<div className="modal modal-open backdrop-blur-sm transition-all duration-1000">
<div className="modal-box border border-primary/20 shadow-2xl p-8">
<div className="flex flex-col items-center text-center gap-4">
<div className="bg-primary/10 p-4 rounded-full animate-pulse">
<ShieldCheckIcon
size={48}
weight="duotone"
className="text-primary"
/>
</div>
<h3 className="font-display text-2xl font-bold text-primary">
Welcome to <Logo />!
</h3>
<p className="text-base-content/80 leading-relaxed">
To ensure <span className="font-bold">complete privacy</span>,
all your letters are{" "}
<span className="font-bold underline">
sealed with your password
</span>
, which only you have access to.
<br />
<span className="font-bold">
The server never sees it, and it's a solemn promise!
</span>
</p>
<div className="alert alert-warning bg-paper/20 border-paper/20 flex items-start gap-3 text-left py-3">
<WarningIcon
size={24}
weight="fill"
className="shrink-0 mt-0.5"
/>
<p className="text-sm font-medium text-primary-content">
If you ever happen to forget your password, your letters are
lost to time, forever.
</p>
</div>
<div className="modal-action w-full">
<button
type="button"
onClick={() => setShowWelcome(false)}
className="btn btn-primary w-full shadow-lg"
>
I understand
</button>
</div>
</div>
</div>
</div>
)}
<div className="glass-card w-full max-w-sm p-2 transition-all duration-500 hover:shadow-2xl fade-zoom"> <div className="glass-card w-full max-w-sm p-2 transition-all duration-500 hover:shadow-2xl fade-zoom">
<form onSubmit={handleSubmit(onSubmit)} className="card-body gap-4"> <form onSubmit={handleSubmit(onSubmit)} className="card-body gap-4">
<h1 className="card-title font-display text-2xl justify-center text-primary/80 tracking-tight"> <h1 className="card-title font-display text-2xl font-bold justify-center text-primary tracking-tight">
Sign in to <Logo /> Sign in to <Logo />
</h1> </h1>
+47 -75
View File
@@ -1,15 +1,28 @@
import { render, screen, waitFor } from "@testing-library/react"; import { render, screen } from "@testing-library/react";
import { HttpResponse, http } from "msw"; import { HttpResponse, http } from "msw";
import { MemoryRouter, Route, Routes, useLocation } from "react-router-dom"; import { MemoryRouter, Route, Routes } from "react-router-dom";
import { beforeEach, describe, expect, it, vi } from "vitest"; import { beforeEach, describe, expect, it, vi } from "vitest";
import { server } from "../../test/mocks/server"; import { server } from "../../test/mocks/server";
import { endpoints } from "../config/endpoints"; import { endpoints } from "../config/endpoints";
import { useKeyStore } from "../store/useKeyStore";
import { CryptoUtils } from "../utils/crypto"; import { CryptoUtils } from "../utils/crypto";
import Reader from "./Reader"; import Reader from "./Reader";
const API_URL = import.meta.env.VITE_API_URL; const API_URL = import.meta.env.VITE_API_URL;
// Spy on crypto methods so we don't have to do actual decryption in the UI test
const spyDecryptLetter = vi.spyOn(
CryptoUtils.prototype,
"decryptLetterWithSharingKey",
);
const spyDecryptMetadata = vi.spyOn(
CryptoUtils.prototype,
"decryptMetadataWithSharingKey",
);
const spyDecryptImage = vi.spyOn(
CryptoUtils.prototype,
"decryptImageWithSharingKey",
);
// Fabric.js needs to know when fonts are loaded // Fabric.js needs to know when fonts are loaded
Object.defineProperty(document, "fonts", { Object.defineProperty(document, "fonts", {
value: { ready: Promise.resolve() }, value: { ready: Promise.resolve() },
@@ -17,27 +30,13 @@ Object.defineProperty(document, "fonts", {
}); });
describe("Reader Page", () => { describe("Reader Page", () => {
let masterKey: CryptoKey; beforeEach(() => {
let utils: CryptoUtils;
const LocationTest = () => {
const location = useLocation();
return (
<div data-testid="location-state">
{JSON.stringify(location.state || {})}
</div>
);
};
beforeEach(async () => {
vi.clearAllMocks(); vi.clearAllMocks();
utils = new CryptoUtils(); // Default mock behavior for successful decryption
await utils.initialize(); spyDecryptLetter.mockResolvedValue('{"objects": []}');
const bundle = await CryptoUtils.deriveKeyBundle("password", "salt"); spyDecryptMetadata.mockResolvedValue({ recipient: "Guest" });
masterKey = bundle.masterKey; spyDecryptImage.mockResolvedValue("blob:url");
// User is logged in by default
useKeyStore.setState({ masterKey });
// Clear the URL hash // Clear the URL hash
vi.stubGlobal("location", { vi.stubGlobal("location", {
@@ -46,39 +45,47 @@ describe("Reader Page", () => {
}); });
}); });
it("should load and decrypt the letter when a valid key is provided and display the envelope", async () => { it("should notify the user if the sharing key is missing from the URL", async () => {
render(
<MemoryRouter initialEntries={["/read/123"]}>
<Routes>
<Route path="/read/:public_id" element={<Reader />} />
</Routes>
</MemoryRouter>,
);
expect(
await screen.findByText(/No sharing key provided/i),
).toBeInTheDocument();
});
it("should load and decrypt the letter when a valid key is provided", async () => {
const mockPublicId = "test-uuid"; const mockPublicId = "test-uuid";
const letterContent = JSON.stringify({ objects: [] }); const mockKey = "fake-key";
const metadata = { recipient: "Guest" };
// simulate guest
useKeyStore.setState({ masterKey: null });
const encryptedLetter = await utils.encryptLetter(letterContent, masterKey);
const encryptedMetadata = await utils.encryptMetadata(metadata, masterKey);
const sharingKey = encryptedLetter.sharingKey as string;
server.use( server.use(
http.get(`${API_URL}${endpoints.LETTERS}${mockPublicId}/`, () => { http.get(`${API_URL}${endpoints.LETTERS}${mockPublicId}/`, () => {
return HttpResponse.json({ return HttpResponse.json({
encrypted_content: encryptedLetter.encrypted_content, encrypted_content: "packed-content",
encrypted_metadata: encryptedMetadata.encrypted_content, // Reader expects .encrypted_content for metadata too encrypted_metadata: "packed-metadata",
encrypted_dek: encryptedLetter.encrypted_dek,
images: [], images: [],
}); });
}), }),
); );
render( render(
<MemoryRouter initialEntries={[`/read/${mockPublicId}#${sharingKey}`]}> <MemoryRouter initialEntries={[`/read/${mockPublicId}#${mockKey}`]}>
<Routes> <Routes>
<Route path="/read/:public_id" element={<Reader />} /> <Route path="/read/:public_id" element={<Reader />} />
</Routes> </Routes>
</MemoryRouter>, </MemoryRouter>,
); );
await waitFor(() => {
expect(screen.getByText(/Guest/i)).toBeInTheDocument(); // Should show loading state first
}); expect(screen.getByText(/Decrypting.../i)).toBeInTheDocument();
expect(
await screen.findByText(/A sealed message for/i),
).toBeInTheDocument();
}); });
it("should display an error message if the server request fails", async () => { it("should display an error message if the server request fails", async () => {
@@ -103,39 +110,4 @@ describe("Reader Page", () => {
await screen.findByText(/Failed to load letter/i), await screen.findByText(/Failed to load letter/i),
).toBeInTheDocument(); ).toBeInTheDocument();
}); });
it("should navigate to the login page with redirect url when the letter has no sharing key and the user is not logged in", async () => {
const mockPublicId = "4ef9f25f-4f37-477a-891a-4b10541e350c";
const letterContent = JSON.stringify({ objects: [] });
const metadata = { recipient: "Guest" };
useKeyStore.setState({ masterKey: null });
const encryptedLetter = await utils.encryptLetter(letterContent, masterKey);
const encryptedMetadata = await utils.encryptMetadata(metadata, masterKey);
server.use(
http.get(`${API_URL}${endpoints.LETTERS}${mockPublicId}/`, () => {
return HttpResponse.json({
encrypted_content: encryptedLetter.encrypted_content,
encrypted_metadata: encryptedMetadata.encrypted_content,
encrypted_dek: encryptedLetter.encrypted_dek,
images: [],
});
}),
);
render(
<MemoryRouter initialEntries={[`/read/${mockPublicId}`]}>
<Routes>
<Route path="/read/:public_id" element={<Reader />} />
<Route path="/login" element={<LocationTest />} />
</Routes>
</MemoryRouter>,
);
const stateComponent = screen.getByTestId("location-state");
expect(stateComponent).toHaveTextContent(
`"redirectUrl":"/read/${mockPublicId}"`,
);
});
}); });
+66 -173
View File
@@ -1,28 +1,16 @@
import { FlameIcon, PaperPlaneTiltIcon } from "@phosphor-icons/react";
import { useEffect, useRef, useState } from "react"; import { useEffect, useRef, useState } from "react";
import { import { useLocation, useParams } from "react-router-dom";
type NavigateFunction,
useLocation,
useNavigate,
useParams,
} from "react-router-dom";
import { api } from "../api/apiClient"; import { api } from "../api/apiClient";
import Logo from "../components/Logo";
import { import {
type CanvasJSON, type CanvasJSON,
type CanvasTools, type CanvasTools,
ComposeCanvas, ComposeCanvas,
} from "../components/editor/ComposeCanvas"; } from "../components/ui/ComposeCanvas";
import Logo from "../components/Logo";
import { BurnModal } from "../components/reader/BurnModal";
import { EnvelopeReveal } from "../components/reader/EnvelopeReveal";
import { PostActionOverlay } from "../components/reader/PostActionOverlay";
import { ShareModal } from "../components/reader/ShareModal";
import { LogModal } from "../components/ui/LogModal"; import { LogModal } from "../components/ui/LogModal";
import { endpoints } from "../config/endpoints"; import { endpoints } from "../config/endpoints";
import { PATHS } from "../config/routes";
import { useKeyStore } from "../store/useKeyStore"; import { useKeyStore } from "../store/useKeyStore";
import { CryptoUtils } from "../utils/crypto"; import { CryptoUtils } from "../utils/crypto";
import { formatDate } from "../utils/dateFormat";
import { import {
decryptCanvasImages, decryptCanvasImages,
decryptCanvasImagesWithSharingKey, decryptCanvasImagesWithSharingKey,
@@ -30,26 +18,17 @@ import {
interface LetterMetadata { interface LetterMetadata {
recipient?: string; recipient?: string;
updated_at?: string;
} }
export default function Reader() { export default function Reader() {
const { public_id } = useParams(); const { public_id } = useParams();
const location = useLocation(); const location = useLocation();
const navigate = useNavigate();
const sharingKey = location.hash.replace("#", ""); const sharingKey = location.hash.replace("#", "");
const navigateRef = useRef<NavigateFunction>(navigate);
const canvasRef = useRef<CanvasTools>(null); const canvasRef = useRef<CanvasTools>(null);
const [isDecrypting, setIsDecrypting] = useState(true); const [isDecrypting, setIsDecrypting] = useState(true);
const [revealState, setRevealState] = useState< const [error, setError] = useState<string | null>(null);
"sealed" | "revealed" | "burned"
>("sealed");
const [error, setError] = useState<{
message: string;
log: string;
} | null>(null);
const [warning, setWarning] = useState<{ const [warning, setWarning] = useState<{
message: string; message: string;
log: string; log: string;
@@ -57,70 +36,23 @@ export default function Reader() {
const [metadata, setMetadata] = useState<LetterMetadata | null>(null); const [metadata, setMetadata] = useState<LetterMetadata | null>(null);
const [decryptedCanvasData, setDecryptedCanvasData] = const [decryptedCanvasData, setDecryptedCanvasData] =
useState<CanvasJSON | null>(null); useState<CanvasJSON | null>(null);
const [showBurnModal, setShowBurnModal] = useState(false);
const [isBurning, setIsBurning] = useState(false);
const [ignite, setIgnite] = useState(false);
const [encryptedDek, setEncryptedDek] = useState<string | null>(null);
const [shareLink, setShareLink] = useState<string | null>(null);
const { masterKey } = useKeyStore(); const { masterKey } = useKeyStore();
const isAuthor = !!masterKey && !sharingKey;
const handleShare = async () => {
if (!(encryptedDek && masterKey && public_id)) return;
const cryptoUtils = new CryptoUtils();
const key = await cryptoUtils.extractSharingKey(encryptedDek, masterKey);
try {
await api.patch(`${endpoints.LETTERS}${public_id}/`, { type: "SENT" });
} catch (_err) {
} finally {
setShareLink(`${window.location.origin}${PATHS.read(public_id)}#${key}`);
}
};
const burnLetter = async () => {
if (!public_id || isBurning) return;
setIsBurning(true);
try {
await api.patch(`${endpoints.LETTERS}${public_id}/`, {
status: "BURNED",
});
} catch (_err) {
} finally {
setIsBurning(false);
setShowBurnModal(false);
setIgnite(true);
setTimeout(() => {
setRevealState("burned");
}, 13000);
}
};
useEffect(() => { useEffect(() => {
if (!(sharingKey || masterKey)) { if (!(sharingKey || masterKey)) {
navigateRef.current("/login", { setError(
state: { redirectUrl: `/read/${public_id}` }, "No sharing key provided. Please check the link or log in if you are the author.",
}); );
setIsDecrypting(false);
return; return;
} }
const loadAndDecrypt = async () => { const loadAndDecrypt = async () => {
try { try {
const response = await api.get(`${endpoints.LETTERS}${public_id}/`); const response = await api.get(`${endpoints.LETTERS}${public_id}/`);
const { const { encrypted_content, encrypted_metadata, encrypted_dek, images } =
encrypted_content, response.data;
encrypted_metadata,
encrypted_dek,
images,
updated_at,
status,
} = response.data;
if (status === "BURNED")
throw new Error("This letter has been burned.");
if (encrypted_dek) setEncryptedDek(encrypted_dek);
const cryptoUtils = new CryptoUtils(); const cryptoUtils = new CryptoUtils();
const isShared = !!sharingKey; const isShared = !!sharingKey;
@@ -128,7 +60,7 @@ export default function Reader() {
if (isShared && !encrypted_content) throw new Error("Content missing"); if (isShared && !encrypted_content) throw new Error("Content missing");
const isDecryptionKeyAvailable = encrypted_dek && masterKey; const isDecryptionKeyAvailable = encrypted_dek && masterKey;
if (!(isShared || isDecryptionKeyAvailable)) if (!(isShared || isDecryptionKeyAvailable))
throw new Error("Auth required: Decryption key is not available"); throw new Error("Auth required");
// Decrypt Metadata // Decrypt Metadata
const decryptedMetadata = isShared const decryptedMetadata = isShared
@@ -141,10 +73,7 @@ export default function Reader() {
// biome-ignore lint/style/noNonNullAssertion: masterKey is guaranteed to be non-null here as isDecryptionKeyAvailable is true // biome-ignore lint/style/noNonNullAssertion: masterKey is guaranteed to be non-null here as isDecryptionKeyAvailable is true
masterKey!, masterKey!,
); );
setMetadata({ setMetadata(decryptedMetadata as LetterMetadata);
...(decryptedMetadata as LetterMetadata),
updated_at,
});
// Decrypt Content // Decrypt Content
const decryptedContent = isShared const decryptedContent = isShared
@@ -188,10 +117,8 @@ export default function Reader() {
} }
setDecryptedCanvasData(canvasData); setDecryptedCanvasData(canvasData);
} catch (err) { } catch (err) {
setError({ const message = err instanceof Error ? err.message : "Unknown error";
message: `Failed to load letter :(`, setError(`Failed to load letter: ${message}`);
log: err instanceof Error ? err.message : "Unknown error",
});
} finally { } finally {
setIsDecrypting(false); setIsDecrypting(false);
} }
@@ -201,19 +128,14 @@ export default function Reader() {
}, [public_id, sharingKey, masterKey]); }, [public_id, sharingKey, masterKey]);
useEffect(() => { useEffect(() => {
if ( if (!isDecrypting && decryptedCanvasData && canvasRef.current) {
!isDecrypting &&
revealState === "revealed" &&
decryptedCanvasData &&
canvasRef.current
) {
canvasRef.current.loadData(decryptedCanvasData); canvasRef.current.loadData(decryptedCanvasData);
} }
}, [isDecrypting, revealState, decryptedCanvasData]); }, [isDecrypting, decryptedCanvasData]);
if (isDecrypting) { if (isDecrypting) {
return ( return (
<div className="flex items-center justify-center bg-base-100 font-serif"> <div className="min-h-screen flex items-center justify-center bg-base-100 font-serif">
<div className="fixed inset-0 bg-[radial-gradient(circle_at_center,transparent_0%,rgba(0,0,0,0.4)_100%)] pointer-events-none z-0" /> <div className="fixed inset-0 bg-[radial-gradient(circle_at_center,transparent_0%,rgba(0,0,0,0.4)_100%)] pointer-events-none z-0" />
<div className="text-center space-y-6 z-10"> <div className="text-center space-y-6 z-10">
<Logo /> <Logo />
@@ -230,45 +152,33 @@ export default function Reader() {
if (error) { if (error) {
return ( return (
<LogModal <div className="min-h-screen flex items-center justify-center bg-base-100 px-6 font-serif">
isOpen={!!error} <div className="fixed inset-0 bg-[radial-gradient(circle_at_center,transparent_0%,rgba(0,0,0,0.4)_100%)] pointer-events-none z-0" />
onClose={() => (window.location.href = "/")} <div className="max-w-md w-full glass-card p-12 text-center space-y-6 z-10 animate-in fade-in zoom-in-95 duration-700">
message={error.message} <div className="space-y-2">
log={error.log} <h2 className="text-error font-display text-xl">
status="ERROR" Something went wrong
/> </h2>
<p className="text-base-content/60 text-sm leading-relaxed">
{error}
</p>
</div>
<button
type="button"
className="btn btn-ghost btn-sm text-xs uppercase tracking-widest hover:text-primary transition-colors"
onClick={() => (window.location.href = "/")}
>
Return Home
</button>
</div>
</div>
); );
} }
return ( return (
<section className="min-h-fit w-full bg-base-100 px-4 py-8 md:py-16 font-serif relative overflow-hidden"> <section className="min-h-screen w-full bg-base-100 px-4 py-8 md:py-16 font-serif relative overflow-hidden">
{/* Background Ambience */}
<div className="fixed inset-0 bg-[radial-gradient(circle_at_center,transparent_0%,rgba(0,0,0,0.5)_100%)] pointer-events-none z-0" /> <div className="fixed inset-0 bg-[radial-gradient(circle_at_center,transparent_0%,rgba(0,0,0,0.5)_100%)] pointer-events-none z-0" />
<div
className={`transition-all delay-300 duration-1000 relative ${
revealState === "revealed"
? "opacity-0 w-0 h-0 overflow-hidden invisible"
: "opacity-100"
}`}
>
{revealState === "sealed" && (
<div className="h-[80vh] mx-auto flex-col items-center flex justify-center">
<div className="perspective-distant scale-80 duration-1000 transition-all animate-[pulse_2s_linear_1]">
<EnvelopeReveal
recipient={metadata?.recipient || "Someone dear"}
date={
metadata?.updated_at
? formatDate(new Date(metadata.updated_at))
: undefined
}
onRevealComplete={() => setRevealState("revealed")}
ignite={ignite}
/>
</div>
</div>
)}
</div>
{ignite && <PostActionOverlay revealState={revealState} />}
<LogModal <LogModal
isOpen={!!warning} isOpen={!!warning}
@@ -278,12 +188,34 @@ export default function Reader() {
status="WARN" status="WARN"
/> />
{revealState === "revealed" && ( <div className="max-w-4xl mx-auto space-y-8 relative z-10">
<div className="max-w-4xl m-8 mx-auto space-y-8 h-full relative inset-0 z-100"> {/* Floating Header */}
<div className="glass-card px-6 py-4 flex items-center justify-between animate-in fade-in slide-in-from-top-6 duration-1000">
<div className="flex items-center gap-4">
<Logo />
<div className="h-4 w-px bg-base-content/10 hidden sm:block" />
{metadata?.recipient && (
<p className="text-[11px] uppercase tracking-[0.2em] text-base-content/40 hidden sm:block">
A sealed letter for{" "}
<span className="text-base-content/60 font-semibold">
{metadata.recipient}
</span>
</p>
)}
</div>
<button
type="button"
className="btn btn-ghost btn-circle btn-sm hover:rotate-90 transition-transform duration-500"
onClick={() => (window.location.href = "/")}
aria-label="Close"
></button>
</div>
{/* The Letter */}
<div className="relative group perspective-1000"> <div className="relative group perspective-1000">
<div className="absolute inset-0 bg-primary/5 blur-3xl rounded-full scale-75 opacity-0 group-hover:opacity-100 transition-opacity duration-1000 pointer-events-none" /> <div className="absolute inset-0 bg-primary/5 blur-3xl rounded-full scale-75 opacity-0 group-hover:opacity-100 transition-opacity duration-1000 pointer-events-none" />
<div className="bg-paper shadow-warm rounded-sm overflow-hidden animate-[opacity_1s_ease-in-out_1]"> <div className="bg-paper shadow-warm rounded-sm overflow-hidden animate-in fade-in zoom-in-95 slide-in-from-bottom-8 duration-1000 delay-300 fill-mode-backwards rotate-[-0.3deg] hover:rotate-0 transition-transform">
<div className="p-1 md:p-2 bg-base-content/5 opacity-10 pointer-events-none absolute inset-0 z-10" /> <div className="p-1 md:p-2 bg-base-content/5 opacity-10 pointer-events-none absolute inset-0 z-10" />
<ComposeCanvas ref={canvasRef} readOnly /> <ComposeCanvas ref={canvasRef} readOnly />
</div> </div>
@@ -295,49 +227,10 @@ export default function Reader() {
)} )}
</div> </div>
</div> </div>
)}
{shareLink && (
<ShareModal shareLink={shareLink} setShareLink={setShareLink} />
)}
{showBurnModal && (
<BurnModal
burnLetter={burnLetter}
isBurning={isBurning}
setShowBurnModal={setShowBurnModal}
setRevealState={setRevealState}
/>
)}
{isAuthor && revealState !== "burned" && (
<div className="flex justify-center gap-2 mt-8 z-10 relative">
<button
id="share-letter-btn"
type="button"
className="btn btn-ghost btn-sm text-base-content/30 hover:text-base-content hover:bg-base-content/10 gap-1.5"
onClick={handleShare}
>
<PaperPlaneTiltIcon size={16} weight="duotone" />
<span className="text-md uppercase font-sans tracking-widest">
Send to someone
</span>
</button>
<button
id="burn-letter-btn"
type="button"
className="btn btn-ghost btn-sm text-error/40 hover:text-error hover:bg-error/10 gap-1.5"
onClick={() => setShowBurnModal(true)}
>
<FlameIcon size={16} weight="duotone" />
<span className="text-md uppercase font-sans tracking-widest">
Burn the letter
</span>
</button>
</div>
)}
{/* Atmospheric Footer */}
<footer className="mt-16 text-center z-10 opacity-10 pointer-events-none"> <footer className="mt-16 text-center z-10 opacity-10 pointer-events-none">
<p className="text-xs font-sans uppercase tracking-[0.5em]"> <p className="text-[9px] uppercase tracking-[0.5em]">
Read. Remember. Release. Read. Remember. Release.
</p> </p>
</footer> </footer>
+4 -4
View File
@@ -55,7 +55,7 @@ export default function Register() {
email: data.email, email: data.email,
password: authHash, password: authHash,
}); });
navigate(ROUTES.VERIFY_EMAIL, { replace: true }); navigate(ROUTES.VERIFY_EMAIL);
} catch (err) { } catch (err) {
let message = "Registration failed. Please try again."; let message = "Registration failed. Please try again.";
if (axios.isAxiosError(err)) { if (axios.isAxiosError(err)) {
@@ -70,7 +70,7 @@ export default function Register() {
return ( return (
<div className="glass-card w-full max-w-sm p-2 transition-all duration-500 hover:shadow-2xl fade-zoom"> <div className="glass-card w-full max-w-sm p-2 transition-all duration-500 hover:shadow-2xl fade-zoom">
<form onSubmit={handleSubmit(onSubmit)} className="card-body gap-4"> <form onSubmit={handleSubmit(onSubmit)} className="card-body gap-4">
<h1 className="card-title font-display text-2xl justify-center text-primary/80 tracking-tight"> <h1 className="card-title font-display text-2xl font-bold justify-center text-primary tracking-tight">
Create a <Logo /> Account Create a <Logo /> Account
</h1> </h1>
@@ -81,7 +81,7 @@ export default function Register() {
)} )}
<FormField <FormField
label="Pen Name" label="Full Name"
placeholder="Word Smith" placeholder="Word Smith"
registration={register("full_name")} registration={register("full_name")}
error={errors.full_name?.message} error={errors.full_name?.message}
@@ -90,7 +90,7 @@ export default function Register() {
<FormField <FormField
label="Email" label="Email"
type="email" type="email"
placeholder="f.kafka@email.com" placeholder="you@email.com"
registration={register("email")} registration={register("email")}
error={errors.email?.message} error={errors.email?.message}
/> />
+1 -42
View File
@@ -169,7 +169,7 @@ describe("encryptImage / decryptImage", () => {
}); });
}); });
describe("Sharing Key Decryption", () => { describe("Sharing Key Decryption (TDD)", () => {
let masterKey: CryptoKey; let masterKey: CryptoKey;
beforeEach(async () => { beforeEach(async () => {
const bundle = await CryptoUtils.deriveKeyBundle("password", "salt"); const bundle = await CryptoUtils.deriveKeyBundle("password", "salt");
@@ -190,44 +190,3 @@ describe("Sharing Key Decryption", () => {
expect(decryptedLetter).toBe(letterContent); expect(decryptedLetter).toBe(letterContent);
}); });
}); });
describe("extractSharingKey", () => {
let masterKey: CryptoKey;
beforeEach(async () => {
utils = new CryptoUtils();
await utils.initialize();
const bundle = await CryptoUtils.deriveKeyBundle(
"password",
"test@test.com",
);
masterKey = bundle.masterKey;
});
it("should return the same key that encryptLetter embedded as sharingKey", async () => {
const encrypted = await utils.encryptLetter("any content", masterKey);
const extracted = await utils.extractSharingKey(
encrypted.encrypted_dek,
masterKey,
);
expect(extracted).toBe(encrypted.sharingKey);
});
it("extracted key should decrypt the ciphertext produced by encryptLetter", async () => {
const plaintext = "hello from the owner";
const encrypted = await utils.encryptLetter(plaintext, masterKey);
const extracted = await utils.extractSharingKey(
encrypted.encrypted_dek,
masterKey,
);
const decrypted = await utils.decryptLetterWithSharingKey(
encrypted.encrypted_content,
extracted,
);
expect(decrypted).toBe(plaintext);
});
});
-20
View File
@@ -309,24 +309,4 @@ export class CryptoUtils {
); );
return URL.createObjectURL(new Blob([bytes])); return URL.createObjectURL(new Blob([bytes]));
} }
// Re-derives the sharing key (raw DEK) on demand (browser only, not sent to server).
public async extractSharingKey(
encrypted_dek: string,
masterKey: CryptoKey,
): Promise<string> {
const [dekIv, wrappedDek] = this.unpackWithIv(encrypted_dek);
const rawDek = await crypto.subtle.unwrapKey(
"raw",
wrappedDek,
masterKey,
{ name: "AES-GCM", iv: dekIv },
CryptoUtils.AES_GCM,
true,
["decrypt"],
);
return this.toBase64(
new Uint8Array(await crypto.subtle.exportKey("raw", rawDek)),
);
}
} }
+1 -9
View File
@@ -1,5 +1,5 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { formatDate, formatRelativeDate } from "./dateFormat"; import { formatRelativeDate } from "./dateFormat";
describe("formatRelativeDate", () => { describe("formatRelativeDate", () => {
beforeEach(() => { beforeEach(() => {
@@ -35,11 +35,3 @@ describe("formatRelativeDate", () => {
expect(result).toBe("Apr 1, 2026, 6:45 PM"); expect(result).toBe("Apr 1, 2026, 6:45 PM");
}); });
}); });
describe("formatDate", () => {
it("should format a new date as mmm dd, yyyy", () => {
const result = formatDate("2026-04-01T10:15:00Z");
expect(result).toBe("April 1, 2026");
});
});
-29
View File
@@ -7,10 +7,6 @@ const dateTimeFormatter = new Intl.DateTimeFormat("en-US", {
timeStyle: "short", timeStyle: "short",
}); });
const dateFormatter = new Intl.DateTimeFormat("en-US", {
dateStyle: "long",
});
const rtf = new Intl.RelativeTimeFormat("en-US", { const rtf = new Intl.RelativeTimeFormat("en-US", {
numeric: "auto", numeric: "auto",
}); });
@@ -20,7 +16,6 @@ function startOfDay(d: Date) {
} }
export function formatRelativeDate(input: Date | string | number) { export function formatRelativeDate(input: Date | string | number) {
if (!input) return "";
const date = new Date(input); const date = new Date(input);
const now = new Date(); const now = new Date();
@@ -37,27 +32,3 @@ export function formatRelativeDate(input: Date | string | number) {
return dateTimeFormatter.format(date); return dateTimeFormatter.format(date);
} }
export function formatRelativeDateWithoutTime(input: Date | string | number) {
if (!input) return "";
const date = new Date(input);
const now = new Date();
const dayMs = 24 * 60 * 60 * 1000;
const diffDays = Math.round(
(startOfDay(date).getTime() - startOfDay(now).getTime()) / dayMs,
);
if (diffDays === 0) return `Today`;
if (diffDays === -1) return `Yesterday`;
if (diffDays > -7) return `${rtf.format(diffDays, "day")}`;
return date.toDateString();
}
export function formatDate(input: Date | string | number) {
if (!input) return "";
const date = new Date(input);
return dateFormatter.format(date);
}
+6 -8
View File
@@ -152,10 +152,9 @@ describe("letterLogic image helpers", () => {
crypto, crypto,
); );
expect(api.get).toHaveBeenCalledWith( expect(api.get).toHaveBeenCalledWith("https://remote/photo.png.bin", {
"https://remote/photo.png.bin", responseType: "blob",
expect.objectContaining({ responseType: "blob" }), });
);
expect(CryptoUtils.prototype.decryptImage).toHaveBeenCalledWith( expect(CryptoUtils.prototype.decryptImage).toHaveBeenCalledWith(
expect.any(Blob), expect.any(Blob),
"wrapped-dek", "wrapped-dek",
@@ -239,10 +238,9 @@ describe("letterLogic image helpers", () => {
crypto, crypto,
); );
expect(api.get).toHaveBeenCalledWith( expect(api.get).toHaveBeenCalledWith("https://remote/photo.png.bin", {
"https://remote/photo.png.bin", responseType: "blob",
expect.objectContaining({ responseType: "blob" }), });
);
expect( expect(
CryptoUtils.prototype.decryptImageWithSharingKey, CryptoUtils.prototype.decryptImageWithSharingKey,
).toHaveBeenCalledWith(expect.any(Blob), "raw-sharing-key"); ).toHaveBeenCalledWith(expect.any(Blob), "raw-sharing-key");
+10 -23
View File
@@ -2,7 +2,7 @@ import { api } from "../api/apiClient";
import type { import type {
CanvasJSON, CanvasJSON,
FabricImageJSON, FabricImageJSON,
} from "../components/editor/ComposeCanvas"; } from "../components/ui/ComposeCanvas";
import type { CryptoUtils } from "./crypto"; import type { CryptoUtils } from "./crypto";
import { blobUrlToFile } from "./fileUtils"; import { blobUrlToFile } from "./fileUtils";
@@ -28,18 +28,14 @@ export async function decryptCanvasImages(
remoteImages.map((img) => [img.file_name, img.file]), remoteImages.map((img) => [img.file_name, img.file]),
); );
const imageDecryptionPromises = canvasData.objects.map(async (obj, index) => { const decryptionPromises = canvasData.objects.map(async (obj, index) => {
if (obj.type !== "Image") return; if (obj.type !== "Image") return;
const imgObj = obj as FabricImageJSON; const imgObj = obj as FabricImageJSON;
const remoteUrl = imageMap.get(imgObj.src); const remoteUrl = imageMap.get(imgObj.src);
if (!remoteUrl) return; if (!remoteUrl) return;
try { try {
// HACK: For S3 Storage fetch and avoiding CORS error const res = await api.get(remoteUrl, { responseType: "blob" });
const res = await api.get(remoteUrl, {
responseType: "blob",
withCredentials: false,
});
const originalSrc = imgObj.src; const originalSrc = imgObj.src;
const blobUrl = await cryptoUtils.decryptImage( const blobUrl = await cryptoUtils.decryptImage(
@@ -60,7 +56,7 @@ export async function decryptCanvasImages(
} }
}); });
await Promise.all(imageDecryptionPromises); await Promise.all(decryptionPromises);
canvasData.objects = canvasData.objects.filter(Boolean); canvasData.objects = canvasData.objects.filter(Boolean);
return { isDecryptionPartialFailure, error }; return { isDecryptionPartialFailure, error };
} }
@@ -70,16 +66,14 @@ export async function decryptCanvasImagesWithSharingKey(
remoteImages: { file_name: string; file: string }[], remoteImages: { file_name: string; file: string }[],
sharingKey: string, sharingKey: string,
cryptoUtils: CryptoUtils, cryptoUtils: CryptoUtils,
): Promise<{ isDecryptionPartialFailure: boolean; error: string }> { ) {
if (!canvasData?.objects) if (!canvasData?.objects) return;
return { isDecryptionPartialFailure: false, error: "" };
let isDecryptionPartialFailure = false;
let error = "";
const imageMap = new Map( const imageMap = new Map(
remoteImages.map((img) => [img.file_name, img.file]), remoteImages.map((img) => [img.file_name, img.file]),
); );
const decryptionPromises = canvasData.objects.map(async (obj, index) => { const decryptionPromises = canvasData.objects.map(async (obj) => {
if (obj.type !== "Image") return; if (obj.type !== "Image") return;
const imgObj = obj as FabricImageJSON; const imgObj = obj as FabricImageJSON;
@@ -87,24 +81,17 @@ export async function decryptCanvasImagesWithSharingKey(
if (!remoteUrl) return; if (!remoteUrl) return;
try { try {
const res = await api.get(remoteUrl, { const res = await api.get(remoteUrl, { responseType: "blob" });
responseType: "blob",
withCredentials: false,
});
imgObj.src = await cryptoUtils.decryptImageWithSharingKey( imgObj.src = await cryptoUtils.decryptImageWithSharingKey(
res.data, res.data,
sharingKey, sharingKey,
); );
} catch (_error) { } catch (_error) {
delete canvasData.objects[index]; // Keep original or handle failure
isDecryptionPartialFailure = true;
error = _error instanceof Error ? _error.message : "Unknown error";
} }
}); });
await Promise.all(decryptionPromises); await Promise.all(decryptionPromises);
canvasData.objects = canvasData.objects.filter(Boolean);
return { isDecryptionPartialFailure, error };
} }
export async function encryptCanvasImages( export async function encryptCanvasImages(
+8 -17
View File
@@ -5,22 +5,9 @@ import react from "@vitejs/plugin-react";
import { defineConfig, loadEnv } from "vite"; import { defineConfig, loadEnv } from "vite";
import { getBaseUrl } from "./utils/url-builder"; import { getBaseUrl } from "./utils/url-builder";
// https://vite.dev/config/
export default defineConfig(({ mode }) => { export default defineConfig(({ mode }) => {
const env = loadEnv(mode, "../", ""); const env = loadEnv(mode, "../", "");
// PROD Config
if (mode === "production") {
return {
envDir: "../",
plugins: [react(), tailwindcss()],
server: {
port: Number(env.FRONTEND_PORT),
host: env.FRONTEND_DOMAIN,
},
};
}
// DEV Config
const isSslEnabled = env.SSL_ENABLED === "true"; const isSslEnabled = env.SSL_ENABLED === "true";
let sslCerts: { key: Buffer; cert: Buffer } | undefined; let sslCerts: { key: Buffer; cert: Buffer } | undefined;
@@ -33,13 +20,17 @@ export default defineConfig(({ mode }) => {
}; };
} }
const baseApiUrl = getBaseUrl(
isSslEnabled,
env.BACKEND_DOMAIN,
env.BACKEND_PORT,
);
console.log(baseApiUrl);
return { return {
envDir: "../", envDir: "../",
plugins: [react(), tailwindcss()], plugins: [react(), tailwindcss()],
define: { define: {
"import.meta.env.VITE_API_URL": JSON.stringify( "import.meta.env.VITE_API_URL": JSON.stringify(baseApiUrl),
getBaseUrl(isSslEnabled, env.BACKEND_DOMAIN, env.BACKEND_PORT),
),
}, },
server: { server: {
port: Number(env.FRONTEND_PORT), port: Number(env.FRONTEND_PORT),
+1 -1
View File
@@ -10,7 +10,7 @@ export default defineConfig({
VITE_API_URL: "http://piku-server", VITE_API_URL: "http://piku-server",
TZ: "Asia/Kolkata", TZ: "Asia/Kolkata",
}, },
include: ["**/*.test.ts", "**/*.test.tsx"], include: ["**/*.test.ts"],
environment: "jsdom", environment: "jsdom",
globals: true, globals: true,
setupFiles: ["./test/setup.ts"], setupFiles: ["./test/setup.ts"],
+4 -4
View File
@@ -5,22 +5,22 @@ pre-commit:
ruff: ruff:
root: "backend/" root: "backend/"
glob: "*.py" glob: "*.py"
run: export PATH="$HOME/.local/bin:$PATH" && uv run ruff check --fix {staged_files} && uv run ruff format {staged_files} run: uv run ruff check --fix {staged_files} && uv run ruff format {staged_files}
stage_fixed: true stage_fixed: true
django-check: django-check:
root: "backend/" root: "backend/"
run: export PATH="$HOME/.local/bin:$PATH" && uv run manage.py check run: uv run manage.py check
# Frontend: Biome (Linter + Formatter) # Frontend: Biome (Linter + Formatter)
biome: biome:
root: "frontend/" root: "frontend/"
glob: "**/*.{js,ts,jsx,tsx}" glob: "**/*.{js,ts,jsx,tsx}"
run: export PATH="$HOME/.bun/bin:$PATH" && bunx @biomejs/biome check --write --no-errors-on-unmatched {staged_files} run: bunx @biomejs/biome check --write --no-errors-on-unmatched {staged_files}
stage_fixed: true stage_fixed: true
# Frontend: TypeScript # Frontend: TypeScript
tsc: tsc:
root: "frontend/" root: "frontend/"
glob: "**/*.ts, **/*.tsx" glob: "**/*.ts, **/*.tsx"
run: export PATH="$HOME/.bun/bin:$PATH" && bunx tsc --noEmit --incremental --tsBuildInfoFile --ignoreConfig .tsbuildinfo {staged_files} run: bunx tsc --noEmit --incremental --tsBuildInfoFile --ignoreConfig .tsbuildinfo {staged_files}
+180
View File
@@ -0,0 +1,180 @@
WARNING: This is a development server. Do not use it in a production deployment. Use a production WSGI server instead.
* Running on https://127.0.0.1:8001
Press CTRL+C to quit
* Restarting with stat
* Debugger is active!
* Debugger PIN: 411-535-418
Unauthorized: /api/auth/refresh/
127.0.0.1 - - [16/Apr/2026 18:45:15] "POST /api/auth/refresh/ HTTP/1.1" 401 -
Unauthorized: /api/auth/refresh/
127.0.0.1 - - [16/Apr/2026 18:45:15] "POST /api/auth/refresh/ HTTP/1.1" 401 -
Unauthorized: /api/auth/refresh/
127.0.0.1 - - [16/Apr/2026 18:45:15] "POST /api/auth/refresh/ HTTP/1.1" 401 -
Unauthorized: /api/auth/refresh/
127.0.0.1 - - [16/Apr/2026 18:45:16] "POST /api/auth/refresh/ HTTP/1.1" 401 -
127.0.0.1 - - [16/Apr/2026 18:45:16] "OPTIONS /api/auth/register/ HTTP/1.1" 200 -
127.0.0.1 - - [16/Apr/2026 18:45:16] "OPTIONS /api/auth/register/ HTTP/1.1" 200 -
127.0.0.1 - - [16/Apr/2026 18:45:16] "OPTIONS /api/auth/register/ HTTP/1.1" 200 -
127.0.0.1 - - [16/Apr/2026 18:45:16] "OPTIONS /api/auth/register/ HTTP/1.1" 200 -
127.0.0.1 - - [16/Apr/2026 18:45:17] "POST /api/auth/register/ HTTP/1.1" 201 -
127.0.0.1 - - [16/Apr/2026 18:45:17] "POST /api/auth/register/ HTTP/1.1" 201 -
127.0.0.1 - - [16/Apr/2026 18:45:17] "POST /api/auth/register/ HTTP/1.1" 201 -
127.0.0.1 - - [16/Apr/2026 18:45:17] "POST /api/auth/register/ HTTP/1.1" 201 -
Unauthorized: /api/auth/refresh/
127.0.0.1 - - [16/Apr/2026 18:45:19] "POST /api/auth/refresh/ HTTP/1.1" 401 -
127.0.0.1 - - [16/Apr/2026 18:45:19] "GET /api/auth/activate/YmFlZTYyNTgtOTcxMi00ZjFmLWE1YTgtYzBiOGMwODdkY2Zi/d755fh-d81b8ac647be32c14f996ab09e783392/ HTTP/1.1" 200 -
127.0.0.1 - - [16/Apr/2026 18:45:20] "OPTIONS /api/auth/login/ HTTP/1.1" 200 -
Unauthorized: /api/auth/refresh/
127.0.0.1 - - [16/Apr/2026 18:45:20] "POST /api/auth/refresh/ HTTP/1.1" 401 -
127.0.0.1 - - [16/Apr/2026 18:45:20] "GET /api/auth/activate/YzJjM2NkOWUtZmIxYS00MGI2LWFiM2EtYTQwYmI5MDJjZGY2/d755fh-a2be30e77657af68697d0b7be375e5ab/ HTTP/1.1" 200 -
Unauthorized: /api/auth/refresh/
127.0.0.1 - - [16/Apr/2026 18:45:20] "POST /api/auth/refresh/ HTTP/1.1" 401 -
127.0.0.1 - - [16/Apr/2026 18:45:20] "GET /api/auth/activate/Yzc1MDFhMTctY2EzOC00YTY0LTkyNmYtNWYyZjgzNDUyZGQ1/d755fh-677d8f6662cce0e74bb9a776663617a9/ HTTP/1.1" 200 -
/var/home/atom/Documents/code/pi ku/backend/.venv/lib64/python3.14/site-packages/jwt/api_jwt.py:147: InsecureKeyLengthWarning: The HMAC key is 27 bytes long, which is below the minimum recommended length of 32 bytes for SHA256. See RFC 7518 Section 3.2.
return self._jws.encode(
127.0.0.1 - - [16/Apr/2026 18:45:20] "POST /api/auth/login/ HTTP/1.1" 200 -
Unauthorized: /api/auth/refresh/
127.0.0.1 - - [16/Apr/2026 18:45:20] "POST /api/auth/refresh/ HTTP/1.1" 401 -
127.0.0.1 - - [16/Apr/2026 18:45:20] "OPTIONS /api/auth/me/ HTTP/1.1" 200 -
/var/home/atom/Documents/code/pi ku/backend/.venv/lib64/python3.14/site-packages/jwt/api_jwt.py:365: InsecureKeyLengthWarning: The HMAC key is 27 bytes long, which is below the minimum recommended length of 32 bytes for SHA256. See RFC 7518 Section 3.2.
decoded = self.decode_complete(
127.0.0.1 - - [16/Apr/2026 18:45:21] "OPTIONS /api/auth/login/ HTTP/1.1" 200 -
127.0.0.1 - - [16/Apr/2026 18:45:21] "GET /api/auth/me/ HTTP/1.1" 200 -
127.0.0.1 - - [16/Apr/2026 18:45:21] "GET /api/auth/activate/YjI4MjczYWMtYWNlNC00OGRlLWJmZDItZmMyMTJiZjM2MDZl/d755fh-7728ee068008d7513a64dbe6282954f3/ HTTP/1.1" 200 -
127.0.0.1 - - [16/Apr/2026 18:45:21] "OPTIONS /api/letters/ HTTP/1.1" 200 -
127.0.0.1 - - [16/Apr/2026 18:45:21] "OPTIONS /api/letters/ HTTP/1.1" 200 -
127.0.0.1 - - [16/Apr/2026 18:45:21] "GET /api/letters/ HTTP/1.1" 200 -
127.0.0.1 - - [16/Apr/2026 18:45:21] "GET /api/letters/ HTTP/1.1" 200 -
127.0.0.1 - - [16/Apr/2026 18:45:21] "OPTIONS /api/auth/login/ HTTP/1.1" 200 -
127.0.0.1 - - [16/Apr/2026 18:45:21] "POST /api/auth/login/ HTTP/1.1" 200 -
127.0.0.1 - - [16/Apr/2026 18:45:21] "OPTIONS /api/auth/me/ HTTP/1.1" 200 -
127.0.0.1 - - [16/Apr/2026 18:45:22] "GET /api/auth/me/ HTTP/1.1" 200 -
127.0.0.1 - - [16/Apr/2026 18:45:22] "OPTIONS /api/letters/ HTTP/1.1" 200 -
127.0.0.1 - - [16/Apr/2026 18:45:22] "OPTIONS /api/letters/ HTTP/1.1" 200 -
127.0.0.1 - - [16/Apr/2026 18:45:22] "GET /api/letters/ HTTP/1.1" 200 -
127.0.0.1 - - [16/Apr/2026 18:45:22] "GET /api/letters/ HTTP/1.1" 200 -
127.0.0.1 - - [16/Apr/2026 18:45:22] "OPTIONS /api/auth/login/ HTTP/1.1" 200 -
127.0.0.1 - - [16/Apr/2026 18:45:22] "OPTIONS /api/letters/77cbf618-f4e2-4d33-87c2-60e5c93c9711/ HTTP/1.1" 200 -
127.0.0.1 - - [16/Apr/2026 18:45:22] "PUT /api/letters/77cbf618-f4e2-4d33-87c2-60e5c93c9711/ HTTP/1.1" 201 -
127.0.0.1 - - [16/Apr/2026 18:45:22] "POST /api/auth/login/ HTTP/1.1" 200 -
127.0.0.1 - - [16/Apr/2026 18:45:22] "OPTIONS /api/auth/me/ HTTP/1.1" 200 -
127.0.0.1 - - [16/Apr/2026 18:45:22] "GET /api/auth/me/ HTTP/1.1" 200 -
127.0.0.1 - - [16/Apr/2026 18:45:22] "OPTIONS /api/letters/ HTTP/1.1" 200 -
127.0.0.1 - - [16/Apr/2026 18:45:22] "OPTIONS /api/letters/ HTTP/1.1" 200 -
127.0.0.1 - - [16/Apr/2026 18:45:22] "GET /api/letters/ HTTP/1.1" 200 -
127.0.0.1 - - [16/Apr/2026 18:45:22] "GET /api/letters/ HTTP/1.1" 200 -
127.0.0.1 - - [16/Apr/2026 18:45:23] "POST /api/auth/login/ HTTP/1.1" 200 -
127.0.0.1 - - [16/Apr/2026 18:45:23] "GET /api/letters/ HTTP/1.1" 200 -
127.0.0.1 - - [16/Apr/2026 18:45:23] "OPTIONS /api/letters/e8f47036-6e57-41f5-b057-8ea712589a73/ HTTP/1.1" 200 -
127.0.0.1 - - [16/Apr/2026 18:45:23] "OPTIONS /api/auth/me/ HTTP/1.1" 200 -
127.0.0.1 - - [16/Apr/2026 18:45:23] "GET /api/letters/ HTTP/1.1" 200 -
127.0.0.1 - - [16/Apr/2026 18:45:23] "GET /api/auth/me/ HTTP/1.1" 200 -
127.0.0.1 - - [16/Apr/2026 18:45:23] "PUT /api/letters/e8f47036-6e57-41f5-b057-8ea712589a73/ HTTP/1.1" 201 -
127.0.0.1 - - [16/Apr/2026 18:45:23] "OPTIONS /api/letters/ HTTP/1.1" 200 -
127.0.0.1 - - [16/Apr/2026 18:45:23] "OPTIONS /api/letters/ HTTP/1.1" 200 -
127.0.0.1 - - [16/Apr/2026 18:45:23] "GET /api/letters/ HTTP/1.1" 200 -
127.0.0.1 - - [16/Apr/2026 18:45:23] "GET /api/letters/ HTTP/1.1" 200 -
127.0.0.1 - - [16/Apr/2026 18:45:24] "GET /api/letters/77cbf618-f4e2-4d33-87c2-60e5c93c9711/ HTTP/1.1" 200 -
127.0.0.1 - - [16/Apr/2026 18:45:24] "GET /api/letters/77cbf618-f4e2-4d33-87c2-60e5c93c9711/ HTTP/1.1" 200 -
127.0.0.1 - - [16/Apr/2026 18:45:24] "OPTIONS /api/letters/4e2af91a-1651-4bcd-85b5-c469ee4a73e3/ HTTP/1.1" 200 -
127.0.0.1 - - [16/Apr/2026 18:45:24] "PUT /api/letters/4e2af91a-1651-4bcd-85b5-c469ee4a73e3/ HTTP/1.1" 201 -
127.0.0.1 - - [16/Apr/2026 18:45:26] "POST /api/auth/refresh/ HTTP/1.1" 200 -
127.0.0.1 - - [16/Apr/2026 18:45:26] "GET /api/auth/me/ HTTP/1.1" 200 -
127.0.0.1 - - [16/Apr/2026 18:45:26] "GET /api/letters/77cbf618-f4e2-4d33-87c2-60e5c93c9711/ HTTP/1.1" 200 -
127.0.0.1 - - [16/Apr/2026 18:45:26] "POST /api/auth/refresh/ HTTP/1.1" 200 -
127.0.0.1 - - [16/Apr/2026 18:45:26] "GET /api/letters/77cbf618-f4e2-4d33-87c2-60e5c93c9711/ HTTP/1.1" 200 -
127.0.0.1 - - [16/Apr/2026 18:45:26] "GET /api/auth/me/ HTTP/1.1" 200 -
127.0.0.1 - - [16/Apr/2026 18:45:26] "GET /api/letters/77cbf618-f4e2-4d33-87c2-60e5c93c9711/ HTTP/1.1" 200 -
127.0.0.1 - - [16/Apr/2026 18:45:26] "GET /api/letters/4e2af91a-1651-4bcd-85b5-c469ee4a73e3/ HTTP/1.1" 200 -
127.0.0.1 - - [16/Apr/2026 18:45:26] "GET /api/letters/77cbf618-f4e2-4d33-87c2-60e5c93c9711/ HTTP/1.1" 200 -
127.0.0.1 - - [16/Apr/2026 18:45:26] "GET /api/letters/4e2af91a-1651-4bcd-85b5-c469ee4a73e3/ HTTP/1.1" 200 -
Unauthorized: /api/auth/refresh/
127.0.0.1 - - [16/Apr/2026 18:45:30] "POST /api/auth/refresh/ HTTP/1.1" 401 -
Unauthorized: /api/auth/refresh/
127.0.0.1 - - [16/Apr/2026 18:45:30] "POST /api/auth/refresh/ HTTP/1.1" 401 -
127.0.0.1 - - [16/Apr/2026 18:45:31] "OPTIONS /api/auth/register/ HTTP/1.1" 200 -
127.0.0.1 - - [16/Apr/2026 18:45:32] "OPTIONS /api/auth/register/ HTTP/1.1" 200 -
127.0.0.1 - - [16/Apr/2026 18:45:32] "POST /api/auth/register/ HTTP/1.1" 201 -
127.0.0.1 - - [16/Apr/2026 18:45:32] "POST /api/auth/register/ HTTP/1.1" 201 -
Unauthorized: /api/auth/refresh/
127.0.0.1 - - [16/Apr/2026 18:45:34] "POST /api/auth/refresh/ HTTP/1.1" 401 -
127.0.0.1 - - [16/Apr/2026 18:45:35] "OPTIONS /api/auth/register/ HTTP/1.1" 200 -
Unauthorized: /api/auth/refresh/
127.0.0.1 - - [16/Apr/2026 18:45:35] "POST /api/auth/refresh/ HTTP/1.1" 401 -
127.0.0.1 - - [16/Apr/2026 18:45:36] "POST /api/auth/register/ HTTP/1.1" 201 -
127.0.0.1 - - [16/Apr/2026 18:45:36] "OPTIONS /api/auth/register/ HTTP/1.1" 200 -
Unauthorized: /api/auth/refresh/
127.0.0.1 - - [16/Apr/2026 18:45:36] "POST /api/auth/refresh/ HTTP/1.1" 401 -
127.0.0.1 - - [16/Apr/2026 18:45:36] "GET /api/auth/activate/M2M0YzFiNTItMjE5Mi00Y2VmLWIwZmItMDlkNDg5NWE4NWU0/d755fw-9c2c43d45732c1cd5ccbef20d3dc4181/ HTTP/1.1" 200 -
127.0.0.1 - - [16/Apr/2026 18:45:37] "OPTIONS /api/auth/login/ HTTP/1.1" 200 -
127.0.0.1 - - [16/Apr/2026 18:45:37] "POST /api/auth/register/ HTTP/1.1" 201 -
Unauthorized: /api/auth/refresh/
127.0.0.1 - - [16/Apr/2026 18:45:37] "POST /api/auth/refresh/ HTTP/1.1" 401 -
127.0.0.1 - - [16/Apr/2026 18:45:37] "GET /api/auth/activate/MjdiM2E1M2ItOTZlYS00Y2Y1LWFhMmQtZThjY2ZkNGQ5ZjQ1/d755fw-e60e3d0b66162c75962beea97a40b4c7/ HTTP/1.1" 200 -
127.0.0.1 - - [16/Apr/2026 18:45:38] "POST /api/auth/login/ HTTP/1.1" 200 -
127.0.0.1 - - [16/Apr/2026 18:45:38] "OPTIONS /api/auth/me/ HTTP/1.1" 200 -
127.0.0.1 - - [16/Apr/2026 18:45:38] "GET /api/auth/me/ HTTP/1.1" 200 -
127.0.0.1 - - [16/Apr/2026 18:45:38] "OPTIONS /api/letters/ HTTP/1.1" 200 -
127.0.0.1 - - [16/Apr/2026 18:45:38] "OPTIONS /api/letters/ HTTP/1.1" 200 -
127.0.0.1 - - [16/Apr/2026 18:45:38] "GET /api/letters/ HTTP/1.1" 200 -
127.0.0.1 - - [16/Apr/2026 18:45:38] "GET /api/letters/ HTTP/1.1" 200 -
127.0.0.1 - - [16/Apr/2026 18:45:38] "OPTIONS /api/auth/login/ HTTP/1.1" 200 -
127.0.0.1 - - [16/Apr/2026 18:45:39] "POST /api/auth/login/ HTTP/1.1" 200 -
127.0.0.1 - - [16/Apr/2026 18:45:39] "OPTIONS /api/auth/me/ HTTP/1.1" 200 -
127.0.0.1 - - [16/Apr/2026 18:45:39] "GET /api/auth/me/ HTTP/1.1" 200 -
127.0.0.1 - - [16/Apr/2026 18:45:39] "OPTIONS /api/letters/ HTTP/1.1" 200 -
127.0.0.1 - - [16/Apr/2026 18:45:39] "OPTIONS /api/letters/ HTTP/1.1" 200 -
127.0.0.1 - - [16/Apr/2026 18:45:39] "GET /api/letters/ HTTP/1.1" 200 -
127.0.0.1 - - [16/Apr/2026 18:45:39] "GET /api/letters/ HTTP/1.1" 200 -
127.0.0.1 - - [16/Apr/2026 18:45:40] "OPTIONS /api/letters/351f0e2c-e10f-4851-9836-bc387758dbad/ HTTP/1.1" 200 -
127.0.0.1 - - [16/Apr/2026 18:45:40] "PUT /api/letters/351f0e2c-e10f-4851-9836-bc387758dbad/ HTTP/1.1" 201 -
Unauthorized: /api/auth/refresh/
127.0.0.1 - - [16/Apr/2026 18:45:40] "POST /api/auth/refresh/ HTTP/1.1" 401 -
127.0.0.1 - - [16/Apr/2026 18:45:40] "GET /api/auth/activate/MDkwNTg5MDctNjAzOS00NDgwLTlkYTktNmUxOWE5ZTBjODJh/d755g0-546e5f4ee3ed111de64f011ac8d02836/ HTTP/1.1" 200 -
127.0.0.1 - - [16/Apr/2026 18:45:41] "OPTIONS /api/auth/login/ HTTP/1.1" 200 -
Unauthorized: /api/auth/refresh/
127.0.0.1 - - [16/Apr/2026 18:45:41] "POST /api/auth/refresh/ HTTP/1.1" 401 -
127.0.0.1 - - [16/Apr/2026 18:45:41] "GET /api/auth/activate/MzAxODRjMWEtMTYxZC00ZTczLThlMWMtM2FmY2RmZDg2NThk/d755g1-2ae1dec8cbac57d223c0cd206a8741a8/ HTTP/1.1" 200 -
127.0.0.1 - - [16/Apr/2026 18:45:42] "POST /api/auth/login/ HTTP/1.1" 200 -
127.0.0.1 - - [16/Apr/2026 18:45:42] "OPTIONS /api/auth/me/ HTTP/1.1" 200 -
127.0.0.1 - - [16/Apr/2026 18:45:42] "GET /api/auth/me/ HTTP/1.1" 200 -
127.0.0.1 - - [16/Apr/2026 18:45:42] "OPTIONS /api/letters/ HTTP/1.1" 200 -
127.0.0.1 - - [16/Apr/2026 18:45:42] "GET /api/letters/ HTTP/1.1" 200 -
127.0.0.1 - - [16/Apr/2026 18:45:42] "GET /api/letters/ HTTP/1.1" 200 -
127.0.0.1 - - [16/Apr/2026 18:45:42] "OPTIONS /api/auth/login/ HTTP/1.1" 200 -
127.0.0.1 - - [16/Apr/2026 18:45:42] "OPTIONS /api/letters/5dec1a15-1d19-47de-b5a5-21bc1845f90a/ HTTP/1.1" 200 -
127.0.0.1 - - [16/Apr/2026 18:45:42] "PUT /api/letters/5dec1a15-1d19-47de-b5a5-21bc1845f90a/ HTTP/1.1" 201 -
127.0.0.1 - - [16/Apr/2026 18:45:43] "POST /api/auth/login/ HTTP/1.1" 200 -
127.0.0.1 - - [16/Apr/2026 18:45:43] "OPTIONS /api/auth/me/ HTTP/1.1" 200 -
127.0.0.1 - - [16/Apr/2026 18:45:43] "GET /api/auth/me/ HTTP/1.1" 200 -
127.0.0.1 - - [16/Apr/2026 18:45:43] "OPTIONS /api/letters/ HTTP/1.1" 200 -
127.0.0.1 - - [16/Apr/2026 18:45:43] "OPTIONS /api/letters/ HTTP/1.1" 200 -
127.0.0.1 - - [16/Apr/2026 18:45:43] "GET /api/letters/ HTTP/1.1" 200 -
127.0.0.1 - - [16/Apr/2026 18:45:43] "GET /api/letters/ HTTP/1.1" 200 -
127.0.0.1 - - [16/Apr/2026 18:45:43] "POST /api/auth/refresh/ HTTP/1.1" 200 -
127.0.0.1 - - [16/Apr/2026 18:45:43] "GET /api/auth/me/ HTTP/1.1" 200 -
127.0.0.1 - - [16/Apr/2026 18:45:43] "GET /api/letters/351f0e2c-e10f-4851-9836-bc387758dbad/ HTTP/1.1" 200 -
127.0.0.1 - - [16/Apr/2026 18:45:43] "GET /api/letters/351f0e2c-e10f-4851-9836-bc387758dbad/ HTTP/1.1" 200 -
127.0.0.1 - - [16/Apr/2026 18:45:46] "POST /api/auth/refresh/ HTTP/1.1" 200 -
127.0.0.1 - - [16/Apr/2026 18:45:46] "POST /api/auth/refresh/ HTTP/1.1" 200 -
127.0.0.1 - - [16/Apr/2026 18:45:46] "GET /api/auth/me/ HTTP/1.1" 200 -
127.0.0.1 - - [16/Apr/2026 18:45:46] "GET /api/auth/me/ HTTP/1.1" 200 -
127.0.0.1 - - [16/Apr/2026 18:45:46] "GET /api/letters/ HTTP/1.1" 200 -
127.0.0.1 - - [16/Apr/2026 18:45:46] "GET /api/letters/ HTTP/1.1" 200 -
127.0.0.1 - - [16/Apr/2026 18:45:46] "GET /api/letters/351f0e2c-e10f-4851-9836-bc387758dbad/ HTTP/1.1" 200 -
127.0.0.1 - - [16/Apr/2026 18:45:46] "GET /api/letters/351f0e2c-e10f-4851-9836-bc387758dbad/ HTTP/1.1" 200 -
127.0.0.1 - - [16/Apr/2026 18:45:47] "OPTIONS /api/letters/e95e2a4c-811a-45b1-9bbd-dafdb180a88f/ HTTP/1.1" 200 -
127.0.0.1 - - [16/Apr/2026 18:45:47] "PUT /api/letters/e95e2a4c-811a-45b1-9bbd-dafdb180a88f/ HTTP/1.1" 201 -
127.0.0.1 - - [16/Apr/2026 18:45:47] "GET /api/letters/ HTTP/1.1" 200 -
127.0.0.1 - - [16/Apr/2026 18:45:47] "GET /api/letters/ HTTP/1.1" 200 -
127.0.0.1 - - [16/Apr/2026 18:45:49] "GET /api/letters/e95e2a4c-811a-45b1-9bbd-dafdb180a88f/ HTTP/1.1" 200 -
127.0.0.1 - - [16/Apr/2026 18:45:49] "GET /api/letters/e95e2a4c-811a-45b1-9bbd-dafdb180a88f/ HTTP/1.1" 200 -
127.0.0.1 - - [16/Apr/2026 18:45:51] "POST /api/auth/refresh/ HTTP/1.1" 200 -
127.0.0.1 - - [16/Apr/2026 18:45:51] "GET /api/auth/me/ HTTP/1.1" 200 -
127.0.0.1 - - [16/Apr/2026 18:45:51] "GET /api/letters/e95e2a4c-811a-45b1-9bbd-dafdb180a88f/ HTTP/1.1" 200 -
127.0.0.1 - - [16/Apr/2026 18:45:51] "GET /api/letters/e95e2a4c-811a-45b1-9bbd-dafdb180a88f/ HTTP/1.1" 200 -
127.0.0.1 - - [16/Apr/2026 18:45:51] "GET /api/letters/e95e2a4c-811a-45b1-9bbd-dafdb180a88f/ HTTP/1.1" 200 -
127.0.0.1 - - [16/Apr/2026 18:45:51] "GET /api/letters/e95e2a4c-811a-45b1-9bbd-dafdb180a88f/ HTTP/1.1" 200 -
Starting with SSL on 127.0.0.1:8001...
/usr/lib64/python3.14/multiprocessing/resource_tracker.py:396: UserWarning: resource_tracker: There appear to be 1 leaked semaphore objects to clean up at shutdown: {'/mp-uf_ylwyc'}
warnings.warn(
+14 -40
View File
@@ -1,19 +1,15 @@
#!/bin/bash #!/bin/bash
set -e set -e
# Usage: ./run-e2e.sh [--docker] [--ui]
NODE_BIN=$(command -v bun || command -v npm || true)
# Use podman if available. Not everyone has it # Use podman if available. Not everyone has it
CONTAINER_BIN=$(command -v podman || command -v docker || true) CONTAINER_BIN=$(command -v podman || command -v docker)
COMPOSE_BIN=$(command -v docker-compose || true)
if [ -z "$CONTAINER_BIN" ]; then if [ -z "$CONTAINER_BIN" ]; then
echo "Sorry, you need either podman or docker installed to run this script." echo "Sorry, you need either podman or docker installed to run this script."
exit 1 exit 1
fi fi
if [ "$CI" = "true" ]; then if [ "$CI" = "true" ]; then
CONTAINER_BIN=$(command -v docker || true) CONTAINER_BIN=$(command -v docker)
fi fi
echo "Using $CONTAINER_BIN for container operations..." echo "Using $CONTAINER_BIN for container operations..."
@@ -30,22 +26,23 @@ else
exit 1 exit 1
fi fi
# This cleans up django backend process and containers. # This cleans up containers. Very useful for local e2e to free system resources immediately.
cleanup() { cleanup() {
echo "Cleaning up..." echo "Cleaning up..."
$CONTAINER_BIN compose -p "piku_e2e" -f "./docker-compose.e2e.yml" down --remove-orphans -v $CONTAINER_BIN rm -f "$DB_NAME" 2>/dev/null || true
[ -n "$BACKEND_PID" ] && kill "$BACKEND_PID" 2>/dev/null || true [ -n "$BACKEND_PID" ] && kill "$BACKEND_PID" 2>/dev/null || true
} }
trap cleanup EXIT trap cleanup EXIT
echo "Starting Database and Mail server..." echo "Starting Database and Mail server..."
COMPOSE_BIN="$(command -v docker-compose || true)"
if echo "$CONTAINER_BIN" | grep -q "podman"; then if echo "$CONTAINER_BIN" | grep -q "podman"; then
podman compose -p "piku_e2e" -f "./docker-compose.e2e.yml" up -d podman compose -f "./docker-compose.e2e.yml" up -d
elif [ -n "$COMPOSE_BIN" ]; then elif [ -n "$COMPOSE_BIN" ]; then
$COMPOSE_BIN -p "piku_e2e" -f "./docker-compose.e2e.yml" up -d "$COMPOSE_BIN" -f "./docker-compose.e2e.yml" up -d
else else
docker compose -p "piku_e2e" -f "./docker-compose.e2e.yml" up -d docker compose -f "./docker-compose.e2e.yml" up -d
fi fi
# postgress will take some time, so we wait, and no race condition. Also, no point in logging this output # postgress will take some time, so we wait, and no race condition. Also, no point in logging this output
@@ -54,38 +51,15 @@ until $CONTAINER_BIN exec "$DB_NAME" pg_isready -U "${DB_USER:-test}" >/dev/null
sleep 2 sleep 2
done done
export PIKU_ENV_FILE="$ENV_FILE"
echo "Starting Backend..." echo "Starting Backend..."
mkdir -p ./tmp/logs mkdir -p ./tmp/logs
( (cd backend && uv run manage.py migrate)
cd backend (cd backend && uv run manage.py serve) > ./tmp/logs/backend.log 2>&1 &
uv run manage.py migrate
)
(
cd backend
exec uv run manage.py serve
) > ./tmp/logs/backend.log 2>&1 &
BACKEND_PID=$! BACKEND_PID=$!
TEST_COMMAND="test:e2e" if [ "$CI" = "true" ]; then
MODE="local" cd frontend && bun run test:e2e "$@"
for arg in "$@"; do
echo "$arg"
if [ "$arg" = "--ui" ]; then
TEST_COMMAND="test:e2e:ui"
fi
if [ "$arg" = "--docker" ]; then
MODE="docker"
fi
done
# optionally using docker to run playwright since someone at microsoft thought it'd be nice to not support fedora :)
if [ $MODE = "docker" ]; then
$CONTAINER_BIN run --rm -it --network host -v $(pwd):/e2e:Z -w /e2e/frontend -p 43008:43008 mcr.microsoft.com/playwright:v1.59.1-noble npm run $TEST_COMMAND
else else
( # Because playwright decided not to support Fedora :)
cd frontend cd frontend && distrobox-enter --name ubuntu-24.04 -- bun run test:e2e "$@"
$NODE_BIN run $TEST_COMMAND
)
fi fi
-77
View File
@@ -1,77 +0,0 @@
#!/bin/bash
set -e
NODE_BIN=$(command -v bun || command -v npm || true)
PY_BIN=$(command -v uv || command -v pip || true)
DISTRO_BIN=$(command -v apt || command -v yum || command -v pacman || command -v zypper || true)
echo "[Backend] Installing Backend Packages..."
if [ $(basename "$PY_BIN") = "pip" ]; then
(
cd backend
python -m venv .venv
. .venv/bin/activate
pip install -r requirements.txt
)
else
(
cd backend
uv sync
)
fi
echo "[Frontend] Installing Frontend Packages..."
if [ $(basename "$NODE_BIN") = "npm" ]; then
(
cd frontend
npm install
)
else
(
cd frontend
bun install --frozen-lockfile
)
fi
# Simplify ssl generation for local - source & credits:- https://github.com/FiloSottile/mkcert
echo "[Cert] Setting up SSL..."
# pre-requisites (might be available already, just in case)
if [ $(basename "$DISTRO_BIN") = "apt" ]; then
sudo apt install -y libnss3-tools
elif [ $(basename "$DISTRO_BIN") = "yum" ]; then
sudo yum install -y nss-tools
elif [ $(basename "$DISTRO_BIN") = "pacman" ]; then
sudo pacman -S --noconfirm nss
elif [ $(basename "$DISTRO_BIN") = "zypper" ]; then
sudo zypper install -y mozilla-nss-tools
fi
# Detect os and arch to get the appropriate bin. Windows: ...NO SOUP FOR YOU!
OS=$(uname -s)
ARCH=$(uname -m)
case $OS in
Darwin)
MKCERT_OS="darwin"
;;
*)
MKCERT_OS="linux"
;;
esac
case $ARCH in
arm64|aarch64)
MKCERT_ARCH="arm64"
;;
*)
MKCERT_ARCH="amd64"
;;
esac
echo "[Cert] Downloading mkcert for $MKCERT_OS $MKCERT_ARCH..."
MKCERT_URL="https://dl.filippo.io/mkcert/latest?for=${MKCERT_OS}/${MKCERT_ARCH}"
curl -L -o /tmp/mkcert $MKCERT_URL
chmod +x /tmp/mkcert
echo "[Cert] Creating certs for localhost..."
mkdir -p certs
/tmp/mkcert -install
/tmp/mkcert -cert-file certs/localhost.pem -key-file certs/localhost-key.pem localhost 127.0.0.1
+3 -44
View File
@@ -1,45 +1,4 @@
#!/bin/bash #!/bin/bash
(podman compose up -d) &
# Change this if you're using docker or docker-compose (cd backend && uv run manage.py serve) &
CONTAINER_BIN="podman" (cd frontend && bun run dev)
cleanup() {
echo 'Stopping dev containers and processes...'
$CONTAINER_BIN compose -p pi_ku down --remove-orphans
[ -n "${BACKEND_PID:-}" ] && kill "$BACKEND_PID" 2>/dev/null
[ -n "${FRONTEND_PID:-}" ] && kill "$FRONTEND_PID" 2>/dev/null
}
# source .env
set -a
source .env
set +a
trap cleanup EXIT
trap 'exit 130' INT
trap 'exit 143' TERM
echo "$PWD"
$CONTAINER_BIN compose -p pi_ku up -d
# wait for db to be ready
DB_CONTAINER=$($CONTAINER_BIN ps -q --filter label=com.docker.compose.service=db)
until $CONTAINER_BIN exec "$DB_CONTAINER" pg_isready -U $DB_USER; do
echo "Waiting for DB $DB_CONTAINER to be ready... $DB_USER"
sleep 1
done
(
cd backend || exit 1
uv run manage.py migrate
uv run manage.py serve
) &
BACKEND_PID=$!
(
cd frontend || exit 1
bun run dev
) &
FRONTEND_PID=$!
wait