mirror of
https://github.com/ramvignesh-b/pi-ku.git
synced 2026-05-04 08:56:52 +00:00
Compare commits
83 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 89ab2cad53 | |||
| b1a32512ab | |||
| 357df454d0 | |||
| 43e5e5ed5b | |||
| 1f47b6f4dd | |||
| f242977be3 | |||
| b1d466fb11 | |||
| 25d5bf142a | |||
| fbd4bd4aec | |||
| ce370a9fc6 | |||
| 2896c60c5f | |||
| 97e4d0be98 | |||
| 3e4a4512a3 | |||
| b83a8d12f2 | |||
| 01bac9840f | |||
| 73e1e64a33 | |||
| fe25231da7 | |||
| 94e024bd5f | |||
| dadb688c50 | |||
| f352c298e7 | |||
| fca23c4fc8 | |||
| 7279798bd4 | |||
| fa1b3f1bcf | |||
| 18e9af651d | |||
| 42493a950c | |||
| db31be4ec8 | |||
| c562c99d3a | |||
| 2f3d5161ed | |||
| ae52a79bd0 | |||
| 00c16627cc | |||
| a84d837942 | |||
| 33995ffee1 | |||
| 7eed38f27e | |||
| b49dc69a25 | |||
| 3111e14732 | |||
| d5444bd47f | |||
| 17564282e8 | |||
| a2aadb5d2b | |||
| 6e0f300518 | |||
| 218ed42f00 | |||
| 7f61ce169e | |||
| d92590f764 | |||
| 7dece74698 | |||
| 6872853125 | |||
| 6552783d64 | |||
| e2ad5cef75 | |||
| f509d74f62 | |||
| 7ff4c1de29 | |||
| a986878a3b | |||
| 59ea95a912 | |||
| 5355194026 | |||
| c3a1e1e252 | |||
| 3cd516039a | |||
| 6aff578ca5 | |||
| cb9d5e35fd | |||
| 625475b740 | |||
| 516991c33a | |||
| 4a971a93b0 | |||
| 27b725e8ec | |||
| 694715a90c | |||
| 11b9e8b04c | |||
| b9d1880951 | |||
| 50bae8d2ce | |||
| 4c204b0a80 | |||
| b5d55bd258 | |||
| bd0f2e0171 | |||
| 7bab2937bc | |||
| ec769818f5 | |||
| 1c1b5ea14e | |||
| e68dcb068b | |||
| ad5bc57eee | |||
| d17d5c01e8 | |||
| 2db7e1f9f5 | |||
| 428db97ba2 | |||
| 86f41a9d90 | |||
| de46b2c631 | |||
| 78e2625883 | |||
| 11780cdbd4 | |||
| 1e7a1c15c9 | |||
| 3d764703dd | |||
| f124efd8c1 | |||
| c9bb4799ce | |||
| 3c9c72d25f |
+4
-3
@@ -2,11 +2,11 @@
|
||||
DB_NAME=piku_test_db
|
||||
DB_USER=test
|
||||
DB_PASSWORD=password123
|
||||
DB_HOST=localhost
|
||||
DB_HOST=127.0.0.1
|
||||
DB_PORT=5433
|
||||
|
||||
# SSL
|
||||
SSL_ENABLED=false
|
||||
SSL_ENABLED=true
|
||||
|
||||
# DJANGO
|
||||
DEBUG=True
|
||||
@@ -17,11 +17,12 @@ BACKEND_PORT=8001
|
||||
# EMAIL
|
||||
EMAIL_HOST=127.0.0.1
|
||||
EMAIL_PORT=1026
|
||||
FROM_EMAIL="Test <test@pi-ku.app>"
|
||||
EMAIL_HOST_USER=
|
||||
EMAIL_HOST_PASSWORD=
|
||||
FROM_EMAIL="Test <test@pi-ku.app>"
|
||||
EMAIL_API_PORT=8026
|
||||
|
||||
# FRONTEND
|
||||
FRONTEND_PORT=5199
|
||||
FRONTEND_DOMAIN=127.0.0.1
|
||||
VITE_API_URL=https://127.0.0.1:8001
|
||||
|
||||
+12
-2
@@ -2,23 +2,33 @@
|
||||
DB_NAME=piku
|
||||
DB_USER=user
|
||||
DB_PASSWORD=password123
|
||||
DB_HOST=localhost
|
||||
DB_HOST=127.0.0.1
|
||||
DB_PORT=5432
|
||||
|
||||
# SSL
|
||||
SSL_ENABLED=true
|
||||
S3_ENABLED=false
|
||||
|
||||
# DJANGO
|
||||
DEBUG=True
|
||||
SECRET_KEY=django-secret-key
|
||||
BACKEND_DOMAIN=127.0.0.1
|
||||
BACKEND_PORT=8000
|
||||
# S3
|
||||
R2_ACCESS_KEY_ID=
|
||||
R2_SECRET_ACCESS_KEY=
|
||||
R2_REGION_NAME=
|
||||
R2_ENDPOINT_URL=
|
||||
R2_PUBLIC_URL=
|
||||
|
||||
# EMAIL
|
||||
EMAIL_HOST=127.0.0.1
|
||||
EMAIL_PORT=1025
|
||||
FROM_EMAIL=Pi Ku <no-reply@test.com>
|
||||
EMAIL_HOST_USER=
|
||||
EMAIL_HOST_PASSWORD=
|
||||
FROM_EMAIL="Pi Ku <no-reply@test.com>"
|
||||
|
||||
# FRONTEND
|
||||
FRONTEND_PORT=5173
|
||||
FRONTEND_DOMAIN=127.0.0.1
|
||||
VITE_API_URL=https://127.0.0.1:8000
|
||||
|
||||
@@ -145,3 +145,7 @@ jobs:
|
||||
name: playwright-report
|
||||
path: frontend/playwright-report/
|
||||
retention-days: 10
|
||||
|
||||
- name: Print Backend Logs on Failure
|
||||
if: failure()
|
||||
run: cat tmp/logs/backend.log || true
|
||||
|
||||
+14
@@ -13,3 +13,17 @@ dist/
|
||||
# Certificates
|
||||
certs/*.pem
|
||||
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
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
.venv
|
||||
@@ -0,0 +1,18 @@
|
||||
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"]
|
||||
@@ -0,0 +1,89 @@
|
||||
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",
|
||||
},
|
||||
},
|
||||
}
|
||||
+58
-17
@@ -21,15 +21,26 @@ BASE_DIR = Path(__file__).resolve().parent.parent
|
||||
|
||||
# Load dotenv files
|
||||
env = environ.Env()
|
||||
env_file = os.path.join(BASE_DIR.parent, ".env")
|
||||
env_file = os.environ.get("PIKU_ENV_FILE", os.path.join(BASE_DIR.parent, ".env"))
|
||||
if os.path.exists(env_file):
|
||||
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"))
|
||||
|
||||
SSL_ENABLED = env("SSL_ENABLED") == "true"
|
||||
FRONTEND_URL = f"https://{env('FRONTEND_DOMAIN')}" if SSL_ENABLED else f"http://{env('FRONTEND_DOMAIN')}"
|
||||
if env("FRONTEND_PORT"):
|
||||
FRONTEND_URL += f":{env('FRONTEND_PORT')}"
|
||||
CSRF_TRUSTED_ORIGINS = env.list("CSRF_TRUSTED_ORIGINS", default=[])
|
||||
|
||||
SSL_ENABLED = env.bool("SSL_ENABLED", default=False)
|
||||
URI_SCHEME = "https://" if SSL_ENABLED else "http://"
|
||||
|
||||
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
|
||||
# See https://docs.djangoproject.com/en/6.0/howto/deployment/checklist/
|
||||
@@ -38,19 +49,19 @@ if env("FRONTEND_PORT"):
|
||||
SECRET_KEY = env("SECRET_KEY")
|
||||
|
||||
# SECURITY WARNING: don't run with debug turned on in production!
|
||||
DEBUG = env("DEBUG")
|
||||
|
||||
ALLOWED_HOSTS = [env("FRONTEND_DOMAIN")]
|
||||
DEBUG = env.bool("DEBUG", default=False)
|
||||
|
||||
|
||||
# Application definition
|
||||
|
||||
INSTALLED_APPS = [
|
||||
"django_apscheduler",
|
||||
"django.contrib.auth",
|
||||
"django.contrib.contenttypes",
|
||||
"django.contrib.sessions",
|
||||
"django.contrib.staticfiles",
|
||||
"django_extensions",
|
||||
"django_structlog",
|
||||
"rest_framework",
|
||||
"corsheaders",
|
||||
"users",
|
||||
@@ -67,13 +78,14 @@ MIDDLEWARE = [
|
||||
"django.contrib.auth.middleware.AuthenticationMiddleware",
|
||||
"django.contrib.messages.middleware.MessageMiddleware",
|
||||
"django.middleware.clickjacking.XFrameOptionsMiddleware",
|
||||
"django_structlog.middlewares.RequestMiddleware",
|
||||
]
|
||||
|
||||
|
||||
ROOT_URLCONF = "config.urls"
|
||||
|
||||
WSGI_APPLICATION = "config.wsgi.application"
|
||||
|
||||
|
||||
# Database
|
||||
# https://docs.djangoproject.com/en/6.0/ref/settings/#databases
|
||||
|
||||
@@ -88,7 +100,8 @@ DATABASES = {
|
||||
}
|
||||
}
|
||||
|
||||
CORS_ALLOWED_ORIGINS = [FRONTEND_URL]
|
||||
CORS_ALLOWED_ORIGINS = FRONTEND_URLS
|
||||
CSRF_TRUSTED_ORIGINS += FRONTEND_URLS
|
||||
CORS_ALLOW_CREDENTIALS = True
|
||||
|
||||
AUTH_USER_MODEL = "users.User"
|
||||
@@ -96,6 +109,7 @@ AUTH_USER_MODEL = "users.User"
|
||||
REST_FRAMEWORK = {
|
||||
"DEFAULT_AUTHENTICATION_CLASSES": ("rest_framework_simplejwt.authentication.JWTAuthentication",),
|
||||
"DEFAULT_PERMISSION_CLASSES": ("rest_framework.permissions.IsAuthenticated",),
|
||||
"DEFAULT_RENDERER_CLASSES": ("rest_framework.renderers.JSONRenderer",),
|
||||
}
|
||||
|
||||
SIMPLE_JWT = {
|
||||
@@ -112,8 +126,8 @@ NOTE: COOKIE_SAMESITE: Lax is used to allow cross-site redirection, like links
|
||||
"""
|
||||
AUTH_COOKIE = {
|
||||
"NAME": "refresh_token",
|
||||
"DOMAIN": None,
|
||||
"SECURE": SSL_ENABLED,
|
||||
"DOMAIN": None if DEBUG else env("FRONTEND_DOMAIN"),
|
||||
"SECURE": SSL_ENABLED if DEBUG else True,
|
||||
"HTTPONLY": True,
|
||||
"SAMESITE": "Lax",
|
||||
}
|
||||
@@ -121,11 +135,13 @@ AUTH_COOKIE = {
|
||||
# Email config
|
||||
EMAIL_BACKEND = "django.core.mail.backends.smtp.EmailBackend"
|
||||
EMAIL_HOST = env("EMAIL_HOST")
|
||||
EMAIL_PORT = env("EMAIL_PORT")
|
||||
EMAIL_USE_TLS = not DEBUG
|
||||
EMAIL_PORT = env.int("EMAIL_PORT")
|
||||
EMAIL_HOST_USER = env("EMAIL_HOST_USER")
|
||||
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")
|
||||
|
||||
|
||||
# Password validation
|
||||
# https://docs.djangoproject.com/en/6.0/ref/settings/#auth-password-validators
|
||||
|
||||
@@ -144,7 +160,6 @@ AUTH_PASSWORD_VALIDATORS = [
|
||||
},
|
||||
]
|
||||
|
||||
|
||||
# Internationalization
|
||||
# https://docs.djangoproject.com/en/6.0/topics/i18n/
|
||||
|
||||
@@ -156,10 +171,36 @@ USE_I18N = True
|
||||
|
||||
USE_TZ = True
|
||||
|
||||
|
||||
# Static files (CSS, JavaScript, Images)
|
||||
# https://docs.djangoproject.com/en/6.0/howto/static-files/
|
||||
|
||||
STATIC_URL = "static/"
|
||||
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"
|
||||
|
||||
@@ -1,5 +1,19 @@
|
||||
import os
|
||||
|
||||
from django.apps import AppConfig
|
||||
|
||||
|
||||
class LettersConfig(AppConfig):
|
||||
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()
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
# 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),
|
||||
),
|
||||
]
|
||||
@@ -0,0 +1,22 @@
|
||||
# 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,4 +1,5 @@
|
||||
import uuid
|
||||
from datetime import UTC, datetime
|
||||
|
||||
from django.conf import settings
|
||||
from django.core.exceptions import ValidationError
|
||||
@@ -24,10 +25,11 @@ class Letter(models.Model):
|
||||
updated_at = models.DateTimeField(auto_now=True)
|
||||
encrypted_content = models.TextField(null=True, blank=True)
|
||||
encrypted_metadata = models.TextField(null=True, blank=True)
|
||||
unlock_at = models.DateTimeField(null=True, blank=True)
|
||||
unlock_at = models.DateTimeField(null=True, blank=True, db_index=True)
|
||||
sealed_at = models.DateTimeField(null=True, blank=True)
|
||||
opened_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)
|
||||
|
||||
def clean(self):
|
||||
@@ -38,6 +40,16 @@ class Letter(models.Model):
|
||||
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.")
|
||||
|
||||
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):
|
||||
return f"{self.type} - {self.status}"
|
||||
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
from datetime import UTC, datetime, timedelta
|
||||
|
||||
from rest_framework import serializers
|
||||
|
||||
from letters.models import Letter, LetterImage
|
||||
@@ -34,6 +36,25 @@ class LetterSerializer(serializers.ModelSerializer):
|
||||
]
|
||||
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):
|
||||
"""
|
||||
Validates the requirmnt of DEK when encrypted content and metadata are stored.
|
||||
@@ -42,4 +63,6 @@ class LetterSerializer(serializers.ModelSerializer):
|
||||
raise serializers.ValidationError(
|
||||
"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
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
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()
|
||||
@@ -1,3 +1,7 @@
|
||||
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.core.files.base import ContentFile
|
||||
from django.test import TestCase
|
||||
@@ -208,6 +212,102 @@ class LetterAPITest(APITestCase):
|
||||
self.assertFalse(default_storage.exists("encrypted-images/old2.bin"))
|
||||
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):
|
||||
def setUp(self):
|
||||
@@ -239,3 +339,72 @@ class LetterImageModelTest(TestCase):
|
||||
self.assertEqual(LetterImage.objects.count(), 1)
|
||||
self.letter.delete()
|
||||
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)
|
||||
|
||||
@@ -62,3 +62,24 @@ class LetterDetailView(generics.RetrieveUpdateDestroyAPIView):
|
||||
|
||||
response_serializer = self.get_serializer(letter)
|
||||
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)
|
||||
|
||||
@@ -5,15 +5,25 @@ description = "Django Rest Framework for handling requests for Pi Ku app"
|
||||
readme = "README.md"
|
||||
requires-python = ">=3.14"
|
||||
dependencies = [
|
||||
"apscheduler>=3.11.2",
|
||||
"boto3>=1.42.96",
|
||||
"django>=6.0.4",
|
||||
"django-apscheduler>=0.7.0",
|
||||
"django-cors-headers>=4.9.0",
|
||||
"django-environ>=0.13.0",
|
||||
"django-extensions>=4.1",
|
||||
"django-storages>=1.14.6",
|
||||
"django-structlog>=10.0.0",
|
||||
"djangorestframework>=3.17.1",
|
||||
"djangorestframework-simplejwt>=5.5.1",
|
||||
"djangorestframework-stubs>=3.16.9",
|
||||
"freezegun>=1.5.5",
|
||||
"gunicorn>=25.3.0",
|
||||
"psycopg2-binary>=2.9.11",
|
||||
"pyopenssl>=26.0.0",
|
||||
"rich>=15.0.0",
|
||||
"ruff>=0.15.9",
|
||||
"structlog>=25.5.0",
|
||||
"werkzeug>=3.1.8",
|
||||
]
|
||||
|
||||
|
||||
@@ -0,0 +1,256 @@
|
||||
# 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
|
||||
@@ -12,7 +12,8 @@ class Command(BaseCommand):
|
||||
If SSL is enabled, use runserver_plus command.
|
||||
If SSL is not enabled, use runserver command.
|
||||
"""
|
||||
ssl_enabled = os.getenv("SSL_ENABLED", "false").lower() == "true"
|
||||
ssl_enabled = os.getenv("SSL_ENABLED", "false").lower().strip() == "true"
|
||||
|
||||
domain = os.getenv("BACKEND_DOMAIN", "127.0.0.1")
|
||||
port = os.getenv("BACKEND_PORT", "8000")
|
||||
addrport = f"{domain}:{port}"
|
||||
|
||||
@@ -8,7 +8,7 @@ from django.utils.http import urlsafe_base64_encode
|
||||
def send_activation_email(user):
|
||||
token = default_token_generator.make_token(user)
|
||||
uid = urlsafe_base64_encode(force_bytes(user.public_id))
|
||||
activation_url = f"{settings.FRONTEND_URL}/activate/{uid}/{token}"
|
||||
activation_url = f"{settings.FRONTEND_URLS[0]}/activate/{uid}/{token}"
|
||||
subject = "Activate Your Piku Account"
|
||||
message = f"""Hi {user.full_name},
|
||||
|
||||
|
||||
Generated
+329
@@ -2,6 +2,18 @@ version = 1
|
||||
revision = 3
|
||||
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]]
|
||||
name = "asgiref"
|
||||
version = "3.11.1"
|
||||
@@ -11,6 +23,34 @@ 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" },
|
||||
]
|
||||
|
||||
[[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]]
|
||||
name = "cffi"
|
||||
version = "2.0.0"
|
||||
@@ -111,6 +151,19 @@ 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" },
|
||||
]
|
||||
|
||||
[[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]]
|
||||
name = "django-cors-headers"
|
||||
version = "4.9.0"
|
||||
@@ -145,6 +198,73 @@ 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" },
|
||||
]
|
||||
|
||||
[[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]]
|
||||
name = "djangorestframework"
|
||||
version = "3.17.1"
|
||||
@@ -171,6 +291,65 @@ 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" },
|
||||
]
|
||||
|
||||
[[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]]
|
||||
name = "markupsafe"
|
||||
version = "3.0.3"
|
||||
@@ -201,34 +380,72 @@ 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" },
|
||||
]
|
||||
|
||||
[[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]]
|
||||
name = "piku-backend"
|
||||
version = "0.1.0"
|
||||
source = { virtual = "." }
|
||||
dependencies = [
|
||||
{ name = "apscheduler" },
|
||||
{ name = "boto3" },
|
||||
{ name = "django" },
|
||||
{ name = "django-apscheduler" },
|
||||
{ name = "django-cors-headers" },
|
||||
{ name = "django-environ" },
|
||||
{ name = "django-extensions" },
|
||||
{ name = "django-storages" },
|
||||
{ name = "django-structlog" },
|
||||
{ name = "djangorestframework" },
|
||||
{ name = "djangorestframework-simplejwt" },
|
||||
{ name = "djangorestframework-stubs" },
|
||||
{ name = "freezegun" },
|
||||
{ name = "gunicorn" },
|
||||
{ name = "psycopg2-binary" },
|
||||
{ name = "pyopenssl" },
|
||||
{ name = "rich" },
|
||||
{ name = "ruff" },
|
||||
{ name = "structlog" },
|
||||
{ name = "werkzeug" },
|
||||
]
|
||||
|
||||
[package.metadata]
|
||||
requires-dist = [
|
||||
{ name = "apscheduler", specifier = ">=3.11.2" },
|
||||
{ name = "boto3", specifier = ">=1.42.96" },
|
||||
{ name = "django", specifier = ">=6.0.4" },
|
||||
{ name = "django-apscheduler", specifier = ">=0.7.0" },
|
||||
{ name = "django-cors-headers", specifier = ">=4.9.0" },
|
||||
{ name = "django-environ", specifier = ">=0.13.0" },
|
||||
{ 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-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 = "pyopenssl", specifier = ">=26.0.0" },
|
||||
{ name = "rich", specifier = ">=15.0.0" },
|
||||
{ name = "ruff", specifier = ">=0.15.9" },
|
||||
{ name = "structlog", specifier = ">=25.5.0" },
|
||||
{ name = "werkzeug", specifier = ">=3.1.8" },
|
||||
]
|
||||
|
||||
@@ -260,6 +477,15 @@ 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" },
|
||||
]
|
||||
|
||||
[[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]]
|
||||
name = "pyjwt"
|
||||
version = "2.12.1"
|
||||
@@ -281,6 +507,40 @@ 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" },
|
||||
]
|
||||
|
||||
[[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]]
|
||||
name = "ruff"
|
||||
version = "0.15.9"
|
||||
@@ -306,6 +566,27 @@ 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" },
|
||||
]
|
||||
|
||||
[[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]]
|
||||
name = "sqlparse"
|
||||
version = "0.5.5"
|
||||
@@ -315,6 +596,33 @@ 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" },
|
||||
]
|
||||
|
||||
[[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]]
|
||||
name = "tzdata"
|
||||
version = "2026.1"
|
||||
@@ -324,6 +632,27 @@ 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" },
|
||||
]
|
||||
|
||||
[[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]]
|
||||
name = "werkzeug"
|
||||
version = "3.1.8"
|
||||
|
||||
+1
-1
@@ -42,7 +42,7 @@
|
||||
"noUnusedVariables": "error"
|
||||
}
|
||||
},
|
||||
"includes": ["**/src", "!backend"]
|
||||
"includes": ["**", "!backend"]
|
||||
},
|
||||
"assist": {
|
||||
"actions": {
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
name: piku_e2e
|
||||
services:
|
||||
db:
|
||||
image: postgres:16-alpine
|
||||
|
||||
@@ -2,7 +2,6 @@ services:
|
||||
db:
|
||||
# postgres database
|
||||
image: postgres:16-alpine
|
||||
container_name: piku_db
|
||||
environment:
|
||||
POSTGRES_DB: ${DB_NAME}
|
||||
POSTGRES_USER: ${DB_USER}
|
||||
@@ -16,7 +15,6 @@ services:
|
||||
mailpit:
|
||||
# email testing
|
||||
image: axllent/mailpit
|
||||
container_name: piku_mail
|
||||
ports:
|
||||
- "8025:8025" # Web UI
|
||||
- "${EMAIL_PORT}:1025" # SMTP
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
|
||||
node_modules
|
||||
test-results
|
||||
playwright-report
|
||||
dist
|
||||
coverage
|
||||
@@ -0,0 +1,32 @@
|
||||
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;"]
|
||||
+83
-34
@@ -45,8 +45,8 @@ test.describe("Letter Drafting (Real Backend)", () => {
|
||||
await page.keyboard.type("This is a secret draft");
|
||||
await page.keyboard.press("Enter");
|
||||
await page.keyboard.type("It should persist.");
|
||||
logger.info(">> [Draft] Clicking Store...");
|
||||
await page.getByRole("button", { name: /store/i }).click();
|
||||
logger.info(">> [Draft] Clicking Draft...");
|
||||
await page.getByRole("button", { name: /draft/i }).click();
|
||||
|
||||
// Verify Success Modal/Alert
|
||||
await expect(page.getByText(/your letter is saved/i)).toBeVisible();
|
||||
@@ -76,7 +76,9 @@ test.describe("Letter Drafting (Real Backend)", () => {
|
||||
await expect(canvasInput).toHaveValue(/It should persist/i);
|
||||
});
|
||||
|
||||
test("should seal a letter and show sharing link", async ({ page }) => {
|
||||
test("should seal a letter and navigate to Reader, then share on demand", async ({
|
||||
page,
|
||||
}) => {
|
||||
const timestamp = Date.now() + Math.random();
|
||||
const email = `seal-${timestamp}@example.com`;
|
||||
const name = `Seal Author ${timestamp}`;
|
||||
@@ -84,39 +86,67 @@ test.describe("Letter Drafting (Real Backend)", () => {
|
||||
await AuthHelper.registerAndLogin(page, email, name, password);
|
||||
|
||||
logger.info(">> [Seal] Navigating to Editor via UI...");
|
||||
await page.getByRole("button", { name: /write something/i }).click();
|
||||
await page.locator("#write-letter-btn").click();
|
||||
|
||||
const recipientInput = page.locator("#recipient");
|
||||
await recipientInput.waitFor({ state: "visible", timeout: 20000 });
|
||||
await recipientInput.waitFor({ state: "visible", timeout: 10000 });
|
||||
await recipientInput.fill("A Secret Guest");
|
||||
|
||||
const canvasInput = page.getByLabel("Canvas text input");
|
||||
await canvasInput.focus();
|
||||
await canvasInput.fill("This letter will be sealed and shared.");
|
||||
|
||||
// Click Seal
|
||||
// Click Seal (open menu, then confirm)
|
||||
logger.info(">> [Seal] Clicking Seal...");
|
||||
await page.getByRole("button", { name: /seal/i }).click();
|
||||
await page
|
||||
.getByRole("button", { name: /seal/i })
|
||||
.filter({ visible: true })
|
||||
.click();
|
||||
await page
|
||||
.getByRole("button", { name: /seal/i })
|
||||
.filter({ visible: true })
|
||||
.click();
|
||||
|
||||
// Verify "Sealed & Ready" modal
|
||||
logger.info(">> [Seal] Verifying sharing modal...");
|
||||
await expect(page.getByText(/sealed & ready/i)).toBeVisible();
|
||||
// Should show sealed confirmation modal
|
||||
logger.info(">> [Seal] Verifying sealed modal...");
|
||||
await expect(page.getByText(/your letter is sealed/i)).toBeVisible({
|
||||
timeout: 10000,
|
||||
});
|
||||
|
||||
// Verify sharing link contains a hash (the key)
|
||||
const linkInput = page.locator("input[readOnly]");
|
||||
// Navigate to Reader via "View letter"
|
||||
await page.getByRole("button", { name: /view letter/i }).click();
|
||||
|
||||
// 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();
|
||||
|
||||
expect(linkValue).toContain("/read/");
|
||||
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();
|
||||
|
||||
// Close modal
|
||||
await page.getByRole("button", { name: /close/i }).click();
|
||||
await expect(page.getByText(/sealed & ready/i)).toBeHidden();
|
||||
await expect(page.getByText(/send this letter/i)).toBeHidden();
|
||||
});
|
||||
|
||||
test("should allow author to access sealed letter from drawer without sharing key", async ({
|
||||
@@ -141,16 +171,21 @@ test.describe("Letter Drafting (Real Backend)", () => {
|
||||
await canvasInput.focus();
|
||||
await canvasInput.fill(letterContent);
|
||||
|
||||
// Click Seal
|
||||
await page.getByRole("button", { name: /seal/i }).click();
|
||||
await expect(page.getByText(/sealed & ready/i)).toBeVisible();
|
||||
// Click Seal (open menu, then confirm)
|
||||
await page
|
||||
.getByRole("button", { name: /seal/i })
|
||||
.filter({ visible: true })
|
||||
.click();
|
||||
await page
|
||||
.getByRole("button", { name: /seal/i })
|
||||
.filter({ visible: true })
|
||||
.click();
|
||||
|
||||
// Close modal
|
||||
await page.getByRole("button", { name: /close/i }).click();
|
||||
|
||||
// Navigate to Drawer - use ID or precise label
|
||||
logger.info(">> [Drawer] Navigating to Drawer...");
|
||||
await page.locator("button[aria-label='Open Drawer']").click();
|
||||
// Sealed modal should appear — click "Keep it" to go to Drawer
|
||||
await expect(page.getByText(/your letter is sealed/i)).toBeVisible({
|
||||
timeout: 10000,
|
||||
});
|
||||
await page.getByRole("button", { name: /keep it/i }).click();
|
||||
|
||||
// Open "Kept" section - search for the section with id='kept' and click its toggle button
|
||||
logger.info(">> [Drawer] Opening Kept section...");
|
||||
@@ -168,14 +203,28 @@ test.describe("Letter Drafting (Real Backend)", () => {
|
||||
logger.info(">> [Drawer] Verifying Reader page...");
|
||||
// Give it a bit more time for decryption
|
||||
await expect(page).toHaveURL(/\/read\/[a-f0-9-]{36}$/, { timeout: 15000 }); // UUID without hash
|
||||
|
||||
// Check decrypted content in Reader
|
||||
await expect(page.getByText(/decrypting/i)).toBeHidden({
|
||||
// Reveal and check decrypted content in Reader
|
||||
await expect(page.getByText(/breaking the seal/i)).toBeHidden({
|
||||
timeout: 10000,
|
||||
});
|
||||
await expect(
|
||||
page.getByText(new RegExp(`A sealed letter for ${recipientName}`, "i")),
|
||||
).toBeVisible();
|
||||
// Check recipient on the front of the envelope
|
||||
await expect(page.getByText(new RegExp(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
|
||||
const readerUrl = page.url();
|
||||
|
||||
@@ -23,7 +23,7 @@ export async function registerAndLogin(
|
||||
// 1. Registration
|
||||
logger.info(`[Auth] Registering user: ${email}`);
|
||||
await page.goto("/onboard");
|
||||
await page.getByLabel(/full name/i).fill(fullName);
|
||||
await page.getByLabel(/pen name/i).fill(fullName);
|
||||
await page.getByLabel("Email", { exact: true }).fill(email);
|
||||
await page.getByLabel("Password", { exact: true }).fill(password);
|
||||
await page.getByLabel(/confirm password/i).fill(password);
|
||||
|
||||
@@ -23,7 +23,7 @@ export const MailpitHelper = {
|
||||
});
|
||||
|
||||
if (response.ok()) {
|
||||
const data = await response.json();
|
||||
const data: { messages: MailpitMessage[] } = await response.json();
|
||||
if (data.messages?.length > 0) {
|
||||
const msgId = data.messages[0].ID;
|
||||
const detailRes = await requestContext.get(
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -5,7 +5,8 @@
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "tsc -b && vite build",
|
||||
"build": "tsc -b & vite build",
|
||||
"build:prod": "vite build --mode production",
|
||||
"lint": "biome lint --write ./src",
|
||||
"format": "biome format --write ./src",
|
||||
"check": "biome check --write ./src",
|
||||
@@ -15,7 +16,7 @@
|
||||
"test:watch": "vitest",
|
||||
"test:coverage": "vitest run --coverage",
|
||||
"test:e2e": "playwright test",
|
||||
"test:e2e:ui": "playwright test --ui"
|
||||
"test:e2e:ui": "playwright test --ui --ui-host=0.0.0.0 --ui-port=43008"
|
||||
},
|
||||
"dependencies": {
|
||||
"@fontsource-variable/jost": "^5.2.8",
|
||||
|
||||
@@ -14,7 +14,6 @@ const baseUrl = getBaseUrl(
|
||||
env.FRONTEND_PORT,
|
||||
);
|
||||
|
||||
console.log(baseUrl);
|
||||
export default defineConfig({
|
||||
timeout: 60000,
|
||||
expect: {
|
||||
@@ -61,7 +60,7 @@ export default defineConfig({
|
||||
|
||||
/* Run your local dev server before starting the tests */
|
||||
webServer: {
|
||||
command: "bun run dev -- --mode e2e",
|
||||
command: "npm run dev -- --mode e2e",
|
||||
url: getBaseUrl(
|
||||
process.env.SSL_ENABLED === "true",
|
||||
process.env.FRONTEND_DOMAIN,
|
||||
|
||||
@@ -1,24 +0,0 @@
|
||||
<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>
|
||||
|
Before Width: | Height: | Size: 4.9 KiB |
+12
-10
@@ -1,21 +1,21 @@
|
||||
import { useEffect } from "react";
|
||||
import { lazy, Suspense, useEffect } from "react";
|
||||
import { BrowserRouter, Navigate, Route, Routes } from "react-router-dom";
|
||||
import { ProtectedRoute, PublicRoute } from "./components/RouteGuards";
|
||||
import SplashScreen from "./components/SplashScreen";
|
||||
import { ROUTES } from "./config/routes";
|
||||
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;
|
||||
|
||||
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() {
|
||||
const { initialize, isInitializing } = useAuth();
|
||||
|
||||
@@ -32,6 +32,7 @@ export default function App() {
|
||||
return (
|
||||
<BrowserRouter>
|
||||
<main className="min-h-screen bg-base-200 flex items-center justify-center w-full">
|
||||
<Suspense fallback={<SplashScreen />}>
|
||||
<Routes>
|
||||
<Route path={ROUTES.HOME} element={<Home />} />
|
||||
|
||||
@@ -87,6 +88,7 @@ export default function App() {
|
||||
<Route path={ROUTES.READ} element={<Reader />} />
|
||||
<Route path="*" element={<Navigate to={ROUTES.HOME} replace />} />
|
||||
</Routes>
|
||||
</Suspense>
|
||||
</main>
|
||||
</BrowserRouter>
|
||||
);
|
||||
|
||||
@@ -1,13 +1,5 @@
|
||||
import { HttpResponse, http } from "msw";
|
||||
import {
|
||||
afterAll,
|
||||
beforeAll,
|
||||
beforeEach,
|
||||
describe,
|
||||
expect,
|
||||
it,
|
||||
vi,
|
||||
} from "vitest";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { mockUser } from "../../test/fixtures/user.fixture";
|
||||
import { server } from "../../test/mocks/server";
|
||||
import { useAuthStore } from "../store/useAuthStore";
|
||||
@@ -21,13 +13,10 @@ beforeEach(() => {
|
||||
user: null,
|
||||
isInitializing: false,
|
||||
});
|
||||
});
|
||||
|
||||
beforeAll(() => {
|
||||
vi.stubEnv("VITE_API_URL", VITE_API_URL);
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
afterEach(() => {
|
||||
vi.unstubAllEnvs();
|
||||
});
|
||||
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 8.1 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 7.4 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 44 KiB |
@@ -1 +0,0 @@
|
||||
<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>
|
||||
|
Before Width: | Height: | Size: 4.0 KiB |
File diff suppressed because one or more lines are too long
|
Before Width: | Height: | Size: 8.5 KiB |
@@ -1,26 +1,26 @@
|
||||
import { DotIcon } from "@phosphor-icons/react";
|
||||
import "@fontsource/knewave/400.css";
|
||||
|
||||
export default function Logo() {
|
||||
export default function Logo({ scale = 2 }) {
|
||||
return (
|
||||
<span
|
||||
<div
|
||||
role="img"
|
||||
aria-label="Pi Ku"
|
||||
className="inline-flex items-baseline justify-center leading-none select-none"
|
||||
style={{ fontFamily: "'Knewave', serif" }}
|
||||
style={{ fontFamily: "'Knewave', serif", scale }}
|
||||
>
|
||||
<span className="text-2xl font-light text-accent">Pi</span>
|
||||
<span className={`text-xl font-light text-accent`}> Pi</span>
|
||||
<DotIcon
|
||||
weight="fill"
|
||||
size={12}
|
||||
className="text-accent translate-y-[0.3em] -mx-px"
|
||||
size={6}
|
||||
className={`text-primary translate-y-1 -mx-px`}
|
||||
/>
|
||||
<span className="text-2xl font-light text-accent">Ku</span>
|
||||
<span className={`text-xl font-light text-accent`}> Ku</span>
|
||||
<DotIcon
|
||||
weight="fill"
|
||||
size={12}
|
||||
className="text-accent translate-y-[0.3em] -mx-px"
|
||||
size={6}
|
||||
className={`text-primary translate-y-1 -mx-px`}
|
||||
/>
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -40,7 +40,7 @@ describe("ProtectedRoute", () => {
|
||||
"/protected",
|
||||
);
|
||||
|
||||
expect(screen.getByText(/Unsealing.../i)).toBeInTheDocument();
|
||||
expect(screen.getByText(/Unsealing/i)).toBeInTheDocument();
|
||||
expect(screen.queryByText("Secret")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
@@ -90,7 +90,7 @@ describe("PublicRoute", () => {
|
||||
</PublicRoute>,
|
||||
"/public",
|
||||
);
|
||||
expect(screen.getByText(/Unsealing.../i)).toBeInTheDocument();
|
||||
expect(screen.getByText(/Unsealing/i)).toBeInTheDocument();
|
||||
expect(screen.queryByText("Login Page")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
|
||||
@@ -1,14 +1,22 @@
|
||||
import { EnvelopeOpenIcon } from "@phosphor-icons/react";
|
||||
import Logo from "./Logo";
|
||||
|
||||
export default function SplashScreen() {
|
||||
return (
|
||||
<div className="fixed inset-0 bg-base-100 flex flex-col items-center justify-center z-9999">
|
||||
<div className="fixed w-screen h-screen 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">
|
||||
<Logo />
|
||||
|
||||
<div className="flex flex-col items-center gap-2">
|
||||
<span className="loading loading-ring loading-lg text-primary" />
|
||||
<p className="text-xs uppercase font-sans tracking-widest opacity-40">
|
||||
Unsealing...
|
||||
<EnvelopeOpenIcon
|
||||
weight="thin"
|
||||
className={"absolute text-primary/50"}
|
||||
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>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
+18
-5
@@ -1,3 +1,5 @@
|
||||
import { GearFineIcon } from "@phosphor-icons/react";
|
||||
|
||||
interface DrawerSectionProps {
|
||||
id: string;
|
||||
title: string;
|
||||
@@ -18,12 +20,12 @@ export function DrawerSection({
|
||||
return (
|
||||
<div
|
||||
id={id}
|
||||
className={`join-item group flex flex-col transition-colors ${isOpen ? "bg-base-300/30" : ""}`}
|
||||
className={`join-item group flex flex-col transition-colors duration-3000 ease-in-out ${isOpen ? "bg-base-300/30" : ""}`}
|
||||
>
|
||||
<div
|
||||
className={`overflow-hidden transition-all duration-1000 ease-in-out bg-neutral/10 ${
|
||||
className={`transition-all duration-1500 ease-in-out bg-neutral/10 ${
|
||||
isOpen
|
||||
? "max-h-125 opacity-100 py-3 border-b border-base-content/5"
|
||||
? "max-h-125 opacity-100 py-3 border-b border-base-content/5 overflow-visible"
|
||||
: "max-h-0 opacity-0 pointer-events-none"
|
||||
}`}
|
||||
>
|
||||
@@ -33,11 +35,11 @@ export function DrawerSection({
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClick}
|
||||
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`}
|
||||
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`}
|
||||
>
|
||||
<div className="flex-1">
|
||||
<div
|
||||
className={`font-sans text-xs tracking-[0.2em] uppercase transition-colors duration-300 ${
|
||||
className={`font-sans text-xs tracking-[0.2em] uppercase transition-colors duration-800 ${
|
||||
isOpen
|
||||
? "text-base-content"
|
||||
: "text-base-content/40 group-hover:text-base-content/80"
|
||||
@@ -49,6 +51,16 @@ export function DrawerSection({
|
||||
{count}
|
||||
</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
|
||||
className={`w-8 h-1 rounded-sm transition-all duration-300 bg-neutral ${
|
||||
isOpen
|
||||
@@ -58,6 +70,7 @@ export function DrawerSection({
|
||||
>
|
||||
<div className="absolute -top-1 left-1.75 w-5 h-px bg-base-content/5" />
|
||||
</div>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
@@ -0,0 +1,59 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,195 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,99 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,144 @@
|
||||
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>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
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,38 +0,0 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -18,7 +18,7 @@ export const LogModal = ({
|
||||
return status === "RESET" || !isOpen ? (
|
||||
<div></div>
|
||||
) : (
|
||||
<div className="modal modal-open modal-bottom sm:modal-middle bg-base-100/20 backdrop-blur-md z-100">
|
||||
<div className="modal modal-open modal-middle bg-base-100/20 backdrop-blur-md z-100">
|
||||
<div className="modal-box bg-transparent border-none shadow-none relative">
|
||||
<div
|
||||
className={`alert ${status === "WARN" ? "alert-warning" : "alert-error"} flex flex-col items-center text-center gap-6 py-4`}
|
||||
|
||||
@@ -6,7 +6,6 @@ import { mockUser } from "../../test/fixtures/user.fixture";
|
||||
import { server } from "../../test/mocks/server";
|
||||
import { useAuthStore } from "../store/useAuthStore";
|
||||
import { useKeyStore } from "../store/useKeyStore";
|
||||
import { CryptoUtils } from "../utils/crypto";
|
||||
import {
|
||||
clearMasterKey,
|
||||
loadMasterKey,
|
||||
@@ -14,7 +13,6 @@ import {
|
||||
} from "../utils/keystore";
|
||||
import { useAuth } from "./useAuth";
|
||||
|
||||
vi.mock("../utils/crypto");
|
||||
vi.mock("../utils/keystore");
|
||||
|
||||
const VITE_API_URL = "http://piku-server";
|
||||
@@ -22,12 +20,6 @@ const VITE_API_URL = "http://piku-server";
|
||||
beforeEach(() => {
|
||||
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(saveMasterKey).mockResolvedValue("masterKey");
|
||||
vi.mocked(clearMasterKey).mockResolvedValue(undefined);
|
||||
|
||||
@@ -48,9 +48,7 @@ export const useAuth = () => {
|
||||
try {
|
||||
const masterKey = await loadMasterKey();
|
||||
if (masterKey) setMasterKey(masterKey);
|
||||
} catch {
|
||||
console.error("Master key restoration failed");
|
||||
}
|
||||
} catch {}
|
||||
|
||||
// If session in memory, don't trigger refresh/me again
|
||||
if (accessToken && user) {
|
||||
@@ -82,9 +80,7 @@ export const useAuth = () => {
|
||||
);
|
||||
await saveMasterKey(masterKey);
|
||||
setMasterKey(masterKey);
|
||||
} catch {
|
||||
console.error("Master key restoration failed");
|
||||
}
|
||||
} catch {}
|
||||
};
|
||||
|
||||
return {
|
||||
|
||||
@@ -0,0 +1,113 @@
|
||||
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");
|
||||
});
|
||||
});
|
||||
@@ -10,7 +10,7 @@ export interface Letter {
|
||||
status: "DRAFT" | "SEALED" | "BURNED";
|
||||
updated_at: string;
|
||||
sealed_at?: string;
|
||||
unlock_at?: string;
|
||||
unlock_at: string;
|
||||
encrypted_metadata: string;
|
||||
encrypted_content: string;
|
||||
encrypted_dek: string;
|
||||
@@ -69,7 +69,15 @@ export function useLetters() {
|
||||
api
|
||||
.get(endpoints.LETTERS)
|
||||
.then((res) => decryptLetters(res.data, masterKey))
|
||||
.then(setLetters)
|
||||
.then((decrypted) => {
|
||||
setLetters(
|
||||
decrypted.sort(
|
||||
(a, b) =>
|
||||
new Date(b.updated_at).getTime() -
|
||||
new Date(a.updated_at).getTime(),
|
||||
),
|
||||
);
|
||||
})
|
||||
.catch((_err) => {})
|
||||
.finally(() => setLoading(false));
|
||||
}, [masterKey]);
|
||||
@@ -86,7 +94,6 @@ export function useLetters() {
|
||||
return {
|
||||
...drawerItems,
|
||||
loading,
|
||||
refreshLetters: () => setLoading(true),
|
||||
isAuthRequired,
|
||||
};
|
||||
}
|
||||
|
||||
+5
-18
@@ -7,50 +7,39 @@
|
||||
prefersdark: true;
|
||||
color-scheme: dark;
|
||||
|
||||
/* ── Base surfaces ── */
|
||||
--color-base-100: oklch(14% 0.012 35); /* was 0.018 hue 50 */
|
||||
--color-base-100: oklch(14% 0.012 35);
|
||||
--color-base-200: oklch(18% 0.014 33);
|
||||
--color-base-300: oklch(22% 0.016 32);
|
||||
--color-base-content: oklch(
|
||||
82% 0.02 70
|
||||
); /* aged parchment, not crisp white */
|
||||
--color-base-content: oklch(82% 0.02 70);
|
||||
|
||||
/* ── Primary: old lamp gold — warm, incandescent ── */
|
||||
--color-primary: oklch(67% 0.11 78);
|
||||
--color-primary-content: oklch(15% 0.03 70);
|
||||
|
||||
/* ── Secondary: dusty plum ── */
|
||||
--color-secondary: oklch(48% 0.08 305);
|
||||
--color-secondary-content: oklch(92% 0.01 305);
|
||||
|
||||
/* ── Accent: muted lavender-clay ── */
|
||||
--color-accent: oklch(55% 0.06 325);
|
||||
--color-accent-content: oklch(18% 0.03 295);
|
||||
|
||||
/* ── Neutral: warm stone ── */
|
||||
--color-neutral: oklch(28% 0.02 45);
|
||||
--color-neutral-content: oklch(80% 0.015 60);
|
||||
|
||||
/* ── Semantic — desaturated, no alarm ── */
|
||||
--color-info: oklch(60% 0.07 240);
|
||||
--color-info-content: oklch(95% 0.01 240);
|
||||
--color-success: oklch(60% 0.08 150);
|
||||
--color-success-content: oklch(16% 0.03 150);
|
||||
--color-warning: oklch(68% 0.08 72); /* honey, not caution-sign amber */
|
||||
--color-warning: oklch(68% 0.08 72);
|
||||
--color-warning-content: oklch(18% 0.03 60);
|
||||
--color-error: oklch(55% 0.1 22);
|
||||
--color-error-content: oklch(92% 0.01 22);
|
||||
|
||||
/* ── Shape ── */
|
||||
--radius-selector: 0.5rem;
|
||||
--radius-field: 0.375rem;
|
||||
--radius-box: 0.5rem;
|
||||
|
||||
/* ── Effects ── */
|
||||
--depth: 1;
|
||||
--noise: 0.03;
|
||||
|
||||
/* ── Border ── */
|
||||
--border: 1px;
|
||||
}
|
||||
|
||||
@@ -58,12 +47,10 @@
|
||||
--font-display: "Playwrite HR Lijeva Variable", cursive;
|
||||
--font-sans: "Jost Variable", sans-serif;
|
||||
--font-serif: "Playfair Display Variable", serif;
|
||||
--color-glass-bg: rgba(
|
||||
28,
|
||||
--color-glass-bg: rgba(28,
|
||||
22,
|
||||
16,
|
||||
0.45
|
||||
); /* slightly deeper to match new base */
|
||||
0.45);
|
||||
--shadow-warm: 0 20px 50px -12px rgba(30, 20, 12, 0.6);
|
||||
--radius-xl: 1.5rem;
|
||||
--color-paper: oklch(97% 0.008 80);
|
||||
|
||||
@@ -68,7 +68,10 @@ export default function Activate() {
|
||||
type="button"
|
||||
className="btn btn-primary w-full shadow-lg"
|
||||
onClick={() =>
|
||||
navigate(ROUTES.LOGIN, { state: { firstTime: true } })
|
||||
navigate(ROUTES.LOGIN, {
|
||||
state: { firstTime: true },
|
||||
replace: true,
|
||||
})
|
||||
}
|
||||
>
|
||||
Start Writing
|
||||
|
||||
@@ -1,10 +1,13 @@
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import { MemoryRouter } from "react-router-dom";
|
||||
import { beforeEach, describe, expect, it } from "vitest";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { mockUser } from "../../test/fixtures/user.fixture";
|
||||
import { useLetters } from "../hooks/useLetters";
|
||||
import { useAuthStore } from "../store/useAuthStore";
|
||||
import Drawer from "./Drawer";
|
||||
|
||||
vi.mock("../hooks/useLetters");
|
||||
|
||||
describe("Drawer Page", () => {
|
||||
beforeEach(() => {
|
||||
// Setup authenticated state for the test
|
||||
@@ -13,6 +16,15 @@ describe("Drawer Page", () => {
|
||||
accessToken: "fake-token",
|
||||
isInitializing: false,
|
||||
});
|
||||
|
||||
vi.mocked(useLetters).mockReturnValue({
|
||||
drafts: [],
|
||||
kept: [],
|
||||
sent: [],
|
||||
vault: [],
|
||||
loading: false,
|
||||
isAuthRequired: false,
|
||||
});
|
||||
});
|
||||
|
||||
it("renders the cabinet sections and empty state message", () => {
|
||||
@@ -27,4 +39,43 @@ describe("Drawer Page", () => {
|
||||
expect(screen.getByText(/Vault/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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,12 +1,17 @@
|
||||
import { FeatherIcon, LockKeyIcon } from "@phosphor-icons/react";
|
||||
import { FeatherIcon } from "@phosphor-icons/react";
|
||||
import { useState } from "react";
|
||||
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 { DrawerSection } from "../components/ui/DrawerSection";
|
||||
import { LetterItem } from "../components/ui/LetterItem";
|
||||
import { PATHS } from "../config/routes";
|
||||
import { useAuth } from "../hooks/useAuth";
|
||||
import { useLetters } from "../hooks/useLetters";
|
||||
import {
|
||||
formatRelativeDate,
|
||||
formatRelativeDateWithoutTime,
|
||||
} from "../utils/dateFormat.ts";
|
||||
|
||||
export default function Drawer() {
|
||||
const { user, logout, unlock } = useAuth();
|
||||
@@ -24,54 +29,8 @@ 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="fixed inset-0 bg-[radial-gradient(circle_at_center,transparent_0%,rgba(0,0,0,0.5)_100%)] pointer-events-none z-0" />
|
||||
|
||||
{isAuthRequired && (
|
||||
<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">
|
||||
{isAuthRequired && <PasskeyModal onUnlock={unlock} />}
|
||||
<header className="text-center mb-12 z-10 animate-in fade-in slide-in-from-top-4 duration-500">
|
||||
<Logo />
|
||||
<div className="font-sans text-xs tracking-[0.3em] uppercase text-base-content/40 mt-2">
|
||||
Personal Archive
|
||||
@@ -89,7 +48,7 @@ export default function Drawer() {
|
||||
</div>
|
||||
</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 overflow-hidden animate-in fade-in slide-in-from-bottom-8 duration-1000 delay-200 fill-mode-backwards 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 duration-500 delay-200 min-h-64 flex flex-col">
|
||||
{loading ? (
|
||||
<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>
|
||||
@@ -112,7 +71,7 @@ export default function Drawer() {
|
||||
status={draft.status}
|
||||
key={draft.public_id}
|
||||
preview={draft.metadata?.recipient || "Untitled Draft"}
|
||||
timestamp={draft.updated_at}
|
||||
timestamp={formatRelativeDate(draft.updated_at)}
|
||||
/>
|
||||
))}
|
||||
</DrawerSection>
|
||||
@@ -130,7 +89,7 @@ export default function Drawer() {
|
||||
status={letter.status}
|
||||
key={letter.public_id}
|
||||
preview={letter.metadata?.recipient || "Someone dear..."}
|
||||
timestamp={letter.updated_at}
|
||||
timestamp={formatRelativeDate(letter.updated_at)}
|
||||
/>
|
||||
))}
|
||||
</DrawerSection>
|
||||
@@ -147,7 +106,7 @@ export default function Drawer() {
|
||||
status={letter.status}
|
||||
id={letter.public_id}
|
||||
preview={letter.metadata?.recipient || "Someone dear..."}
|
||||
timestamp={letter.updated_at}
|
||||
timestamp={formatRelativeDate(letter.updated_at)}
|
||||
/>
|
||||
))}
|
||||
{sent.length === 0 && (
|
||||
@@ -169,7 +128,11 @@ export default function Drawer() {
|
||||
status={letter.status}
|
||||
id={letter.public_id}
|
||||
preview={letter.metadata?.recipient || "Future Self"}
|
||||
timestamp={letter.updated_at}
|
||||
timestamp={formatRelativeDate(letter.updated_at)}
|
||||
unlock_at={formatRelativeDateWithoutTime(
|
||||
letter.unlock_at || "",
|
||||
)}
|
||||
isLocked={letter.unlock_at > new Date().toISOString()}
|
||||
/>
|
||||
))}
|
||||
</DrawerSection>
|
||||
@@ -179,27 +142,28 @@ export default function Drawer() {
|
||||
|
||||
<button
|
||||
type="button"
|
||||
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"
|
||||
onClick={() => navigate(PATHS.write(""), { replace: true })}
|
||||
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-500"
|
||||
onClick={() => navigate(PATHS.write(""))}
|
||||
>
|
||||
<FeatherIcon
|
||||
size={18}
|
||||
weight="duotone"
|
||||
className="text-primary/30 transition-all duration-700 group-hover:text-primary"
|
||||
className="text-primary/30 transition-all duration-300 group-hover:text-primary"
|
||||
/>
|
||||
Write something{" "}
|
||||
<span className="relative inline-flex">
|
||||
<span className="transition-opacity duration-1500 opacity-80 group-hover:opacity-0">
|
||||
<span className="transition-opacity duration-500 opacity-80 group-hover:opacity-0">
|
||||
. . . . . .
|
||||
</span>
|
||||
<span className="absolute inset-0 text-primary transition-opacity duration-1000 opacity-0 group-hover:opacity-100">
|
||||
<span className="absolute inset-0 text-primary transition-opacity duration-300 opacity-0 group-hover:opacity-100">
|
||||
unsaid
|
||||
</span>
|
||||
</span>
|
||||
</button>
|
||||
|
||||
<footer className="mt-25 font-sans text-[0.6rem] tracking-[0.2em] uppercase text-base-content/10 z-10">
|
||||
Kept. Unsent.
|
||||
For your unsaid.
|
||||
</footer>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -0,0 +1,168 @@
|
||||
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();
|
||||
});
|
||||
});
|
||||
+88
-131
@@ -1,11 +1,7 @@
|
||||
import {
|
||||
ClockIcon,
|
||||
DownloadSimpleIcon,
|
||||
ImageIcon,
|
||||
LockIcon,
|
||||
SpinnerGapIcon,
|
||||
TrayIcon,
|
||||
XCircleIcon,
|
||||
XIcon,
|
||||
} from "@phosphor-icons/react";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
@@ -18,10 +14,17 @@ import { api } from "../api/apiClient";
|
||||
import {
|
||||
type CanvasTools,
|
||||
ComposeCanvas,
|
||||
} from "../components/ui/ComposeCanvas";
|
||||
} from "../components/editor/ComposeCanvas";
|
||||
import { PostSealModal } from "../components/editor/PostSealModal";
|
||||
import {
|
||||
LetterHead,
|
||||
ToolBar,
|
||||
VaultConfirmModal,
|
||||
} from "../components/editor/ToolBar";
|
||||
import DateDisplay from "../components/ui/DateDisplay";
|
||||
import { LogModal } from "../components/ui/LogModal";
|
||||
import { Navbar } from "../components/ui/Navbar";
|
||||
|
||||
import { endpoints } from "../config/endpoints";
|
||||
import { PATHS } from "../config/routes";
|
||||
import { useKeyStore } from "../store/useKeyStore";
|
||||
@@ -35,6 +38,12 @@ const OVERLAY_FADE_MS = 250;
|
||||
const SAVED_VISIBLE_MS = 1400;
|
||||
const ERROR_VISIBLE_MS = 2400;
|
||||
|
||||
const toPlaceholderList = [
|
||||
"Someone dear...",
|
||||
"Somewhere near...",
|
||||
"Something to bear...",
|
||||
];
|
||||
|
||||
export default function Editor() {
|
||||
const navigate = useNavigate();
|
||||
const navigateRef = useRef<NavigateFunction>(navigate);
|
||||
@@ -51,21 +60,39 @@ export default function Editor() {
|
||||
}>({ status: "RESET", message: "", log: "" });
|
||||
|
||||
const [isInitialLoading, setIsInitialLoading] = useState(false);
|
||||
const [shareLink, setShareLink] = useState<string | null>(null);
|
||||
const [sealedTargetId, setSealedTargetId] = useState<string | null>(null);
|
||||
const [lastSaved, setLastSaved] = useState<string | null>(null);
|
||||
const [status, setStatus] = useState<"DRAFT" | "SEALED">("DRAFT");
|
||||
const [status, setLetterStatus] = useState<"DRAFT" | "SEALED" | "VAULT">(
|
||||
"DRAFT",
|
||||
);
|
||||
const [isSaveDatePulsing, setIsSaveDatePulsing] = useState(false);
|
||||
const [lastSavedPulseTick, setLastSavedPulseTick] = useState(0);
|
||||
const [sealBtnClicked, setSealBtnClicked] = useState<boolean>(false);
|
||||
|
||||
const [saveOverlay, setSaveOverlay] = useState<SaveOverlay>("idle");
|
||||
const [showSaveOverlay, setShowSaveOverlay] = useState(false);
|
||||
const [confirmModal, setConfirmModal] = useState<"VAULT" | "SEAL" | null>(
|
||||
null,
|
||||
);
|
||||
|
||||
const [recipient, setRecipient] = useState("");
|
||||
const [unlockDate, setUnlockDate] = useState<Date | null>(null);
|
||||
const [placeholderIndex, setPlaceholderIndex] = useState(0);
|
||||
|
||||
const { masterKey } = useKeyStore();
|
||||
|
||||
const canvasRef = useRef<CanvasTools>(null);
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
// Placeholder rotation
|
||||
useEffect(() => {
|
||||
const interval = setInterval(() => {
|
||||
setPlaceholderIndex((prev) => (prev + 1) % toPlaceholderList.length);
|
||||
}, 4000);
|
||||
|
||||
return () => clearInterval(interval);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!(public_id && masterKey)) return;
|
||||
if (justSavedRef.current) {
|
||||
@@ -82,7 +109,7 @@ export default function Editor() {
|
||||
const letterData = res.data;
|
||||
|
||||
setLastSaved(formatRelativeDate(new Date(letterData.updated_at)));
|
||||
setStatus(letterData.status);
|
||||
setLetterStatus(letterData.status);
|
||||
|
||||
if (letterData.status === "SEALED") {
|
||||
navigateRef.current(PATHS.read(public_id), { replace: true });
|
||||
@@ -129,8 +156,6 @@ export default function Editor() {
|
||||
});
|
||||
}
|
||||
|
||||
console.log(canvasData);
|
||||
|
||||
if (canvasRef.current) {
|
||||
await canvasRef.current.loadData(canvasData);
|
||||
}
|
||||
@@ -192,7 +217,12 @@ export default function Editor() {
|
||||
}
|
||||
};
|
||||
|
||||
const handleSave = async (status: "SEALED" | "DRAFT"): Promise<void> => {
|
||||
const handleSave = async (
|
||||
status: "SEALED" | "DRAFT" | "VAULT",
|
||||
vaultDate?: Date,
|
||||
): Promise<void> => {
|
||||
setSealBtnClicked(false);
|
||||
|
||||
let targetId = public_id || letterIdRef.current;
|
||||
if (!targetId) {
|
||||
targetId = crypto.randomUUID();
|
||||
@@ -228,9 +258,18 @@ export default function Editor() {
|
||||
);
|
||||
|
||||
const formData = new FormData();
|
||||
formData.append("public_id", targetId);
|
||||
if (status === "VAULT") {
|
||||
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("status", status);
|
||||
}
|
||||
formData.append("public_id", targetId);
|
||||
formData.append("encrypted_content", encrypted_letter.encrypted_content);
|
||||
formData.append("encrypted_dek", encrypted_letter.encrypted_dek);
|
||||
formData.append(
|
||||
@@ -251,31 +290,20 @@ export default function Editor() {
|
||||
}
|
||||
|
||||
setLastSaved(formatRelativeDate(new Date()));
|
||||
setStatus(status);
|
||||
setLetterStatus(status);
|
||||
setLastSavedPulseTick((prev) => prev + 1);
|
||||
|
||||
if (status === "SEALED" && encrypted_letter.sharingKey) {
|
||||
const link = `${window.location.origin}${PATHS.read(
|
||||
targetId,
|
||||
)}#${encrypted_letter.sharingKey}`;
|
||||
setShareLink(link);
|
||||
setShowSaveOverlay(false);
|
||||
setTimeout(() => setSaveOverlay("idle"), OVERLAY_FADE_MS);
|
||||
} else {
|
||||
if (status === "SEALED") {
|
||||
setSealedTargetId(targetId);
|
||||
}
|
||||
setSaveOverlay("saved");
|
||||
setShowSaveOverlay(true);
|
||||
}
|
||||
} catch (_error) {
|
||||
setSaveOverlay("error");
|
||||
setShowSaveOverlay(true);
|
||||
}
|
||||
};
|
||||
|
||||
const copyToClipboard = async () => {
|
||||
if (!shareLink) return;
|
||||
await navigator.clipboard.writeText(shareLink);
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<Navbar
|
||||
@@ -285,18 +313,18 @@ export default function Editor() {
|
||||
isSaveDatePulsing ? "animate-pulse" : ""
|
||||
}`}
|
||||
>
|
||||
<ClockIcon
|
||||
size={16}
|
||||
weight="bold"
|
||||
className="text-neutral-content/30"
|
||||
/>
|
||||
<p className="text-sm text-neutral-content/30 flex-col justify-end leading-none text-right">
|
||||
<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>
|
||||
</p>
|
||||
</div>
|
||||
<ClockIcon
|
||||
size={16}
|
||||
weight="bold"
|
||||
className="text-neutral-content/30"
|
||||
/>
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
@@ -327,53 +355,7 @@ export default function Editor() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{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 && (
|
||||
{saveOverlay !== "idle" && (
|
||||
<div
|
||||
className={`modal modal-open bg-base-100/20 backdrop-blur-md transition-opacity duration-300 ${
|
||||
showSaveOverlay ? "opacity-100" : "opacity-0"
|
||||
@@ -429,6 +411,17 @@ export default function Editor() {
|
||||
</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="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">
|
||||
@@ -441,9 +434,9 @@ export default function Editor() {
|
||||
<input
|
||||
id="recipient"
|
||||
type="text"
|
||||
placeholder="Someone dear..."
|
||||
placeholder={toPlaceholderList[placeholderIndex]}
|
||||
value={recipient}
|
||||
disabled={status === "SEALED"}
|
||||
disabled={status !== "DRAFT"}
|
||||
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"
|
||||
/>
|
||||
@@ -452,18 +445,17 @@ export default function Editor() {
|
||||
</div>
|
||||
|
||||
{status === "DRAFT" ? (
|
||||
<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"
|
||||
onClick={() => fileInputRef.current?.click()}
|
||||
>
|
||||
<ImageIcon size={18} weight="bold" />
|
||||
</button>
|
||||
<ToolBar
|
||||
fileInputRef={fileInputRef}
|
||||
sealBtnClicked={sealBtnClicked}
|
||||
setSealBtnClicked={setSealBtnClicked}
|
||||
onSave={handleSave}
|
||||
setConfirmModal={setConfirmModal}
|
||||
/>
|
||||
) : (
|
||||
<LetterHead />
|
||||
)}
|
||||
|
||||
<input
|
||||
type="file"
|
||||
ref={fileInputRef}
|
||||
@@ -471,43 +463,8 @@ export default function Editor() {
|
||||
accept="image/*"
|
||||
className="hidden"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<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"} />
|
||||
<ComposeCanvas ref={canvasRef} readOnly={status !== "DRAFT"} />
|
||||
</div>
|
||||
</section>
|
||||
</>
|
||||
|
||||
@@ -44,7 +44,19 @@ describe("Login Page", () => {
|
||||
expect(await screen.findByText(/technical issues/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should redirect to the drawer when login is successful", async () => {
|
||||
it.each([
|
||||
{
|
||||
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 = {
|
||||
public_id: "user-123",
|
||||
email: "test@example.com",
|
||||
@@ -61,10 +73,18 @@ describe("Login Page", () => {
|
||||
);
|
||||
|
||||
render(
|
||||
<MemoryRouter initialEntries={["/login"]}>
|
||||
<MemoryRouter
|
||||
initialEntries={[
|
||||
{
|
||||
pathname: "/login",
|
||||
state: locationState,
|
||||
},
|
||||
]}
|
||||
>
|
||||
<Routes>
|
||||
<Route path="/login" element={<Login />} />
|
||||
<Route path="/drawer" element={<div>Drawer</div>} />
|
||||
<Route path="/read/:publicId" element={<div>Reader</div>} />
|
||||
</Routes>
|
||||
</MemoryRouter>,
|
||||
);
|
||||
@@ -73,6 +93,6 @@ describe("Login Page", () => {
|
||||
await userEvent.type(screen.getByLabelText(/password/i), "password123");
|
||||
await userEvent.click(screen.getByRole("button", { name: /sign in/i }));
|
||||
|
||||
expect(await screen.findByText(/Drawer/i)).toBeInTheDocument();
|
||||
expect(await screen.findByText(nextRoute)).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -20,6 +20,57 @@ const loginSchema = z.object({
|
||||
|
||||
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() {
|
||||
const navigate = useNavigate();
|
||||
const location = useLocation();
|
||||
@@ -27,6 +78,7 @@ export default function Login() {
|
||||
const [apiError, setApiError] = useState<string | null>(null);
|
||||
const { setAuthStore } = useAuth();
|
||||
const [showWelcome, setShowWelcome] = useState(!!location.state?.firstTime);
|
||||
const nextRoute = location.state?.redirectUrl || ROUTES.DRAWER;
|
||||
|
||||
const {
|
||||
register,
|
||||
@@ -59,7 +111,7 @@ export default function Login() {
|
||||
// store the auth related data
|
||||
await setAuthStore(authData.access, userData, masterKey);
|
||||
|
||||
navigate(ROUTES.DRAWER);
|
||||
navigate(nextRoute, { replace: true });
|
||||
} catch (err) {
|
||||
let message =
|
||||
"Sorry, we're experiencing technical issues.\nPlease try again later.";
|
||||
@@ -74,61 +126,10 @@ export default function Login() {
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-3">
|
||||
{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>
|
||||
)}
|
||||
{showWelcome && <WelcomeModal setShowWelcome={setShowWelcome} />}
|
||||
<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">
|
||||
<h1 className="card-title font-display text-2xl font-bold justify-center text-primary tracking-tight">
|
||||
<h1 className="card-title font-display text-2xl justify-center text-primary/80 tracking-tight">
|
||||
Sign in to <Logo />
|
||||
</h1>
|
||||
|
||||
|
||||
@@ -1,28 +1,15 @@
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import { render, screen, waitFor } from "@testing-library/react";
|
||||
import { HttpResponse, http } from "msw";
|
||||
import { MemoryRouter, Route, Routes } from "react-router-dom";
|
||||
import { MemoryRouter, Route, Routes, useLocation } from "react-router-dom";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { server } from "../../test/mocks/server";
|
||||
import { endpoints } from "../config/endpoints";
|
||||
import { useKeyStore } from "../store/useKeyStore";
|
||||
import { CryptoUtils } from "../utils/crypto";
|
||||
import Reader from "./Reader";
|
||||
|
||||
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
|
||||
Object.defineProperty(document, "fonts", {
|
||||
value: { ready: Promise.resolve() },
|
||||
@@ -30,13 +17,27 @@ Object.defineProperty(document, "fonts", {
|
||||
});
|
||||
|
||||
describe("Reader Page", () => {
|
||||
beforeEach(() => {
|
||||
let masterKey: CryptoKey;
|
||||
let utils: CryptoUtils;
|
||||
|
||||
const LocationTest = () => {
|
||||
const location = useLocation();
|
||||
return (
|
||||
<div data-testid="location-state">
|
||||
{JSON.stringify(location.state || {})}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
beforeEach(async () => {
|
||||
vi.clearAllMocks();
|
||||
|
||||
// Default mock behavior for successful decryption
|
||||
spyDecryptLetter.mockResolvedValue('{"objects": []}');
|
||||
spyDecryptMetadata.mockResolvedValue({ recipient: "Guest" });
|
||||
spyDecryptImage.mockResolvedValue("blob:url");
|
||||
utils = new CryptoUtils();
|
||||
await utils.initialize();
|
||||
const bundle = await CryptoUtils.deriveKeyBundle("password", "salt");
|
||||
masterKey = bundle.masterKey;
|
||||
// User is logged in by default
|
||||
useKeyStore.setState({ masterKey });
|
||||
|
||||
// Clear the URL hash
|
||||
vi.stubGlobal("location", {
|
||||
@@ -45,47 +46,39 @@ describe("Reader Page", () => {
|
||||
});
|
||||
});
|
||||
|
||||
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 () => {
|
||||
it("should load and decrypt the letter when a valid key is provided and display the envelope", async () => {
|
||||
const mockPublicId = "test-uuid";
|
||||
const mockKey = "fake-key";
|
||||
const letterContent = JSON.stringify({ objects: [] });
|
||||
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(
|
||||
http.get(`${API_URL}${endpoints.LETTERS}${mockPublicId}/`, () => {
|
||||
return HttpResponse.json({
|
||||
encrypted_content: "packed-content",
|
||||
encrypted_metadata: "packed-metadata",
|
||||
encrypted_content: encryptedLetter.encrypted_content,
|
||||
encrypted_metadata: encryptedMetadata.encrypted_content, // Reader expects .encrypted_content for metadata too
|
||||
encrypted_dek: encryptedLetter.encrypted_dek,
|
||||
images: [],
|
||||
});
|
||||
}),
|
||||
);
|
||||
|
||||
render(
|
||||
<MemoryRouter initialEntries={[`/read/${mockPublicId}#${mockKey}`]}>
|
||||
<MemoryRouter initialEntries={[`/read/${mockPublicId}#${sharingKey}`]}>
|
||||
<Routes>
|
||||
<Route path="/read/:public_id" element={<Reader />} />
|
||||
</Routes>
|
||||
</MemoryRouter>,
|
||||
);
|
||||
|
||||
// Should show loading state first
|
||||
expect(screen.getByText(/Decrypting.../i)).toBeInTheDocument();
|
||||
|
||||
expect(
|
||||
await screen.findByText(/A sealed message for/i),
|
||||
).toBeInTheDocument();
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText(/Guest/i)).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it("should display an error message if the server request fails", async () => {
|
||||
@@ -110,4 +103,39 @@ describe("Reader Page", () => {
|
||||
await screen.findByText(/Failed to load letter/i),
|
||||
).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}"`,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
+173
-66
@@ -1,16 +1,28 @@
|
||||
import { FlameIcon, PaperPlaneTiltIcon } from "@phosphor-icons/react";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { useLocation, useParams } from "react-router-dom";
|
||||
import {
|
||||
type NavigateFunction,
|
||||
useLocation,
|
||||
useNavigate,
|
||||
useParams,
|
||||
} from "react-router-dom";
|
||||
import { api } from "../api/apiClient";
|
||||
import Logo from "../components/Logo";
|
||||
import {
|
||||
type CanvasJSON,
|
||||
type CanvasTools,
|
||||
ComposeCanvas,
|
||||
} from "../components/ui/ComposeCanvas";
|
||||
} from "../components/editor/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 { endpoints } from "../config/endpoints";
|
||||
import { PATHS } from "../config/routes";
|
||||
import { useKeyStore } from "../store/useKeyStore";
|
||||
import { CryptoUtils } from "../utils/crypto";
|
||||
import { formatDate } from "../utils/dateFormat";
|
||||
import {
|
||||
decryptCanvasImages,
|
||||
decryptCanvasImagesWithSharingKey,
|
||||
@@ -18,17 +30,26 @@ import {
|
||||
|
||||
interface LetterMetadata {
|
||||
recipient?: string;
|
||||
updated_at?: string;
|
||||
}
|
||||
|
||||
export default function Reader() {
|
||||
const { public_id } = useParams();
|
||||
const location = useLocation();
|
||||
const navigate = useNavigate();
|
||||
const sharingKey = location.hash.replace("#", "");
|
||||
|
||||
const navigateRef = useRef<NavigateFunction>(navigate);
|
||||
const canvasRef = useRef<CanvasTools>(null);
|
||||
|
||||
const [isDecrypting, setIsDecrypting] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [revealState, setRevealState] = useState<
|
||||
"sealed" | "revealed" | "burned"
|
||||
>("sealed");
|
||||
const [error, setError] = useState<{
|
||||
message: string;
|
||||
log: string;
|
||||
} | null>(null);
|
||||
const [warning, setWarning] = useState<{
|
||||
message: string;
|
||||
log: string;
|
||||
@@ -36,23 +57,70 @@ export default function Reader() {
|
||||
const [metadata, setMetadata] = useState<LetterMetadata | null>(null);
|
||||
const [decryptedCanvasData, setDecryptedCanvasData] =
|
||||
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 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(() => {
|
||||
if (!(sharingKey || masterKey)) {
|
||||
setError(
|
||||
"No sharing key provided. Please check the link or log in if you are the author.",
|
||||
);
|
||||
setIsDecrypting(false);
|
||||
navigateRef.current("/login", {
|
||||
state: { redirectUrl: `/read/${public_id}` },
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const loadAndDecrypt = async () => {
|
||||
try {
|
||||
const response = await api.get(`${endpoints.LETTERS}${public_id}/`);
|
||||
const { encrypted_content, encrypted_metadata, encrypted_dek, images } =
|
||||
response.data;
|
||||
const {
|
||||
encrypted_content,
|
||||
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 isShared = !!sharingKey;
|
||||
@@ -60,7 +128,7 @@ export default function Reader() {
|
||||
if (isShared && !encrypted_content) throw new Error("Content missing");
|
||||
const isDecryptionKeyAvailable = encrypted_dek && masterKey;
|
||||
if (!(isShared || isDecryptionKeyAvailable))
|
||||
throw new Error("Auth required");
|
||||
throw new Error("Auth required: Decryption key is not available");
|
||||
|
||||
// Decrypt Metadata
|
||||
const decryptedMetadata = isShared
|
||||
@@ -73,7 +141,10 @@ export default function Reader() {
|
||||
// biome-ignore lint/style/noNonNullAssertion: masterKey is guaranteed to be non-null here as isDecryptionKeyAvailable is true
|
||||
masterKey!,
|
||||
);
|
||||
setMetadata(decryptedMetadata as LetterMetadata);
|
||||
setMetadata({
|
||||
...(decryptedMetadata as LetterMetadata),
|
||||
updated_at,
|
||||
});
|
||||
|
||||
// Decrypt Content
|
||||
const decryptedContent = isShared
|
||||
@@ -117,8 +188,10 @@ export default function Reader() {
|
||||
}
|
||||
setDecryptedCanvasData(canvasData);
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : "Unknown error";
|
||||
setError(`Failed to load letter: ${message}`);
|
||||
setError({
|
||||
message: `Failed to load letter :(`,
|
||||
log: err instanceof Error ? err.message : "Unknown error",
|
||||
});
|
||||
} finally {
|
||||
setIsDecrypting(false);
|
||||
}
|
||||
@@ -128,14 +201,19 @@ export default function Reader() {
|
||||
}, [public_id, sharingKey, masterKey]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isDecrypting && decryptedCanvasData && canvasRef.current) {
|
||||
if (
|
||||
!isDecrypting &&
|
||||
revealState === "revealed" &&
|
||||
decryptedCanvasData &&
|
||||
canvasRef.current
|
||||
) {
|
||||
canvasRef.current.loadData(decryptedCanvasData);
|
||||
}
|
||||
}, [isDecrypting, decryptedCanvasData]);
|
||||
}, [isDecrypting, revealState, decryptedCanvasData]);
|
||||
|
||||
if (isDecrypting) {
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center bg-base-100 font-serif">
|
||||
<div className="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="text-center space-y-6 z-10">
|
||||
<Logo />
|
||||
@@ -152,33 +230,45 @@ export default function Reader() {
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center bg-base-100 px-6 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="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">
|
||||
<div className="space-y-2">
|
||||
<h2 className="text-error font-display text-xl">
|
||||
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>
|
||||
<LogModal
|
||||
isOpen={!!error}
|
||||
onClose={() => (window.location.href = "/")}
|
||||
message={error.message}
|
||||
log={error.log}
|
||||
status="ERROR"
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<section className="min-h-screen w-full bg-base-100 px-4 py-8 md:py-16 font-serif relative overflow-hidden">
|
||||
{/* Background Ambience */}
|
||||
<section className="min-h-fit w-full bg-base-100 px-4 py-8 md:py-16 font-serif relative overflow-hidden">
|
||||
<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
|
||||
isOpen={!!warning}
|
||||
@@ -188,34 +278,12 @@ export default function Reader() {
|
||||
status="WARN"
|
||||
/>
|
||||
|
||||
<div className="max-w-4xl mx-auto space-y-8 relative z-10">
|
||||
{/* 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 */}
|
||||
{revealState === "revealed" && (
|
||||
<div className="max-w-4xl m-8 mx-auto space-y-8 h-full relative inset-0 z-100">
|
||||
<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="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="bg-paper shadow-warm rounded-sm overflow-hidden animate-[opacity_1s_ease-in-out_1]">
|
||||
<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 />
|
||||
</div>
|
||||
@@ -227,10 +295,49 @@ export default function Reader() {
|
||||
)}
|
||||
</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">
|
||||
<p className="text-[9px] uppercase tracking-[0.5em]">
|
||||
<p className="text-xs font-sans uppercase tracking-[0.5em]">
|
||||
Read. Remember. Release.
|
||||
</p>
|
||||
</footer>
|
||||
|
||||
@@ -55,7 +55,7 @@ export default function Register() {
|
||||
email: data.email,
|
||||
password: authHash,
|
||||
});
|
||||
navigate(ROUTES.VERIFY_EMAIL);
|
||||
navigate(ROUTES.VERIFY_EMAIL, { replace: true });
|
||||
} catch (err) {
|
||||
let message = "Registration failed. Please try again.";
|
||||
if (axios.isAxiosError(err)) {
|
||||
@@ -70,7 +70,7 @@ export default function Register() {
|
||||
return (
|
||||
<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">
|
||||
<h1 className="card-title font-display text-2xl font-bold justify-center text-primary tracking-tight">
|
||||
<h1 className="card-title font-display text-2xl justify-center text-primary/80 tracking-tight">
|
||||
Create a <Logo /> Account
|
||||
</h1>
|
||||
|
||||
@@ -81,7 +81,7 @@ export default function Register() {
|
||||
)}
|
||||
|
||||
<FormField
|
||||
label="Full Name"
|
||||
label="Pen Name"
|
||||
placeholder="Word Smith"
|
||||
registration={register("full_name")}
|
||||
error={errors.full_name?.message}
|
||||
@@ -90,7 +90,7 @@ export default function Register() {
|
||||
<FormField
|
||||
label="Email"
|
||||
type="email"
|
||||
placeholder="you@email.com"
|
||||
placeholder="f.kafka@email.com"
|
||||
registration={register("email")}
|
||||
error={errors.email?.message}
|
||||
/>
|
||||
|
||||
@@ -169,7 +169,7 @@ describe("encryptImage / decryptImage", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("Sharing Key Decryption (TDD)", () => {
|
||||
describe("Sharing Key Decryption", () => {
|
||||
let masterKey: CryptoKey;
|
||||
beforeEach(async () => {
|
||||
const bundle = await CryptoUtils.deriveKeyBundle("password", "salt");
|
||||
@@ -190,3 +190,44 @@ describe("Sharing Key Decryption (TDD)", () => {
|
||||
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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -309,4 +309,24 @@ export class CryptoUtils {
|
||||
);
|
||||
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,5 +1,5 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { formatRelativeDate } from "./dateFormat";
|
||||
import { formatDate, formatRelativeDate } from "./dateFormat";
|
||||
|
||||
describe("formatRelativeDate", () => {
|
||||
beforeEach(() => {
|
||||
@@ -35,3 +35,11 @@ describe("formatRelativeDate", () => {
|
||||
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");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -7,6 +7,10 @@ const dateTimeFormatter = new Intl.DateTimeFormat("en-US", {
|
||||
timeStyle: "short",
|
||||
});
|
||||
|
||||
const dateFormatter = new Intl.DateTimeFormat("en-US", {
|
||||
dateStyle: "long",
|
||||
});
|
||||
|
||||
const rtf = new Intl.RelativeTimeFormat("en-US", {
|
||||
numeric: "auto",
|
||||
});
|
||||
@@ -16,6 +20,7 @@ function startOfDay(d: Date) {
|
||||
}
|
||||
|
||||
export function formatRelativeDate(input: Date | string | number) {
|
||||
if (!input) return "";
|
||||
const date = new Date(input);
|
||||
const now = new Date();
|
||||
|
||||
@@ -32,3 +37,27 @@ export function formatRelativeDate(input: Date | string | number) {
|
||||
|
||||
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);
|
||||
}
|
||||
|
||||
@@ -152,9 +152,10 @@ describe("letterLogic image helpers", () => {
|
||||
crypto,
|
||||
);
|
||||
|
||||
expect(api.get).toHaveBeenCalledWith("https://remote/photo.png.bin", {
|
||||
responseType: "blob",
|
||||
});
|
||||
expect(api.get).toHaveBeenCalledWith(
|
||||
"https://remote/photo.png.bin",
|
||||
expect.objectContaining({ responseType: "blob" }),
|
||||
);
|
||||
expect(CryptoUtils.prototype.decryptImage).toHaveBeenCalledWith(
|
||||
expect.any(Blob),
|
||||
"wrapped-dek",
|
||||
@@ -238,9 +239,10 @@ describe("letterLogic image helpers", () => {
|
||||
crypto,
|
||||
);
|
||||
|
||||
expect(api.get).toHaveBeenCalledWith("https://remote/photo.png.bin", {
|
||||
responseType: "blob",
|
||||
});
|
||||
expect(api.get).toHaveBeenCalledWith(
|
||||
"https://remote/photo.png.bin",
|
||||
expect.objectContaining({ responseType: "blob" }),
|
||||
);
|
||||
expect(
|
||||
CryptoUtils.prototype.decryptImageWithSharingKey,
|
||||
).toHaveBeenCalledWith(expect.any(Blob), "raw-sharing-key");
|
||||
|
||||
@@ -2,7 +2,7 @@ import { api } from "../api/apiClient";
|
||||
import type {
|
||||
CanvasJSON,
|
||||
FabricImageJSON,
|
||||
} from "../components/ui/ComposeCanvas";
|
||||
} from "../components/editor/ComposeCanvas";
|
||||
import type { CryptoUtils } from "./crypto";
|
||||
import { blobUrlToFile } from "./fileUtils";
|
||||
|
||||
@@ -28,14 +28,18 @@ export async function decryptCanvasImages(
|
||||
remoteImages.map((img) => [img.file_name, img.file]),
|
||||
);
|
||||
|
||||
const decryptionPromises = canvasData.objects.map(async (obj, index) => {
|
||||
const imageDecryptionPromises = canvasData.objects.map(async (obj, index) => {
|
||||
if (obj.type !== "Image") return;
|
||||
const imgObj = obj as FabricImageJSON;
|
||||
const remoteUrl = imageMap.get(imgObj.src);
|
||||
if (!remoteUrl) return;
|
||||
|
||||
try {
|
||||
const res = await api.get(remoteUrl, { responseType: "blob" });
|
||||
// HACK: For S3 Storage fetch and avoiding CORS error
|
||||
const res = await api.get(remoteUrl, {
|
||||
responseType: "blob",
|
||||
withCredentials: false,
|
||||
});
|
||||
const originalSrc = imgObj.src;
|
||||
|
||||
const blobUrl = await cryptoUtils.decryptImage(
|
||||
@@ -56,7 +60,7 @@ export async function decryptCanvasImages(
|
||||
}
|
||||
});
|
||||
|
||||
await Promise.all(decryptionPromises);
|
||||
await Promise.all(imageDecryptionPromises);
|
||||
canvasData.objects = canvasData.objects.filter(Boolean);
|
||||
return { isDecryptionPartialFailure, error };
|
||||
}
|
||||
@@ -66,14 +70,16 @@ export async function decryptCanvasImagesWithSharingKey(
|
||||
remoteImages: { file_name: string; file: string }[],
|
||||
sharingKey: string,
|
||||
cryptoUtils: CryptoUtils,
|
||||
) {
|
||||
if (!canvasData?.objects) return;
|
||||
|
||||
): Promise<{ isDecryptionPartialFailure: boolean; error: string }> {
|
||||
if (!canvasData?.objects)
|
||||
return { isDecryptionPartialFailure: false, error: "" };
|
||||
let isDecryptionPartialFailure = false;
|
||||
let error = "";
|
||||
const imageMap = new Map(
|
||||
remoteImages.map((img) => [img.file_name, img.file]),
|
||||
);
|
||||
|
||||
const decryptionPromises = canvasData.objects.map(async (obj) => {
|
||||
const decryptionPromises = canvasData.objects.map(async (obj, index) => {
|
||||
if (obj.type !== "Image") return;
|
||||
|
||||
const imgObj = obj as FabricImageJSON;
|
||||
@@ -81,17 +87,24 @@ export async function decryptCanvasImagesWithSharingKey(
|
||||
if (!remoteUrl) return;
|
||||
|
||||
try {
|
||||
const res = await api.get(remoteUrl, { responseType: "blob" });
|
||||
const res = await api.get(remoteUrl, {
|
||||
responseType: "blob",
|
||||
withCredentials: false,
|
||||
});
|
||||
imgObj.src = await cryptoUtils.decryptImageWithSharingKey(
|
||||
res.data,
|
||||
sharingKey,
|
||||
);
|
||||
} catch (_error) {
|
||||
// Keep original or handle failure
|
||||
delete canvasData.objects[index];
|
||||
isDecryptionPartialFailure = true;
|
||||
error = _error instanceof Error ? _error.message : "Unknown error";
|
||||
}
|
||||
});
|
||||
|
||||
await Promise.all(decryptionPromises);
|
||||
canvasData.objects = canvasData.objects.filter(Boolean);
|
||||
return { isDecryptionPartialFailure, error };
|
||||
}
|
||||
|
||||
export async function encryptCanvasImages(
|
||||
|
||||
+17
-8
@@ -5,9 +5,22 @@ import react from "@vitejs/plugin-react";
|
||||
import { defineConfig, loadEnv } from "vite";
|
||||
import { getBaseUrl } from "./utils/url-builder";
|
||||
|
||||
// https://vite.dev/config/
|
||||
export default defineConfig(({ 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";
|
||||
let sslCerts: { key: Buffer; cert: Buffer } | undefined;
|
||||
|
||||
@@ -20,17 +33,13 @@ export default defineConfig(({ mode }) => {
|
||||
};
|
||||
}
|
||||
|
||||
const baseApiUrl = getBaseUrl(
|
||||
isSslEnabled,
|
||||
env.BACKEND_DOMAIN,
|
||||
env.BACKEND_PORT,
|
||||
);
|
||||
console.log(baseApiUrl);
|
||||
return {
|
||||
envDir: "../",
|
||||
plugins: [react(), tailwindcss()],
|
||||
define: {
|
||||
"import.meta.env.VITE_API_URL": JSON.stringify(baseApiUrl),
|
||||
"import.meta.env.VITE_API_URL": JSON.stringify(
|
||||
getBaseUrl(isSslEnabled, env.BACKEND_DOMAIN, env.BACKEND_PORT),
|
||||
),
|
||||
},
|
||||
server: {
|
||||
port: Number(env.FRONTEND_PORT),
|
||||
|
||||
@@ -10,7 +10,7 @@ export default defineConfig({
|
||||
VITE_API_URL: "http://piku-server",
|
||||
TZ: "Asia/Kolkata",
|
||||
},
|
||||
include: ["**/*.test.ts"],
|
||||
include: ["**/*.test.ts", "**/*.test.tsx"],
|
||||
environment: "jsdom",
|
||||
globals: true,
|
||||
setupFiles: ["./test/setup.ts"],
|
||||
|
||||
+4
-4
@@ -5,22 +5,22 @@ pre-commit:
|
||||
ruff:
|
||||
root: "backend/"
|
||||
glob: "*.py"
|
||||
run: uv run ruff check --fix {staged_files} && uv run ruff format {staged_files}
|
||||
run: export PATH="$HOME/.local/bin:$PATH" && uv run ruff check --fix {staged_files} && uv run ruff format {staged_files}
|
||||
stage_fixed: true
|
||||
|
||||
django-check:
|
||||
root: "backend/"
|
||||
run: uv run manage.py check
|
||||
run: export PATH="$HOME/.local/bin:$PATH" && uv run manage.py check
|
||||
|
||||
# Frontend: Biome (Linter + Formatter)
|
||||
biome:
|
||||
root: "frontend/"
|
||||
glob: "**/*.{js,ts,jsx,tsx}"
|
||||
run: bunx @biomejs/biome check --write --no-errors-on-unmatched {staged_files}
|
||||
run: export PATH="$HOME/.bun/bin:$PATH" && bunx @biomejs/biome check --write --no-errors-on-unmatched {staged_files}
|
||||
stage_fixed: true
|
||||
|
||||
# Frontend: TypeScript
|
||||
tsc:
|
||||
root: "frontend/"
|
||||
glob: "**/*.ts, **/*.tsx"
|
||||
run: bunx tsc --noEmit --incremental --tsBuildInfoFile --ignoreConfig .tsbuildinfo {staged_files}
|
||||
run: export PATH="$HOME/.bun/bin:$PATH" && bunx tsc --noEmit --incremental --tsBuildInfoFile --ignoreConfig .tsbuildinfo {staged_files}
|
||||
|
||||
@@ -1,180 +0,0 @@
|
||||
[31m[1mWARNING: This is a development server. Do not use it in a production deployment. Use a production WSGI server instead.[0m
|
||||
* Running on https://127.0.0.1:8001
|
||||
[33mPress CTRL+C to quit[0m
|
||||
* 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] "[31m[1mPOST /api/auth/refresh/ HTTP/1.1[0m" 401 -
|
||||
Unauthorized: /api/auth/refresh/
|
||||
127.0.0.1 - - [16/Apr/2026 18:45:15] "[31m[1mPOST /api/auth/refresh/ HTTP/1.1[0m" 401 -
|
||||
Unauthorized: /api/auth/refresh/
|
||||
127.0.0.1 - - [16/Apr/2026 18:45:15] "[31m[1mPOST /api/auth/refresh/ HTTP/1.1[0m" 401 -
|
||||
Unauthorized: /api/auth/refresh/
|
||||
127.0.0.1 - - [16/Apr/2026 18:45:16] "[31m[1mPOST /api/auth/refresh/ HTTP/1.1[0m" 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] "[35m[1mPOST /api/auth/register/ HTTP/1.1[0m" 201 -
|
||||
127.0.0.1 - - [16/Apr/2026 18:45:17] "[35m[1mPOST /api/auth/register/ HTTP/1.1[0m" 201 -
|
||||
127.0.0.1 - - [16/Apr/2026 18:45:17] "[35m[1mPOST /api/auth/register/ HTTP/1.1[0m" 201 -
|
||||
127.0.0.1 - - [16/Apr/2026 18:45:17] "[35m[1mPOST /api/auth/register/ HTTP/1.1[0m" 201 -
|
||||
Unauthorized: /api/auth/refresh/
|
||||
127.0.0.1 - - [16/Apr/2026 18:45:19] "[31m[1mPOST /api/auth/refresh/ HTTP/1.1[0m" 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] "[31m[1mPOST /api/auth/refresh/ HTTP/1.1[0m" 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] "[31m[1mPOST /api/auth/refresh/ HTTP/1.1[0m" 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] "[31m[1mPOST /api/auth/refresh/ HTTP/1.1[0m" 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] "[35m[1mPUT /api/letters/77cbf618-f4e2-4d33-87c2-60e5c93c9711/ HTTP/1.1[0m" 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] "[35m[1mPUT /api/letters/e8f47036-6e57-41f5-b057-8ea712589a73/ HTTP/1.1[0m" 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] "[35m[1mPUT /api/letters/4e2af91a-1651-4bcd-85b5-c469ee4a73e3/ HTTP/1.1[0m" 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] "[31m[1mPOST /api/auth/refresh/ HTTP/1.1[0m" 401 -
|
||||
Unauthorized: /api/auth/refresh/
|
||||
127.0.0.1 - - [16/Apr/2026 18:45:30] "[31m[1mPOST /api/auth/refresh/ HTTP/1.1[0m" 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] "[35m[1mPOST /api/auth/register/ HTTP/1.1[0m" 201 -
|
||||
127.0.0.1 - - [16/Apr/2026 18:45:32] "[35m[1mPOST /api/auth/register/ HTTP/1.1[0m" 201 -
|
||||
Unauthorized: /api/auth/refresh/
|
||||
127.0.0.1 - - [16/Apr/2026 18:45:34] "[31m[1mPOST /api/auth/refresh/ HTTP/1.1[0m" 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] "[31m[1mPOST /api/auth/refresh/ HTTP/1.1[0m" 401 -
|
||||
127.0.0.1 - - [16/Apr/2026 18:45:36] "[35m[1mPOST /api/auth/register/ HTTP/1.1[0m" 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] "[31m[1mPOST /api/auth/refresh/ HTTP/1.1[0m" 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] "[35m[1mPOST /api/auth/register/ HTTP/1.1[0m" 201 -
|
||||
Unauthorized: /api/auth/refresh/
|
||||
127.0.0.1 - - [16/Apr/2026 18:45:37] "[31m[1mPOST /api/auth/refresh/ HTTP/1.1[0m" 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] "[35m[1mPUT /api/letters/351f0e2c-e10f-4851-9836-bc387758dbad/ HTTP/1.1[0m" 201 -
|
||||
Unauthorized: /api/auth/refresh/
|
||||
127.0.0.1 - - [16/Apr/2026 18:45:40] "[31m[1mPOST /api/auth/refresh/ HTTP/1.1[0m" 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] "[31m[1mPOST /api/auth/refresh/ HTTP/1.1[0m" 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] "[35m[1mPUT /api/letters/5dec1a15-1d19-47de-b5a5-21bc1845f90a/ HTTP/1.1[0m" 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] "[35m[1mPUT /api/letters/e95e2a4c-811a-45b1-9bbd-dafdb180a88f/ HTTP/1.1[0m" 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(
|
||||
+41
-15
@@ -1,15 +1,19 @@
|
||||
#!/bin/bash
|
||||
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
|
||||
CONTAINER_BIN=$(command -v podman || command -v docker)
|
||||
CONTAINER_BIN=$(command -v podman || command -v docker || true)
|
||||
COMPOSE_BIN=$(command -v docker-compose || true)
|
||||
if [ -z "$CONTAINER_BIN" ]; then
|
||||
echo "Sorry, you need either podman or docker installed to run this script."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if [ "$CI" = "true" ]; then
|
||||
CONTAINER_BIN=$(command -v docker)
|
||||
CONTAINER_BIN=$(command -v docker || true)
|
||||
fi
|
||||
|
||||
echo "Using $CONTAINER_BIN for container operations..."
|
||||
@@ -26,23 +30,22 @@ else
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# This cleans up containers. Very useful for local e2e to free system resources immediately.
|
||||
# This cleans up django backend process and containers.
|
||||
cleanup() {
|
||||
echo "Cleaning up..."
|
||||
$CONTAINER_BIN rm -f "$DB_NAME" 2>/dev/null || true
|
||||
$CONTAINER_BIN compose -p "piku_e2e" -f "./docker-compose.e2e.yml" down --remove-orphans -v
|
||||
[ -n "$BACKEND_PID" ] && kill "$BACKEND_PID" 2>/dev/null || true
|
||||
}
|
||||
trap cleanup EXIT
|
||||
|
||||
echo "Starting Database and Mail server..."
|
||||
COMPOSE_BIN="$(command -v docker-compose || true)"
|
||||
|
||||
if echo "$CONTAINER_BIN" | grep -q "podman"; then
|
||||
podman compose -f "./docker-compose.e2e.yml" up -d
|
||||
podman compose -p "piku_e2e" -f "./docker-compose.e2e.yml" up -d
|
||||
elif [ -n "$COMPOSE_BIN" ]; then
|
||||
"$COMPOSE_BIN" -f "./docker-compose.e2e.yml" up -d
|
||||
$COMPOSE_BIN -p "piku_e2e" -f "./docker-compose.e2e.yml" up -d
|
||||
else
|
||||
docker compose -f "./docker-compose.e2e.yml" up -d
|
||||
docker compose -p "piku_e2e" -f "./docker-compose.e2e.yml" up -d
|
||||
fi
|
||||
|
||||
# postgress will take some time, so we wait, and no race condition. Also, no point in logging this output
|
||||
@@ -51,15 +54,38 @@ until $CONTAINER_BIN exec "$DB_NAME" pg_isready -U "${DB_USER:-test}" >/dev/null
|
||||
sleep 2
|
||||
done
|
||||
|
||||
export PIKU_ENV_FILE="$ENV_FILE"
|
||||
echo "Starting Backend..."
|
||||
mkdir -p ./tmp/logs
|
||||
(cd backend && uv run manage.py migrate)
|
||||
(cd backend && uv run manage.py serve) > ./tmp/logs/backend.log 2>&1 &
|
||||
(
|
||||
cd backend
|
||||
uv run manage.py migrate
|
||||
)
|
||||
(
|
||||
cd backend
|
||||
exec uv run manage.py serve
|
||||
) > ./tmp/logs/backend.log 2>&1 &
|
||||
BACKEND_PID=$!
|
||||
|
||||
if [ "$CI" = "true" ]; then
|
||||
cd frontend && bun run test:e2e "$@"
|
||||
else
|
||||
# Because playwright decided not to support Fedora :)
|
||||
cd frontend && distrobox-enter --name ubuntu-24.04 -- bun run test:e2e "$@"
|
||||
TEST_COMMAND="test:e2e"
|
||||
MODE="local"
|
||||
|
||||
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
|
||||
(
|
||||
cd frontend
|
||||
$NODE_BIN run $TEST_COMMAND
|
||||
)
|
||||
fi
|
||||
|
||||
Executable
+77
@@ -0,0 +1,77 @@
|
||||
#!/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
|
||||
+44
-3
@@ -1,4 +1,45 @@
|
||||
#!/bin/bash
|
||||
(podman compose up -d) &
|
||||
(cd backend && uv run manage.py serve) &
|
||||
(cd frontend && bun run dev)
|
||||
|
||||
# Change this if you're using docker or docker-compose
|
||||
CONTAINER_BIN="podman"
|
||||
|
||||
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
|
||||
|
||||
Reference in New Issue
Block a user