This commit is contained in:
2026-06-17 00:21:00 +02:00
commit 408e48c568
41 changed files with 6617 additions and 0 deletions
+29
View File
@@ -0,0 +1,29 @@
# ── Postgres ────────────────────────────────────────────────
POSTGRES_USER=shoplist
POSTGRES_PASSWORD=change_me_in_prod
POSTGRES_DB=shoplist
# ── Backend ─────────────────────────────────────────────────
# DATABASE_URL is built from the values above inside docker-compose.
DATABASE_URL=postgres://shoplist:change_me_in_prod@localhost:5432/shoplist
BACKEND_HOST=0.0.0.0
BACKEND_PORT=8080
# 64+ hex chars. Generate: openssl rand -hex 32
SESSION_SECRET=please_generate_a_long_random_secret_at_least_64_chars_xxxxxxxxxx
# Public URL of the frontend, used to build verification/reset links.
PUBLIC_APP_URL=http://localhost:5173
# Comma-separated allowed CORS origins.
CORS_ORIGINS=http://localhost:5173
# ── SMTP (mail notifications + verification) ────────────────
SMTP_HOST=smtp.example.com
SMTP_PORT=587
SMTP_USERNAME=postmaster@example.com
SMTP_PASSWORD=change_me
SMTP_FROM="Shopping List <no-reply@example.com>"
# starttls | tls | none (none = dev only)
SMTP_SECURITY=starttls
# ── Frontend ────────────────────────────────────────────────
# Where the SvelteKit app reaches the backend (server-side).
PUBLIC_API_BASE=http://localhost:8080
+18
View File
@@ -0,0 +1,18 @@
# Rust
/backend/target
**/*.rs.bk
# Node / SvelteKit
node_modules
/frontend/.svelte-kit
/frontend/build
/frontend/.vercel
# Env / secrets
.env
*.local
# Editor / OS
.DS_Store
*.swp
.idea
+55
View File
@@ -0,0 +1,55 @@
# shopping-list
Self-hosted, topic-based shopping lists with price-drop notifications.
Track things you want to buy (clothes, gear, whatever), paste product URLs,
get mailed when a deal appears.
## Stack
- **Backend** — Rust, [Axum](https://github.com/tokio-rs/axum), SQLx + Postgres,
argon2 password hashing, server-side sessions (tower-sessions), lettre SMTP.
- **Frontend** — SvelteKit + TailwindCSS, grunge/breakcore theme.
- **Infra** — docker-compose (Postgres + backend + frontend).
## Status
Phased build. Current: **Phase 1 — auth foundation**.
| Phase | Scope |
|-------|-------|
| 1 | Accounts, email verification, login/logout, password reset, per-user settings |
| 2 | Lists (topics) + items CRUD |
| 3 | Tracked URLs: paste → periodic refetch → price parse + history |
| 4 | Notifications: SMTP price-drop alerts |
| 5 | Design polish |
## Quick start (dev)
```sh
cp .env.example .env
# edit .env — set SESSION_SECRET (openssl rand -hex 32) and SMTP creds
# Postgres only, run backend/frontend natively while developing:
docker compose up -d db
# backend
cd backend && cargo run
# frontend (separate shell)
cd frontend && pnpm install && pnpm dev
```
Full stack in containers:
```sh
docker compose up --build
```
App: http://localhost:5173 · API: http://localhost:8080
## Notes
- Deal tracking uses **user-pasted product URLs** refetched on a schedule —
no hardcoded retailer scrapers. Region default: DE/EU, EUR.
- SMTP is required for email verification + notifications. Use a real provider
or a dev catcher like [Mailpit](https://github.com/axllent/mailpit).
+2907
View File
File diff suppressed because it is too large Load Diff
+37
View File
@@ -0,0 +1,37 @@
[package]
name = "shoplist-backend"
version = "0.1.0"
edition = "2021"
[dependencies]
axum = { version = "0.8", features = ["macros"] }
tokio = { version = "1", features = ["full"] }
tower = "0.5"
tower-http = { version = "0.6", features = ["cors", "trace"] }
tower-sessions = "0.14"
tower-sessions-sqlx-store = { version = "0.15", features = ["postgres"] }
sqlx = { version = "0.8", default-features = false, features = [
"runtime-tokio", "tls-rustls", "postgres", "uuid", "time", "macros",
] }
serde = { version = "1", features = ["derive"] }
serde_json = "1"
argon2 = "0.5"
rand = "0.8"
sha2 = "0.10"
hex = "0.4"
lettre = { version = "0.11", default-features = false, features = [
"tokio1-rustls-tls", "smtp-transport", "builder", "hostname",
] }
validator = { version = "0.19", features = ["derive"] }
thiserror = "2"
anyhow = "1"
tracing = "0.1"
tracing-subscriber = { version = "0.3", features = ["env-filter"] }
time = { version = "0.3", features = ["serde"] }
uuid = { version = "1", features = ["v4", "serde"] }
dotenvy = "0.15"
+58
View File
@@ -0,0 +1,58 @@
-- Phase 1: auth foundation
-- Requires Postgres. citext for case-insensitive email; pgcrypto for gen_random_uuid.
CREATE EXTENSION IF NOT EXISTS citext;
CREATE EXTENSION IF NOT EXISTS pgcrypto;
CREATE TABLE users (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
email CITEXT NOT NULL UNIQUE,
password_hash TEXT NOT NULL,
display_name TEXT,
email_verified BOOLEAN NOT NULL DEFAULT FALSE,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
-- One row per user; created on registration.
CREATE TABLE user_settings (
user_id UUID PRIMARY KEY REFERENCES users(id) ON DELETE CASCADE,
locale TEXT NOT NULL DEFAULT 'de', -- 'de' | 'en'
currency TEXT NOT NULL DEFAULT 'EUR',
theme TEXT NOT NULL DEFAULT 'breakcore',
notify_email BOOLEAN NOT NULL DEFAULT TRUE,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
-- Email verification + password reset tokens.
-- Only the SHA-256 hash of the token is stored; raw token lives in the emailed link.
CREATE TYPE token_purpose AS ENUM ('verify_email', 'password_reset');
CREATE TABLE auth_tokens (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
token_hash BYTEA NOT NULL UNIQUE,
purpose token_purpose NOT NULL,
expires_at TIMESTAMPTZ NOT NULL,
consumed_at TIMESTAMPTZ,
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE INDEX idx_auth_tokens_user ON auth_tokens(user_id);
CREATE INDEX idx_auth_tokens_expires ON auth_tokens(expires_at);
-- Touch updated_at on row changes.
CREATE OR REPLACE FUNCTION set_updated_at() RETURNS trigger AS $$
BEGIN
NEW.updated_at = now();
RETURN NEW;
END;
$$ LANGUAGE plpgsql;
CREATE TRIGGER trg_users_updated
BEFORE UPDATE ON users
FOR EACH ROW EXECUTE FUNCTION set_updated_at();
CREATE TRIGGER trg_user_settings_updated
BEFORE UPDATE ON user_settings
FOR EACH ROW EXECUTE FUNCTION set_updated_at();
+4
View File
@@ -0,0 +1,4 @@
pub mod password;
pub mod routes;
pub mod session;
pub mod tokens;
+23
View File
@@ -0,0 +1,23 @@
use argon2::password_hash::{rand_core::OsRng, PasswordHash, PasswordHasher, PasswordVerifier, SaltString};
use argon2::Argon2;
use crate::error::{AppError, AppResult};
/// Hash a plaintext password with Argon2id + random salt (PHC string).
pub fn hash_password(plain: &str) -> AppResult<String> {
let salt = SaltString::generate(&mut OsRng);
let hash = Argon2::default()
.hash_password(plain.as_bytes(), &salt)
.map_err(|e| AppError::Internal(anyhow::anyhow!("password hash failed: {e}")))?;
Ok(hash.to_string())
}
/// Verify a plaintext password against a stored PHC hash.
/// Returns Ok(false) on mismatch, error only on malformed stored hash.
pub fn verify_password(plain: &str, stored_hash: &str) -> AppResult<bool> {
let parsed = PasswordHash::new(stored_hash)
.map_err(|e| AppError::Internal(anyhow::anyhow!("stored hash malformed: {e}")))?;
Ok(Argon2::default()
.verify_password(plain.as_bytes(), &parsed)
.is_ok())
}
+265
View File
@@ -0,0 +1,265 @@
use axum::extract::State;
use axum::http::StatusCode;
use axum::routing::post;
use axum::{Json, Router};
use serde::{Deserialize, Serialize};
use time::Duration;
use tower_sessions::Session;
use validator::Validate;
use super::password::{hash_password, verify_password};
use super::session::{set_user, AuthUser};
use super::tokens::{self, TokenPurpose};
use crate::error::{AppError, AppResult};
use crate::models::{User, UserPublic, UserSettings};
use crate::state::AppState;
pub fn router() -> Router<AppState> {
Router::new()
.route("/register", post(register))
.route("/login", post(login))
.route("/logout", post(logout))
.route("/verify", post(verify_email))
.route("/resend-verification", post(resend_verification))
.route("/request-password-reset", post(request_password_reset))
.route("/reset-password", post(reset_password))
.route("/me", axum::routing::get(me))
}
// ── Request payloads ────────────────────────────────────────
#[derive(Debug, Deserialize, Validate)]
struct RegisterReq {
#[validate(email(message = "invalid email"))]
email: String,
#[validate(length(min = 10, max = 256, message = "password must be 10-256 chars"))]
password: String,
#[validate(length(max = 80, message = "display name too long"))]
display_name: Option<String>,
}
#[derive(Debug, Deserialize)]
struct LoginReq {
email: String,
password: String,
}
#[derive(Debug, Deserialize)]
struct TokenReq {
token: String,
}
#[derive(Debug, Deserialize, Validate)]
struct EmailReq {
#[validate(email)]
email: String,
}
#[derive(Debug, Deserialize, Validate)]
struct ResetReq {
token: String,
#[validate(length(min = 10, max = 256, message = "password must be 10-256 chars"))]
new_password: String,
}
// ── Response payloads ───────────────────────────────────────
#[derive(Debug, Serialize)]
struct MeResp {
user: UserPublic,
settings: UserSettings,
}
fn validate<T: Validate>(payload: &T) -> AppResult<()> {
payload
.validate()
.map_err(|e| AppError::Validation(e.to_string()))
}
/// Detect a Postgres unique-violation (SQLSTATE 23505).
fn is_unique_violation(e: &sqlx::Error) -> bool {
matches!(e, sqlx::Error::Database(db) if db.code().as_deref() == Some("23505"))
}
// ── Handlers ────────────────────────────────────────────────
async fn register(
State(state): State<AppState>,
session: Session,
Json(req): Json<RegisterReq>,
) -> AppResult<(StatusCode, Json<UserPublic>)> {
validate(&req)?;
let hash = hash_password(&req.password)?;
let display = req.display_name.as_deref().map(str::trim).filter(|s| !s.is_empty());
let user = sqlx::query_as::<_, User>(
"INSERT INTO users (email, password_hash, display_name)
VALUES ($1, $2, $3) RETURNING *",
)
.bind(&req.email)
.bind(&hash)
.bind(display)
.fetch_one(&state.pool)
.await
.map_err(|e| {
if is_unique_violation(&e) {
AppError::Conflict("email already registered".into())
} else {
e.into()
}
})?;
// Default settings row.
sqlx::query("INSERT INTO user_settings (user_id) VALUES ($1)")
.bind(user.id)
.execute(&state.pool)
.await?;
// Email delivery failure must not abort signup; user can resend later.
if let Err(e) = send_verification_email(&state, &user).await {
tracing::warn!(user_id = %user.id, error = %e, "failed to send verification email");
}
// Log the new user in immediately; features can gate on email_verified.
set_user(&session, user.id).await?;
Ok((StatusCode::CREATED, Json(UserPublic::from(&user))))
}
async fn login(
State(state): State<AppState>,
session: Session,
Json(req): Json<LoginReq>,
) -> AppResult<Json<UserPublic>> {
let user = sqlx::query_as::<_, User>("SELECT * FROM users WHERE email = $1")
.bind(&req.email)
.fetch_optional(&state.pool)
.await?;
// Constant-ish: only reveal a single generic error for unknown user or bad password.
let user = match user {
Some(u) if verify_password(&req.password, &u.password_hash)? => u,
_ => return Err(AppError::BadRequest("invalid email or password".into())),
};
set_user(&session, user.id).await?;
Ok(Json(UserPublic::from(&user)))
}
async fn logout(session: Session) -> AppResult<StatusCode> {
session
.flush()
.await
.map_err(|e| AppError::Internal(anyhow::anyhow!("session flush: {e}")))?;
Ok(StatusCode::NO_CONTENT)
}
async fn verify_email(
State(state): State<AppState>,
Json(req): Json<TokenReq>,
) -> AppResult<StatusCode> {
let user_id = tokens::consume(&state.pool, &req.token, TokenPurpose::VerifyEmail).await?;
sqlx::query("UPDATE users SET email_verified = TRUE WHERE id = $1")
.bind(user_id)
.execute(&state.pool)
.await?;
Ok(StatusCode::NO_CONTENT)
}
async fn resend_verification(
State(state): State<AppState>,
AuthUser(user): AuthUser,
) -> AppResult<StatusCode> {
if user.email_verified {
return Err(AppError::BadRequest("email already verified".into()));
}
send_verification_email(&state, &user).await?;
Ok(StatusCode::NO_CONTENT)
}
async fn request_password_reset(
State(state): State<AppState>,
Json(req): Json<EmailReq>,
) -> AppResult<StatusCode> {
validate(&req)?;
// Always return 204 regardless of account existence (no enumeration).
if let Some(user) = sqlx::query_as::<_, User>("SELECT * FROM users WHERE email = $1")
.bind(&req.email)
.fetch_optional(&state.pool)
.await?
{
let token =
tokens::create(&state.pool, user.id, TokenPurpose::PasswordReset, Duration::hours(1))
.await?;
let link = format!("{}/reset?token={}", state.config.public_app_url, token);
let _ = state
.mailer
.send(
&user.email,
"Reset your password",
&format!("Reset your password:\n{link}\n\nThis link expires in 1 hour."),
&format!(
"<p>Reset your password:</p><p><a href=\"{link}\">{link}</a></p>\
<p>This link expires in 1 hour.</p>"
),
)
.await;
}
Ok(StatusCode::NO_CONTENT)
}
async fn reset_password(
State(state): State<AppState>,
Json(req): Json<ResetReq>,
) -> AppResult<StatusCode> {
validate(&req)?;
let user_id = tokens::consume(&state.pool, &req.token, TokenPurpose::PasswordReset).await?;
let hash = hash_password(&req.new_password)?;
sqlx::query("UPDATE users SET password_hash = $1 WHERE id = $2")
.bind(&hash)
.bind(user_id)
.execute(&state.pool)
.await?;
Ok(StatusCode::NO_CONTENT)
}
async fn me(
State(state): State<AppState>,
AuthUser(user): AuthUser,
) -> AppResult<Json<MeResp>> {
let settings = sqlx::query_as::<_, UserSettings>(
"SELECT user_id, locale, currency, theme, notify_email
FROM user_settings WHERE user_id = $1",
)
.bind(user.id)
.fetch_one(&state.pool)
.await?;
Ok(Json(MeResp {
user: UserPublic::from(&user),
settings,
}))
}
// ── Helpers ─────────────────────────────────────────────────
async fn send_verification_email(state: &AppState, user: &User) -> AppResult<()> {
let token =
tokens::create(&state.pool, user.id, TokenPurpose::VerifyEmail, Duration::hours(24)).await?;
let link = format!("{}/verify?token={}", state.config.public_app_url, token);
state
.mailer
.send(
&user.email,
"Verify your email",
&format!("Welcome! Verify your email:\n{link}\n\nThis link expires in 24 hours."),
&format!(
"<p>Welcome! Verify your email:</p><p><a href=\"{link}\">{link}</a></p>\
<p>This link expires in 24 hours.</p>"
),
)
.await?;
Ok(())
}
+65
View File
@@ -0,0 +1,65 @@
use axum::extract::FromRequestParts;
use axum::http::request::Parts;
use sqlx::PgPool;
use tower_sessions::Session;
use uuid::Uuid;
use crate::error::{AppError, AppResult};
use crate::models::User;
use crate::state::AppState;
/// Session key holding the authenticated user's id.
pub const USER_ID_KEY: &str = "user_id";
/// Store the user id in the session (call after a successful login/verify).
pub async fn set_user(session: &Session, user_id: Uuid) -> AppResult<()> {
// Rotate the session id on privilege change to prevent fixation.
session
.cycle_id()
.await
.map_err(|e| AppError::Internal(anyhow::anyhow!("session cycle: {e}")))?;
session
.insert(USER_ID_KEY, user_id)
.await
.map_err(|e| AppError::Internal(anyhow::anyhow!("session insert: {e}")))?;
Ok(())
}
/// Load a user by id, if present.
pub async fn load_user(pool: &PgPool, id: Uuid) -> AppResult<Option<User>> {
let user = sqlx::query_as::<_, User>("SELECT * FROM users WHERE id = $1")
.bind(id)
.fetch_optional(pool)
.await?;
Ok(user)
}
/// Extractor that requires an authenticated user; rejects with 401 otherwise.
pub struct AuthUser(pub User);
impl FromRequestParts<AppState> for AuthUser {
type Rejection = AppError;
async fn from_request_parts(
parts: &mut Parts,
state: &AppState,
) -> Result<Self, Self::Rejection> {
let session = parts
.extensions
.get::<Session>()
.cloned()
.ok_or(AppError::Unauthorized)?;
let id = session
.get::<Uuid>(USER_ID_KEY)
.await
.map_err(|e| AppError::Internal(anyhow::anyhow!("session read: {e}")))?
.ok_or(AppError::Unauthorized)?;
let user = load_user(&state.pool, id)
.await?
.ok_or(AppError::Unauthorized)?;
Ok(AuthUser(user))
}
}
+78
View File
@@ -0,0 +1,78 @@
use rand::RngCore;
use sha2::{Digest, Sha256};
use sqlx::PgPool;
use time::{Duration, OffsetDateTime};
use uuid::Uuid;
use crate::error::{AppError, AppResult};
/// Purpose of a one-time auth token. Maps to the Postgres `token_purpose` enum.
#[derive(Debug, Clone, Copy, PartialEq, Eq, sqlx::Type)]
#[sqlx(type_name = "token_purpose", rename_all = "snake_case")]
pub enum TokenPurpose {
VerifyEmail,
PasswordReset,
}
/// Generate a URL-safe random token (raw, only ever emailed to the user).
fn generate_raw() -> String {
let mut bytes = [0u8; 32];
rand::thread_rng().fill_bytes(&mut bytes);
hex::encode(bytes)
}
/// SHA-256 of the raw token; this is what we persist.
fn hash_token(raw: &str) -> Vec<u8> {
let mut hasher = Sha256::new();
hasher.update(raw.as_bytes());
hasher.finalize().to_vec()
}
/// Create and store a token, returning the raw value to embed in an email link.
pub async fn create(
pool: &PgPool,
user_id: Uuid,
purpose: TokenPurpose,
ttl: Duration,
) -> AppResult<String> {
let raw = generate_raw();
let hash = hash_token(&raw);
let expires_at = OffsetDateTime::now_utc() + ttl;
sqlx::query(
"INSERT INTO auth_tokens (user_id, token_hash, purpose, expires_at)
VALUES ($1, $2, $3, $4)",
)
.bind(user_id)
.bind(hash)
.bind(purpose)
.bind(expires_at)
.execute(pool)
.await?;
Ok(raw)
}
/// Consume a token: validate hash+purpose, ensure unexpired and unconsumed,
/// mark consumed, and return the owning user id. Atomic via single UPDATE.
pub async fn consume(pool: &PgPool, raw: &str, purpose: TokenPurpose) -> AppResult<Uuid> {
let hash = hash_token(raw);
let user_id: Option<Uuid> = sqlx::query_scalar(
"UPDATE auth_tokens
SET consumed_at = now()
WHERE token_hash = $1
AND purpose = $2
AND consumed_at IS NULL
AND expires_at > now()
RETURNING user_id",
)
.bind(hash)
.bind(purpose)
.fetch_optional(pool)
.await?;
user_id.ok_or(AppError::BadRequest(
"invalid or expired token".to_string(),
))
}
+79
View File
@@ -0,0 +1,79 @@
use std::env;
/// Runtime configuration, loaded from environment variables.
#[derive(Clone, Debug)]
pub struct Config {
pub database_url: String,
pub host: String,
pub port: u16,
pub session_secret: String,
pub public_app_url: String,
pub cors_origins: Vec<String>,
pub smtp: SmtpConfig,
}
#[derive(Clone, Debug)]
pub struct SmtpConfig {
pub host: String,
pub port: u16,
pub username: String,
pub password: String,
pub from: String,
pub security: SmtpSecurity,
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum SmtpSecurity {
StartTls,
Tls,
None,
}
impl Config {
pub fn from_env() -> anyhow::Result<Self> {
// Load .env if present; ignore if missing (e.g. in containers).
let _ = dotenvy::dotenv();
let session_secret = req("SESSION_SECRET")?;
if session_secret.len() < 32 {
anyhow::bail!("SESSION_SECRET must be at least 32 chars");
}
let cors_origins = opt("CORS_ORIGINS", "http://localhost:5173")
.split(',')
.map(|s| s.trim().to_string())
.filter(|s| !s.is_empty())
.collect();
let security = match opt("SMTP_SECURITY", "starttls").to_lowercase().as_str() {
"tls" => SmtpSecurity::Tls,
"none" => SmtpSecurity::None,
_ => SmtpSecurity::StartTls,
};
Ok(Config {
database_url: req("DATABASE_URL")?,
host: opt("BACKEND_HOST", "0.0.0.0"),
port: opt("BACKEND_PORT", "8080").parse()?,
session_secret,
public_app_url: opt("PUBLIC_APP_URL", "http://localhost:5173"),
cors_origins,
smtp: SmtpConfig {
host: opt("SMTP_HOST", "localhost"),
port: opt("SMTP_PORT", "587").parse()?,
username: opt("SMTP_USERNAME", ""),
password: opt("SMTP_PASSWORD", ""),
from: opt("SMTP_FROM", "Shopping List <no-reply@localhost>"),
security,
},
})
}
}
fn req(key: &str) -> anyhow::Result<String> {
env::var(key).map_err(|_| anyhow::anyhow!("missing required env var: {key}"))
}
fn opt(key: &str, default: &str) -> String {
env::var(key).unwrap_or_else(|_| default.to_string())
}
+15
View File
@@ -0,0 +1,15 @@
use sqlx::postgres::{PgPool, PgPoolOptions};
use std::time::Duration;
/// Build the Postgres pool and run migrations.
pub async fn connect(database_url: &str) -> anyhow::Result<PgPool> {
let pool = PgPoolOptions::new()
.max_connections(10)
.acquire_timeout(Duration::from_secs(5))
.connect(database_url)
.await?;
sqlx::migrate!("./migrations").run(&pool).await?;
Ok(pool)
}
+70
View File
@@ -0,0 +1,70 @@
use axum::http::StatusCode;
use axum::response::{IntoResponse, Response};
use axum::Json;
use serde_json::json;
/// Application-wide error type. Maps to an HTTP status + JSON body `{ "error": ... }`.
#[derive(Debug, thiserror::Error)]
pub enum AppError {
#[error("{0}")]
BadRequest(String),
#[error("{0}")]
Validation(String),
#[error("authentication required")]
Unauthorized,
#[error("forbidden")]
Forbidden,
#[error("not found")]
NotFound,
#[error("{0}")]
Conflict(String),
/// Anything unexpected. Logged in full; client sees a generic message.
#[error("internal error")]
Internal(#[source] anyhow::Error),
}
impl AppError {
fn status(&self) -> StatusCode {
match self {
AppError::BadRequest(_) => StatusCode::BAD_REQUEST,
AppError::Validation(_) => StatusCode::UNPROCESSABLE_ENTITY,
AppError::Unauthorized => StatusCode::UNAUTHORIZED,
AppError::Forbidden => StatusCode::FORBIDDEN,
AppError::NotFound => StatusCode::NOT_FOUND,
AppError::Conflict(_) => StatusCode::CONFLICT,
AppError::Internal(_) => StatusCode::INTERNAL_SERVER_ERROR,
}
}
}
impl IntoResponse for AppError {
fn into_response(self) -> Response {
if let AppError::Internal(ref e) = self {
tracing::error!(error = ?e, "internal error");
}
let status = self.status();
let body = Json(json!({ "error": self.to_string() }));
(status, body).into_response()
}
}
pub type AppResult<T> = Result<T, AppError>;
// Convert common error sources into a 500 by default.
impl From<sqlx::Error> for AppError {
fn from(e: sqlx::Error) -> Self {
AppError::Internal(e.into())
}
}
impl From<anyhow::Error> for AppError {
fn from(e: anyhow::Error) -> Self {
AppError::Internal(e)
}
}
+77
View File
@@ -0,0 +1,77 @@
use lettre::message::{header::ContentType, Mailbox, MultiPart, SinglePart};
use lettre::transport::smtp::authentication::Credentials;
use lettre::{AsyncSmtpTransport, AsyncTransport, Message, Tokio1Executor};
use crate::config::{SmtpConfig, SmtpSecurity};
use crate::error::{AppError, AppResult};
/// Wraps an async SMTP transport plus the configured `From` address.
#[derive(Clone)]
pub struct Mailer {
transport: AsyncSmtpTransport<Tokio1Executor>,
from: Mailbox,
}
impl Mailer {
pub fn from_config(cfg: &SmtpConfig) -> anyhow::Result<Self> {
let mut builder = match cfg.security {
SmtpSecurity::Tls => AsyncSmtpTransport::<Tokio1Executor>::relay(&cfg.host)?,
SmtpSecurity::StartTls => {
AsyncSmtpTransport::<Tokio1Executor>::starttls_relay(&cfg.host)?
}
SmtpSecurity::None => {
AsyncSmtpTransport::<Tokio1Executor>::builder_dangerous(&cfg.host)
}
}
.port(cfg.port);
if !cfg.username.is_empty() {
builder = builder.credentials(Credentials::new(
cfg.username.clone(),
cfg.password.clone(),
));
}
let from: Mailbox = cfg
.from
.parse()
.map_err(|e| anyhow::anyhow!("invalid SMTP_FROM: {e}"))?;
Ok(Mailer {
transport: builder.build(),
from,
})
}
/// Send a multipart (plain + HTML) email.
pub async fn send(&self, to: &str, subject: &str, text: &str, html: &str) -> AppResult<()> {
let to: Mailbox = to
.parse()
.map_err(|_| AppError::BadRequest("invalid recipient address".into()))?;
let msg = Message::builder()
.from(self.from.clone())
.to(to)
.subject(subject)
.multipart(
MultiPart::alternative()
.singlepart(
SinglePart::builder()
.header(ContentType::TEXT_PLAIN)
.body(text.to_string()),
)
.singlepart(
SinglePart::builder()
.header(ContentType::TEXT_HTML)
.body(html.to_string()),
),
)
.map_err(|e| AppError::Internal(anyhow::anyhow!("build email: {e}")))?;
self.transport
.send(msg)
.await
.map_err(|e| AppError::Internal(anyhow::anyhow!("send email: {e}")))?;
Ok(())
}
}
+86
View File
@@ -0,0 +1,86 @@
mod auth;
mod config;
mod db;
mod error;
mod mail;
mod models;
mod routes;
mod state;
use std::sync::Arc;
use axum::http::{header, HeaderValue, Method};
use axum::Router;
use time::Duration;
use tower_http::cors::{AllowOrigin, CorsLayer};
use tower_http::trace::TraceLayer;
use tower_sessions::{Expiry, SessionManagerLayer};
use tower_sessions_sqlx_store::PostgresStore;
use tracing_subscriber::{layer::SubscriberExt, util::SubscriberInitExt, EnvFilter};
use config::Config;
use mail::Mailer;
use state::AppState;
#[tokio::main]
async fn main() -> anyhow::Result<()> {
tracing_subscriber::registry()
.with(EnvFilter::try_from_default_env().unwrap_or_else(|_| "info,sqlx=warn".into()))
.with(tracing_subscriber::fmt::layer())
.init();
let config = Config::from_env()?;
tracing::info!(port = config.port, "starting shoplist-backend");
let pool = db::connect(&config.database_url).await?;
let mailer = Mailer::from_config(&config.smtp)?;
// Session store (separate table set, managed by the store's own migrations).
let session_store = PostgresStore::new(pool.clone());
session_store.migrate().await?;
let session_layer = SessionManagerLayer::new(session_store)
.with_secure(false) // set true behind HTTPS in production
.with_same_site(tower_sessions::cookie::SameSite::Lax)
.with_expiry(Expiry::OnInactivity(Duration::days(30)));
let cors = build_cors(&config.cors_origins)?;
let state = AppState {
pool,
config: Arc::new(config.clone()),
mailer,
};
let api = Router::new()
.merge(routes::router())
.nest("/auth", auth::routes::router());
let app = Router::new()
.nest("/api", api)
.layer(cors)
.layer(session_layer)
.layer(TraceLayer::new_for_http())
.with_state(state);
let addr = format!("{}:{}", config.host, config.port);
let listener = tokio::net::TcpListener::bind(&addr).await?;
tracing::info!("listening on {addr}");
axum::serve(listener, app).await?;
Ok(())
}
fn build_cors(origins: &[String]) -> anyhow::Result<CorsLayer> {
let parsed: Vec<HeaderValue> = origins
.iter()
.map(|o| o.parse::<HeaderValue>())
.collect::<Result<_, _>>()
.map_err(|e| anyhow::anyhow!("invalid CORS origin: {e}"))?;
Ok(CorsLayer::new()
.allow_origin(AllowOrigin::list(parsed))
.allow_credentials(true)
.allow_methods([Method::GET, Method::POST, Method::PATCH, Method::DELETE])
.allow_headers([header::CONTENT_TYPE]))
}
+45
View File
@@ -0,0 +1,45 @@
use serde::Serialize;
use time::OffsetDateTime;
use uuid::Uuid;
/// Full user row. `password_hash` never leaves the backend.
#[derive(Debug, sqlx::FromRow)]
pub struct User {
pub id: Uuid,
pub email: String,
pub password_hash: String,
pub display_name: Option<String>,
pub email_verified: bool,
pub created_at: OffsetDateTime,
pub updated_at: OffsetDateTime,
}
/// Safe representation sent to clients.
#[derive(Debug, Serialize)]
pub struct UserPublic {
pub id: Uuid,
pub email: String,
pub display_name: Option<String>,
pub email_verified: bool,
}
impl From<&User> for UserPublic {
fn from(u: &User) -> Self {
UserPublic {
id: u.id,
email: u.email.clone(),
display_name: u.display_name.clone(),
email_verified: u.email_verified,
}
}
}
#[derive(Debug, Serialize, sqlx::FromRow)]
pub struct UserSettings {
#[serde(skip)]
pub user_id: Uuid,
pub locale: String,
pub currency: String,
pub theme: String,
pub notify_email: bool,
}
+99
View File
@@ -0,0 +1,99 @@
use axum::extract::State;
use axum::routing::{get, patch};
use axum::{Json, Router};
use serde::Deserialize;
use serde_json::{json, Value};
use validator::Validate;
use crate::auth::session::AuthUser;
use crate::error::{AppError, AppResult};
use crate::models::UserSettings;
use crate::state::AppState;
pub fn router() -> Router<AppState> {
Router::new()
.route("/health", get(health))
.route("/settings", patch(update_settings))
.route("/profile", patch(update_profile))
}
async fn health() -> Json<Value> {
Json(json!({ "status": "ok" }))
}
const ALLOWED_LOCALES: &[&str] = &["de", "en"];
const ALLOWED_THEMES: &[&str] = &["breakcore", "grunge", "minimal"];
#[derive(Debug, Deserialize)]
struct SettingsReq {
locale: Option<String>,
#[serde(default)]
currency: Option<String>,
theme: Option<String>,
notify_email: Option<bool>,
}
async fn update_settings(
State(state): State<AppState>,
AuthUser(user): AuthUser,
Json(req): Json<SettingsReq>,
) -> AppResult<Json<UserSettings>> {
if let Some(loc) = &req.locale {
if !ALLOWED_LOCALES.contains(&loc.as_str()) {
return Err(AppError::Validation(format!("unsupported locale: {loc}")));
}
}
if let Some(theme) = &req.theme {
if !ALLOWED_THEMES.contains(&theme.as_str()) {
return Err(AppError::Validation(format!("unsupported theme: {theme}")));
}
}
if let Some(cur) = &req.currency {
if cur.len() != 3 {
return Err(AppError::Validation("currency must be a 3-letter code".into()));
}
}
let settings = sqlx::query_as::<_, UserSettings>(
"UPDATE user_settings SET
locale = COALESCE($2, locale),
currency = COALESCE($3, currency),
theme = COALESCE($4, theme),
notify_email = COALESCE($5, notify_email)
WHERE user_id = $1
RETURNING user_id, locale, currency, theme, notify_email",
)
.bind(user.id)
.bind(req.locale)
.bind(req.currency.map(|c| c.to_uppercase()))
.bind(req.theme)
.bind(req.notify_email)
.fetch_one(&state.pool)
.await?;
Ok(Json(settings))
}
#[derive(Debug, Deserialize, Validate)]
struct ProfileReq {
#[validate(length(max = 80, message = "display name too long"))]
display_name: Option<String>,
}
async fn update_profile(
State(state): State<AppState>,
AuthUser(user): AuthUser,
Json(req): Json<ProfileReq>,
) -> AppResult<Json<Value>> {
req.validate()
.map_err(|e| AppError::Validation(e.to_string()))?;
let display = req.display_name.as_deref().map(str::trim).filter(|s| !s.is_empty());
sqlx::query("UPDATE users SET display_name = $2 WHERE id = $1")
.bind(user.id)
.bind(display)
.execute(&state.pool)
.await?;
Ok(Json(json!({ "display_name": display })))
}
+14
View File
@@ -0,0 +1,14 @@
use std::sync::Arc;
use sqlx::PgPool;
use crate::config::Config;
use crate::mail::Mailer;
/// Shared application state injected into every handler.
#[derive(Clone)]
pub struct AppState {
pub pool: PgPool,
pub config: Arc<Config>,
pub mailer: Mailer,
}
+61
View File
@@ -0,0 +1,61 @@
services:
db:
image: postgres:17-alpine
restart: unless-stopped
environment:
POSTGRES_USER: ${POSTGRES_USER}
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
POSTGRES_DB: ${POSTGRES_DB}
volumes:
- pgdata:/var/lib/postgresql/data
ports:
- "5432:5432"
healthcheck:
test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER} -d ${POSTGRES_DB}"]
interval: 5s
timeout: 5s
retries: 10
# Dev-only SMTP catcher. UI at http://localhost:8025, SMTP on :1025.
# Point SMTP_HOST=mailpit SMTP_PORT=1025 SMTP_SECURITY=none to use it.
mailpit:
image: axllent/mailpit:latest
restart: unless-stopped
ports:
- "8025:8025"
- "1025:1025"
backend:
build: ./backend
restart: unless-stopped
depends_on:
db:
condition: service_healthy
environment:
DATABASE_URL: postgres://${POSTGRES_USER}:${POSTGRES_PASSWORD}@db:5432/${POSTGRES_DB}
BACKEND_HOST: 0.0.0.0
BACKEND_PORT: 8080
SESSION_SECRET: ${SESSION_SECRET}
PUBLIC_APP_URL: ${PUBLIC_APP_URL}
CORS_ORIGINS: ${CORS_ORIGINS}
SMTP_HOST: ${SMTP_HOST}
SMTP_PORT: ${SMTP_PORT}
SMTP_USERNAME: ${SMTP_USERNAME}
SMTP_PASSWORD: ${SMTP_PASSWORD}
SMTP_FROM: ${SMTP_FROM}
SMTP_SECURITY: ${SMTP_SECURITY}
ports:
- "8080:8080"
frontend:
build: ./frontend
restart: unless-stopped
depends_on:
- backend
environment:
PUBLIC_API_BASE: ${PUBLIC_API_BASE}
ports:
- "5173:3000"
volumes:
pgdata:
+1
View File
@@ -0,0 +1 @@
engine-strict=false
+23
View File
@@ -0,0 +1,23 @@
{
"name": "shoplist-frontend",
"version": "0.1.0",
"private": true,
"type": "module",
"scripts": {
"dev": "vite dev --port 5173",
"build": "vite build",
"preview": "vite preview",
"check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json"
},
"devDependencies": {
"@sveltejs/adapter-node": "^5.2.0",
"@sveltejs/kit": "^2.8.0",
"@sveltejs/vite-plugin-svelte": "^5.0.0",
"@tailwindcss/vite": "^4.0.0",
"svelte": "^5.15.0",
"svelte-check": "^4.1.0",
"tailwindcss": "^4.0.0",
"typescript": "^5.7.0",
"vite": "^6.0.0"
}
}
+1534
View File
File diff suppressed because it is too large Load Diff
+2
View File
@@ -0,0 +1,2 @@
allowBuilds:
esbuild: false
+252
View File
@@ -0,0 +1,252 @@
@import 'tailwindcss';
/* Web fonts loaded via <link> in app.html (terminal + brutalist mono vibe). */
/* ── Design tokens ─────────────────────────────────────────── */
@theme {
--color-void: #0a0a0b;
--color-ash: #131316;
--color-panel: #17171b;
--color-smoke: #2a2a30;
--color-ink: #e9e7e1;
--color-mute: #8a8a93;
--color-acid: #c2f73f; /* toxic green */
--color-blood: #ff1f6b; /* hot magenta */
--color-cyber: #28e0e0; /* cyan */
--color-bruise: #7a3cff; /* electric purple */
--font-display: 'Space Grotesk', system-ui, sans-serif;
--font-mono: 'Space Mono', ui-monospace, monospace;
--font-term: 'VT323', monospace;
--radius-none: 0px;
}
/* ── Base ──────────────────────────────────────────────────── */
@layer base {
:root {
color-scheme: dark;
}
html,
body {
background-color: var(--color-void);
color: var(--color-ink);
font-family: var(--font-mono);
}
body {
min-height: 100dvh;
position: relative;
overflow-x: hidden;
}
/* Film grain + scanlines layered over everything. */
body::before {
content: '';
position: fixed;
inset: 0;
pointer-events: none;
z-index: 9999;
opacity: 0.05;
background-image: repeating-linear-gradient(
0deg,
rgba(255, 255, 255, 0.6) 0px,
rgba(255, 255, 255, 0.6) 1px,
transparent 1px,
transparent 3px
);
mix-blend-mode: overlay;
}
body::after {
content: '';
position: fixed;
inset: 0;
pointer-events: none;
z-index: 9998;
opacity: 0.4;
background:
radial-gradient(circle at 20% 10%, rgba(122, 60, 255, 0.12), transparent 40%),
radial-gradient(circle at 85% 80%, rgba(255, 31, 107, 0.1), transparent 45%);
}
::selection {
background: var(--color-acid);
color: var(--color-void);
}
h1,
h2,
h3 {
font-family: var(--font-display);
letter-spacing: -0.03em;
}
a {
color: var(--color-cyber);
text-decoration: none;
}
a:hover {
color: var(--color-acid);
}
/* Brutalist scrollbar. */
::-webkit-scrollbar {
width: 10px;
}
::-webkit-scrollbar-track {
background: var(--color-void);
}
::-webkit-scrollbar-thumb {
background: var(--color-smoke);
border: 1px solid var(--color-blood);
}
}
/* ── Components ────────────────────────────────────────────── */
@layer components {
/* Hard-edged panel with offset shadow — xerox/zine look. */
.panel {
background: var(--color-panel);
border: 2px solid var(--color-smoke);
box-shadow: 6px 6px 0 0 var(--color-void), 9px 9px 0 0 var(--color-blood);
}
.panel-acid {
box-shadow: 6px 6px 0 0 var(--color-void), 9px 9px 0 0 var(--color-acid);
}
.label {
font-family: var(--font-mono);
font-size: 0.7rem;
letter-spacing: 0.25em;
text-transform: uppercase;
color: var(--color-mute);
}
.field {
width: 100%;
background: var(--color-void);
border: 2px solid var(--color-smoke);
color: var(--color-ink);
padding: 0.7rem 0.8rem;
font-family: var(--font-mono);
outline: none;
transition: border-color 0.1s ease, box-shadow 0.1s ease;
}
.field::placeholder {
color: #55555e;
}
.field:focus {
border-color: var(--color-acid);
box-shadow: 0 0 0 1px var(--color-acid), 0 0 18px -6px var(--color-acid);
}
/* Chunky brutalist button. */
.btn {
display: inline-flex;
align-items: center;
justify-content: center;
gap: 0.5rem;
font-family: var(--font-display);
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.05em;
padding: 0.7rem 1.2rem;
border: 2px solid var(--color-ink);
background: var(--color-ink);
color: var(--color-void);
cursor: pointer;
transition: transform 0.06s ease, box-shadow 0.06s ease, background 0.1s;
box-shadow: 4px 4px 0 0 var(--color-blood);
}
.btn:hover {
transform: translate(-2px, -2px);
box-shadow: 6px 6px 0 0 var(--color-blood);
}
.btn:active {
transform: translate(2px, 2px);
box-shadow: 1px 1px 0 0 var(--color-blood);
}
.btn-acid {
background: var(--color-acid);
border-color: var(--color-acid);
box-shadow: 4px 4px 0 0 var(--color-void);
}
.btn-acid:hover {
box-shadow: 6px 6px 0 0 var(--color-void);
}
.btn-ghost {
background: transparent;
color: var(--color-ink);
box-shadow: 4px 4px 0 0 var(--color-smoke);
}
.btn:disabled {
opacity: 0.4;
cursor: not-allowed;
transform: none;
}
.tag {
font-family: var(--font-mono);
font-size: 0.65rem;
letter-spacing: 0.2em;
text-transform: uppercase;
padding: 0.15rem 0.5rem;
border: 1px solid currentColor;
}
/* Glitchy duplicated-layer heading. */
.glitch {
position: relative;
color: var(--color-ink);
}
.glitch::before,
.glitch::after {
content: attr(data-text);
position: absolute;
inset: 0;
pointer-events: none;
}
.glitch::before {
color: var(--color-blood);
transform: translate(-2px, 0);
mix-blend-mode: screen;
clip-path: inset(0 0 55% 0);
animation: glitch-x 3.5s infinite steps(2);
}
.glitch::after {
color: var(--color-cyber);
transform: translate(2px, 0);
mix-blend-mode: screen;
clip-path: inset(55% 0 0 0);
animation: glitch-x 2.7s infinite steps(2) reverse;
}
}
@keyframes glitch-x {
0%, 92%, 100% { transform: translate(0, 0); }
93% { transform: translate(-3px, 1px); }
96% { transform: translate(3px, -1px); }
}
.marquee {
white-space: nowrap;
animation: marquee 22s linear infinite;
}
@keyframes marquee {
from { transform: translateX(0); }
to { transform: translateX(-50%); }
}
.flicker {
animation: flicker 4s infinite;
}
@keyframes flicker {
0%, 100% { opacity: 1; }
97% { opacity: 1; }
98% { opacity: 0.4; }
99% { opacity: 0.9; }
}
+11
View File
@@ -0,0 +1,11 @@
// See https://svelte.dev/docs/kit/types#app.d.ts
declare global {
namespace App {
// interface Error {}
// interface Locals {}
// interface PageData {}
// interface Platform {}
}
}
export {};
+19
View File
@@ -0,0 +1,19 @@
<!doctype html>
<html lang="en" class="dark">
<head>
<meta charset="utf-8" />
<link rel="icon" href="%sveltekit.assets%/favicon.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<meta name="theme-color" content="#0a0a0a" />
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link
href="https://fonts.googleapis.com/css2?family=Space+Grotesk:wght@400;500;700&family=Space+Mono:ital,wght@0,400;0,700;1,400&family=VT323&display=swap"
rel="stylesheet"
/>
%sveltekit.head%
</head>
<body data-sveltekit-preload-data="hover">
<div class="contents">%sveltekit.body%</div>
</body>
</html>
+37
View File
@@ -0,0 +1,37 @@
import { env } from '$env/dynamic/public';
const BASE = env.PUBLIC_API_BASE || 'http://localhost:8080';
export class ApiError extends Error {
status: number;
constructor(status: number, message: string) {
super(message);
this.status = status;
}
}
async function request<T>(path: string, opts: RequestInit = {}): Promise<T> {
const res = await fetch(`${BASE}/api${path}`, {
credentials: 'include',
headers: { 'Content-Type': 'application/json', ...(opts.headers ?? {}) },
...opts
});
if (res.status === 204) return undefined as T;
const text = await res.text();
const data = text ? JSON.parse(text) : null;
if (!res.ok) {
throw new ApiError(res.status, data?.error ?? res.statusText);
}
return data as T;
}
export const api = {
get: <T>(p: string) => request<T>(p),
post: <T>(p: string, body?: unknown) =>
request<T>(p, { method: 'POST', body: body ? JSON.stringify(body) : undefined }),
patch: <T>(p: string, body?: unknown) =>
request<T>(p, { method: 'PATCH', body: body ? JSON.stringify(body) : undefined })
};
+53
View File
@@ -0,0 +1,53 @@
import { api } from './api';
export type User = {
id: string;
email: string;
display_name: string | null;
email_verified: boolean;
};
export type Settings = {
locale: string;
currency: string;
theme: string;
notify_email: boolean;
};
type Me = { user: User; settings: Settings };
class AuthStore {
user = $state<User | null>(null);
settings = $state<Settings | null>(null);
loaded = $state(false);
async refresh() {
try {
const me = await api.get<Me>('/auth/me');
this.user = me.user;
this.settings = me.settings;
} catch {
this.user = null;
this.settings = null;
} finally {
this.loaded = true;
}
}
set(me: Me) {
this.user = me.user;
this.settings = me.settings;
this.loaded = true;
}
async logout() {
try {
await api.post('/auth/logout');
} finally {
this.user = null;
this.settings = null;
}
}
}
export const auth = new AuthStore();
+78
View File
@@ -0,0 +1,78 @@
<script lang="ts">
import '../app.css';
import { onMount } from 'svelte';
import { goto } from '$app/navigation';
import { page } from '$app/state';
import { auth } from '$lib/auth.svelte';
let { children } = $props();
onMount(() => {
auth.refresh();
});
async function doLogout() {
await auth.logout();
goto('/login');
}
const ticker =
'BUY LESS · WANT MORE · TRACK THE DROP · NO IMPULSE · GRAB THE DEAL · ';
</script>
<div class="min-h-dvh flex flex-col">
<!-- Ticker strip -->
<div class="bg-acid text-void overflow-hidden border-b-2 border-void">
<div class="marquee py-1 font-mono text-xs font-bold tracking-widest">
{ticker.repeat(6)}
</div>
</div>
<!-- Header -->
<header class="border-b-2 border-smoke">
<div class="mx-auto flex max-w-5xl items-center justify-between px-4 py-4">
<a href="/" class="group flex items-baseline gap-2">
<span
class="glitch flicker font-display text-2xl font-bold tracking-tighter text-ink"
data-text="//WANTLIST"
>
//WANTLIST
</span>
</a>
<nav class="flex items-center gap-2 text-sm">
{#if auth.loaded && auth.user}
<a href="/settings" class="tag border-smoke text-mute hover:text-acid">
{auth.user.display_name ?? auth.user.email}
</a>
<button class="btn btn-ghost !px-3 !py-1 text-xs" onclick={doLogout}>
logout
</button>
{:else if auth.loaded}
{#if page.url.pathname !== '/login'}
<a href="/login" class="tag border-smoke text-mute hover:text-acid">login</a>
{/if}
{#if page.url.pathname !== '/register'}
<a href="/register" class="btn btn-acid !px-3 !py-1 text-xs">sign up</a>
{/if}
{/if}
</nav>
</div>
</header>
<!-- Unverified banner -->
{#if auth.loaded && auth.user && !auth.user.email_verified}
<div class="border-b-2 border-blood bg-blood/10 px-4 py-2 text-center text-xs text-blood">
⚠ email not verified — check your inbox. lost it?
<a href="/settings" class="underline">resend from settings</a>
</div>
{/if}
<main class="mx-auto w-full max-w-5xl flex-1 px-4 py-10">
{@render children()}
</main>
<footer class="border-t-2 border-smoke px-4 py-6 text-center">
<p class="label">self-hosted · rust + sveltekit · phase 1</p>
</footer>
</div>
+70
View File
@@ -0,0 +1,70 @@
<script lang="ts">
import { auth } from '$lib/auth.svelte';
</script>
<svelte:head>
<title>//WANTLIST</title>
</svelte:head>
{#if auth.loaded && auth.user}
<!-- Logged-in placeholder dashboard -->
<section class="space-y-6">
<div>
<p class="label">logged in as</p>
<h1 class="font-display text-3xl font-bold">
{auth.user.display_name ?? auth.user.email}
</h1>
</div>
<div class="panel panel-acid p-6">
<p class="tag mb-3 inline-block border-acid text-acid">phase 2</p>
<h2 class="mb-2 text-xl font-bold">your lists land here</h2>
<p class="max-w-prose text-mute">
topic-based wantlists (clothes, gear, whatever), item tracking, and pasted
product URLs that get refetched for price drops. building it next.
</p>
</div>
<div class="grid gap-4 sm:grid-cols-3">
{#each [['LISTS', 'soon'], ['TRACKED URLS', 'soon'], ['DEAL ALERTS', 'soon']] as [k, v]}
<div class="panel p-4">
<p class="label">{k}</p>
<p class="font-display text-2xl text-blood">{v}</p>
</div>
{/each}
</div>
</section>
{:else}
<!-- Marketing hero -->
<section class="space-y-10">
<div class="space-y-4">
<p class="tag inline-block border-cyber text-cyber">self-hosted · rust core</p>
<h1
class="glitch font-display text-5xl font-bold leading-[0.95] sm:text-7xl"
data-text="TRACK WHAT YOU WANT. STRIKE ON THE DROP."
>
TRACK WHAT YOU WANT. STRIKE ON THE DROP.
</h1>
<p class="max-w-xl text-lg text-mute">
Topic-based shopping lists for the things you actually want. Paste a product
URL, and get mailed the moment the price tanks. No feed. No algorithm. Your
server, your rules.
</p>
</div>
<div class="flex flex-wrap gap-3">
<a href="/register" class="btn btn-acid">make an account →</a>
<a href="/login" class="btn btn-ghost">log in</a>
</div>
<div class="grid gap-4 sm:grid-cols-3">
{#each [['01', 'LIST IT', 'group wants by topic'], ['02', 'PASTE URL', 'we watch the price'], ['03', 'GET MAILED', 'strike on the drop']] as [n, t, d]}
<div class="panel p-5">
<p class="font-term text-4xl text-blood">{n}</p>
<p class="mt-1 font-display text-lg font-bold">{t}</p>
<p class="text-sm text-mute">{d}</p>
</div>
{/each}
</div>
</section>
{/if}
+50
View File
@@ -0,0 +1,50 @@
<script lang="ts">
import { api, ApiError } from '$lib/api';
let email = $state('');
let done = $state(false);
let error = $state('');
let busy = $state(false);
async function submit(e: SubmitEvent) {
e.preventDefault();
error = '';
busy = true;
try {
await api.post('/auth/request-password-reset', { email });
done = true;
} catch (err) {
error = err instanceof ApiError ? err.message : 'something broke';
} finally {
busy = false;
}
}
</script>
<svelte:head><title>reset · //WANTLIST</title></svelte:head>
<div class="mx-auto max-w-md">
<div class="panel p-8">
<p class="label">password reset</p>
<h1 class="mb-6 font-display text-3xl font-bold">LOST THE KEY</h1>
{#if done}
<p class="border-2 border-acid bg-acid/10 px-3 py-3 text-sm text-acid">
if that email exists, a reset link is on its way. check your inbox.
</p>
{:else}
<form class="space-y-4" onsubmit={submit}>
<div>
<label class="label" for="em">email</label>
<input id="em" class="field mt-1" type="email" bind:value={email} required autocomplete="email" />
</div>
{#if error}
<p class="border-2 border-blood bg-blood/10 px-3 py-2 text-sm text-blood">{error}</p>
{/if}
<button class="btn w-full" disabled={busy}>{busy ? 'sending…' : 'send reset link'}</button>
</form>
{/if}
<p class="mt-5 text-center text-sm text-mute"><a href="/login">back to login</a></p>
</div>
</div>
+56
View File
@@ -0,0 +1,56 @@
<script lang="ts">
import { goto } from '$app/navigation';
import { api, ApiError } from '$lib/api';
import { auth } from '$lib/auth.svelte';
let email = $state('');
let password = $state('');
let error = $state('');
let busy = $state(false);
async function submit(e: SubmitEvent) {
e.preventDefault();
error = '';
busy = true;
try {
await api.post('/auth/login', { email, password });
await auth.refresh();
goto('/');
} catch (err) {
error = err instanceof ApiError ? err.message : 'something broke';
} finally {
busy = false;
}
}
</script>
<svelte:head><title>log in · //WANTLIST</title></svelte:head>
<div class="mx-auto max-w-md">
<div class="panel p-8">
<p class="label">welcome back</p>
<h1 class="mb-6 font-display text-3xl font-bold">RE-ENTER THE PIT</h1>
<form class="space-y-4" onsubmit={submit}>
<div>
<label class="label" for="em">email</label>
<input id="em" class="field mt-1" type="email" bind:value={email} required autocomplete="email" />
</div>
<div>
<label class="label" for="pw">password</label>
<input id="pw" class="field mt-1" type="password" bind:value={password} required autocomplete="current-password" />
</div>
{#if error}
<p class="border-2 border-blood bg-blood/10 px-3 py-2 text-sm text-blood">{error}</p>
{/if}
<button class="btn w-full" disabled={busy}>{busy ? 'entering…' : 'log in'}</button>
</form>
<div class="mt-5 flex justify-between text-sm text-mute">
<a href="/register">need an account?</a>
<a href="/forgot">forgot password?</a>
</div>
</div>
</div>
+66
View File
@@ -0,0 +1,66 @@
<script lang="ts">
import { goto } from '$app/navigation';
import { api, ApiError } from '$lib/api';
import { auth } from '$lib/auth.svelte';
let email = $state('');
let password = $state('');
let displayName = $state('');
let error = $state('');
let busy = $state(false);
async function submit(e: SubmitEvent) {
e.preventDefault();
error = '';
busy = true;
try {
await api.post('/auth/register', {
email,
password,
display_name: displayName || null
});
await auth.refresh();
goto('/');
} catch (err) {
error = err instanceof ApiError ? err.message : 'something broke';
} finally {
busy = false;
}
}
</script>
<svelte:head><title>sign up · //WANTLIST</title></svelte:head>
<div class="mx-auto max-w-md">
<div class="panel panel-acid p-8">
<p class="label">new account</p>
<h1 class="mb-6 font-display text-3xl font-bold">CARVE YOUR MARK</h1>
<form class="space-y-4" onsubmit={submit}>
<div>
<label class="label" for="dn">display name <span class="text-mute">(optional)</span></label>
<input id="dn" class="field mt-1" bind:value={displayName} maxlength="80" autocomplete="nickname" />
</div>
<div>
<label class="label" for="em">email</label>
<input id="em" class="field mt-1" type="email" bind:value={email} required autocomplete="email" />
</div>
<div>
<label class="label" for="pw">password <span class="text-mute">(min 10)</span></label>
<input id="pw" class="field mt-1" type="password" bind:value={password} required minlength="10" autocomplete="new-password" />
</div>
{#if error}
<p class="border-2 border-blood bg-blood/10 px-3 py-2 text-sm text-blood">{error}</p>
{/if}
<button class="btn btn-acid w-full" disabled={busy}>
{busy ? 'carving…' : 'sign up'}
</button>
</form>
<p class="mt-5 text-center text-sm text-mute">
already have one? <a href="/login">log in</a>
</p>
</div>
</div>
+58
View File
@@ -0,0 +1,58 @@
<script lang="ts">
import { goto } from '$app/navigation';
import { page } from '$app/state';
import { api, ApiError } from '$lib/api';
const token = $derived(page.url.searchParams.get('token') ?? '');
let password = $state('');
let error = $state('');
let busy = $state(false);
let done = $state(false);
async function submit(e: SubmitEvent) {
e.preventDefault();
error = '';
busy = true;
try {
await api.post('/auth/reset-password', { token, new_password: password });
done = true;
setTimeout(() => goto('/login'), 1500);
} catch (err) {
error = err instanceof ApiError ? err.message : 'something broke';
} finally {
busy = false;
}
}
</script>
<svelte:head><title>new password · //WANTLIST</title></svelte:head>
<div class="mx-auto max-w-md">
<div class="panel panel-acid p-8">
<p class="label">set new password</p>
<h1 class="mb-6 font-display text-3xl font-bold">CUT A NEW KEY</h1>
{#if !token}
<p class="border-2 border-blood bg-blood/10 px-3 py-3 text-sm text-blood">
no reset token in this link. request a fresh one.
</p>
<p class="mt-5 text-center text-sm text-mute"><a href="/forgot">request reset</a></p>
{:else if done}
<p class="border-2 border-acid bg-acid/10 px-3 py-3 text-sm text-acid">
password changed. redirecting to login…
</p>
{:else}
<form class="space-y-4" onsubmit={submit}>
<div>
<label class="label" for="pw">new password <span class="text-mute">(min 10)</span></label>
<input id="pw" class="field mt-1" type="password" bind:value={password} required minlength="10" autocomplete="new-password" />
</div>
{#if error}
<p class="border-2 border-blood bg-blood/10 px-3 py-2 text-sm text-blood">{error}</p>
{/if}
<button class="btn btn-acid w-full" disabled={busy}>{busy ? 'cutting…' : 'set password'}</button>
</form>
{/if}
</div>
</div>
+137
View File
@@ -0,0 +1,137 @@
<script lang="ts">
import { goto } from '$app/navigation';
import { api, ApiError } from '$lib/api';
import { auth, type Settings } from '$lib/auth.svelte';
let displayName = $state('');
let settings = $state<Settings>({ locale: 'de', currency: 'EUR', theme: 'breakcore', notify_email: true });
let msg = $state('');
let error = $state('');
let busy = $state(false);
let resendMsg = $state('');
// Sync local form state once auth finishes loading.
$effect(() => {
if (auth.loaded && !auth.user) {
goto('/login');
}
if (auth.user) {
displayName = auth.user.display_name ?? '';
}
if (auth.settings) {
settings = { ...auth.settings };
}
});
const themes = ['breakcore', 'grunge', 'minimal'];
const locales = ['de', 'en'];
async function save(e: SubmitEvent) {
e.preventDefault();
error = '';
msg = '';
busy = true;
try {
await api.patch('/profile', { display_name: displayName || null });
await api.patch('/settings', settings);
await auth.refresh();
msg = 'saved.';
} catch (err) {
error = err instanceof ApiError ? err.message : 'save failed';
} finally {
busy = false;
}
}
async function resend() {
resendMsg = '';
try {
await api.post('/auth/resend-verification');
resendMsg = 'sent — check your inbox.';
} catch (err) {
resendMsg = err instanceof ApiError ? err.message : 'failed';
}
}
</script>
<svelte:head><title>settings · //WANTLIST</title></svelte:head>
{#if auth.loaded && auth.user}
<div class="mx-auto max-w-2xl space-y-6">
<div>
<p class="label">configuration</p>
<h1 class="font-display text-4xl font-bold">CONTROL PANEL</h1>
</div>
<!-- Verification status -->
<div class="panel p-5">
<p class="label">email</p>
<div class="mt-1 flex flex-wrap items-center justify-between gap-3">
<span class="font-mono">{auth.user.email}</span>
{#if auth.user.email_verified}
<span class="tag border-acid text-acid">verified</span>
{:else}
<div class="flex items-center gap-2">
<span class="tag border-blood text-blood">unverified</span>
<button class="btn btn-ghost !px-3 !py-1 text-xs" onclick={resend}>resend</button>
</div>
{/if}
</div>
{#if resendMsg}<p class="mt-2 text-xs text-mute">{resendMsg}</p>{/if}
</div>
<form class="panel panel-acid space-y-6 p-6" onsubmit={save}>
<div>
<label class="label" for="dn">display name</label>
<input id="dn" class="field mt-1" bind:value={displayName} maxlength="80" />
</div>
<div class="grid gap-4 sm:grid-cols-2">
<div>
<label class="label" for="loc">language</label>
<select id="loc" class="field mt-1" bind:value={settings.locale}>
{#each locales as l}<option value={l}>{l.toUpperCase()}</option>{/each}
</select>
</div>
<div>
<label class="label" for="cur">currency</label>
<input id="cur" class="field mt-1 uppercase" bind:value={settings.currency} maxlength="3" />
</div>
</div>
<div>
<p class="label mb-2">theme</p>
<div class="grid grid-cols-3 gap-2">
{#each themes as t}
<button
type="button"
class="tag border-smoke py-2 text-center transition-colors"
class:!border-acid={settings.theme === t}
class:text-acid={settings.theme === t}
onclick={() => (settings.theme = t)}
>
{t}
</button>
{/each}
</div>
</div>
<label class="flex cursor-pointer items-center gap-3">
<input type="checkbox" class="size-5 accent-acid" bind:checked={settings.notify_email} />
<span class="font-mono text-sm">email me about deals & price drops</span>
</label>
{#if error}
<p class="border-2 border-blood bg-blood/10 px-3 py-2 text-sm text-blood">{error}</p>
{/if}
{#if msg}
<p class="border-2 border-acid bg-acid/10 px-3 py-2 text-sm text-acid">{msg}</p>
{/if}
<button class="btn btn-acid" disabled={busy}>{busy ? 'saving…' : 'save changes'}</button>
</form>
</div>
{:else}
<p class="text-center text-mute flicker">loading…</p>
{/if}
+47
View File
@@ -0,0 +1,47 @@
<script lang="ts">
import { onMount } from 'svelte';
import { page } from '$app/state';
import { api, ApiError } from '$lib/api';
import { auth } from '$lib/auth.svelte';
type State = 'working' | 'ok' | 'error';
let status = $state<State>('working');
let message = $state('');
onMount(async () => {
const token = page.url.searchParams.get('token');
if (!token) {
status = 'error';
message = 'no verification token in this link.';
return;
}
try {
await api.post('/auth/verify', { token });
await auth.refresh();
status = 'ok';
} catch (err) {
status = 'error';
message = err instanceof ApiError ? err.message : 'verification failed';
}
});
</script>
<svelte:head><title>verify · //WANTLIST</title></svelte:head>
<div class="mx-auto max-w-md">
<div class="panel p-8 text-center">
<p class="label">email verification</p>
{#if status === 'working'}
<h1 class="mt-4 font-display text-3xl font-bold flicker">VERIFYING…</h1>
{:else if status === 'ok'}
<h1 class="mt-4 font-display text-3xl font-bold text-acid">VERIFIED ✓</h1>
<p class="mt-3 text-mute">your email is confirmed. you're all set.</p>
<a href="/" class="btn btn-acid mt-6">enter →</a>
{:else}
<h1 class="mt-4 font-display text-3xl font-bold text-blood">FAILED ✗</h1>
<p class="mt-3 text-mute">{message}</p>
<a href="/" class="btn btn-ghost mt-6">go home</a>
{/if}
</div>
</div>
+5
View File
@@ -0,0 +1,5 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32">
<rect width="32" height="32" fill="#0a0a0b"/>
<rect x="6" y="6" width="20" height="20" fill="none" stroke="#c2f73f" stroke-width="2"/>
<path d="M10 16 l4 4 l8 -9" fill="none" stroke="#ff1f6b" stroke-width="3"/>
</svg>

After

Width:  |  Height:  |  Size: 285 B

+12
View File
@@ -0,0 +1,12 @@
import adapter from '@sveltejs/adapter-node';
import { vitePreprocess } from '@sveltejs/vite-plugin-svelte';
/** @type {import('@sveltejs/kit').Config} */
const config = {
preprocess: vitePreprocess(),
kit: {
adapter: adapter()
}
};
export default config;
+14
View File
@@ -0,0 +1,14 @@
{
"extends": "./.svelte-kit/tsconfig.json",
"compilerOptions": {
"allowJs": true,
"checkJs": true,
"esModuleInterop": true,
"forceConsistentCasingInFileNames": true,
"resolveJsonModule": true,
"skipLibCheck": true,
"sourceMap": true,
"strict": true,
"moduleResolution": "bundler"
}
}
+7
View File
@@ -0,0 +1,7 @@
import { sveltekit } from '@sveltejs/kit/vite';
import tailwindcss from '@tailwindcss/vite';
import { defineConfig } from 'vite';
export default defineConfig({
plugins: [tailwindcss(), sveltekit()]
});