diff --git a/alembic.ini b/alembic.ini new file mode 100644 index 0000000..1741e86 --- /dev/null +++ b/alembic.ini @@ -0,0 +1,44 @@ +# Alembic single-database async configuration for ThoughtSync. + +[alembic] +script_location = %(here)s/alembic +prepend_sys_path = . src +path_separator = os + +# Local-dev default; overridden at runtime by THOUGHTSYNC_DATABASE_URL (see env.py). +sqlalchemy.url = postgresql+asyncpg://thoughtsync:thoughtsync@localhost:5432/thoughtsync + + +[loggers] +keys = root,sqlalchemy,alembic + +[handlers] +keys = console + +[formatters] +keys = generic + +[logger_root] +level = WARNING +handlers = console +qualname = + +[logger_sqlalchemy] +level = WARNING +handlers = +qualname = sqlalchemy.engine + +[logger_alembic] +level = INFO +handlers = +qualname = alembic + +[handler_console] +class = StreamHandler +args = (sys.stderr,) +level = NOTSET +formatter = generic + +[formatter_generic] +format = %(levelname)-5.5s [%(name)s] %(message)s +datefmt = %H:%M:%S diff --git a/alembic/env.py b/alembic/env.py new file mode 100644 index 0000000..bcfe7d2 --- /dev/null +++ b/alembic/env.py @@ -0,0 +1,48 @@ +import asyncio +import os +from logging.config import fileConfig + +from sqlalchemy import pool +from sqlalchemy.engine import Connection +from sqlalchemy.ext.asyncio import async_engine_from_config + +from alembic import context + +from thoughtsync.models import Base +import thoughtsync.models.all # noqa: F401 — registers every model on Base.metadata + +config = context.config + +if config.config_file_name is not None: + fileConfig(config.config_file_name) + +config.set_main_option( + "sqlalchemy.url", + os.environ.get("THOUGHTSYNC_DATABASE_URL", config.get_main_option("sqlalchemy.url")), +) + +target_metadata = Base.metadata + + +def do_run_migrations(connection: Connection) -> None: + context.configure(connection=connection, target_metadata=target_metadata, compare_type=True) + with context.begin_transaction(): + context.run_migrations() + + +async def run_async_migrations() -> None: + connectable = async_engine_from_config( + config.get_section(config.config_ini_section, {}), + prefix="sqlalchemy.", + poolclass=pool.NullPool, + ) + async with connectable.connect() as connection: + await connection.run_sync(do_run_migrations) + await connectable.dispose() + + +def run_migrations_online() -> None: + asyncio.run(run_async_migrations()) + + +run_migrations_online() diff --git a/alembic/script.py.mako b/alembic/script.py.mako new file mode 100644 index 0000000..fbc4b07 --- /dev/null +++ b/alembic/script.py.mako @@ -0,0 +1,26 @@ +"""${message} + +Revision ID: ${up_revision} +Revises: ${down_revision | comma,n} +Create Date: ${create_date} + +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa +${imports if imports else ""} + +# revision identifiers, used by Alembic. +revision: str = ${repr(up_revision)} +down_revision: Union[str, None] = ${repr(down_revision)} +branch_labels: Union[str, Sequence[str], None] = ${repr(branch_labels)} +depends_on: Union[str, Sequence[str], None] = ${repr(depends_on)} + + +def upgrade() -> None: + ${upgrades if upgrades else "pass"} + + +def downgrade() -> None: + ${downgrades if downgrades else "pass"} diff --git a/alembic/versions/0001_foundation.py b/alembic/versions/0001_foundation.py new file mode 100644 index 0000000..9f6083a --- /dev/null +++ b/alembic/versions/0001_foundation.py @@ -0,0 +1,80 @@ +"""foundation: users, groups, group_members, shares + +Revision ID: 0001 +Revises: +Create Date: 2026-07-19 + +The M0 identity + sharing-ACL spine. No notes yet (those arrive in M1); the +shares table is polymorphic so notes reuse it without a schema retrofit. +""" +from alembic import op +import sqlalchemy as sa +from sqlalchemy.dialects.postgresql import CITEXT, UUID + +revision = "0001" +down_revision = None +branch_labels = None +depends_on = None + + +def upgrade() -> None: + op.execute("CREATE EXTENSION IF NOT EXISTS citext") + + op.create_table( + "users", + sa.Column("id", UUID(as_uuid=True), primary_key=True), + sa.Column("email", CITEXT(), nullable=False, unique=True), + sa.Column("email_verified", sa.Boolean(), nullable=False, server_default=sa.false()), + sa.Column("password_hash", sa.Text(), nullable=True), + sa.Column("display_name", sa.Text(), nullable=False), + sa.Column("avatar_path", sa.Text(), nullable=True), + sa.Column("created_at", sa.DateTime(timezone=True), nullable=False, server_default=sa.func.now()), + sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False, server_default=sa.func.now()), + ) + op.create_index("ix_users_email", "users", ["email"]) + + op.create_table( + "groups", + sa.Column("id", UUID(as_uuid=True), primary_key=True), + sa.Column("name", sa.Text(), nullable=False), + sa.Column("owner_id", UUID(as_uuid=True), sa.ForeignKey("users.id", ondelete="SET NULL"), nullable=True), + sa.Column("created_at", sa.DateTime(timezone=True), nullable=False, server_default=sa.func.now()), + ) + + op.create_table( + "group_members", + sa.Column("id", UUID(as_uuid=True), primary_key=True), + sa.Column("group_id", UUID(as_uuid=True), sa.ForeignKey("groups.id", ondelete="CASCADE"), nullable=False), + sa.Column("user_id", UUID(as_uuid=True), sa.ForeignKey("users.id", ondelete="CASCADE"), nullable=False), + sa.Column("role", sa.Text(), nullable=False, server_default="member"), + sa.Column("created_at", sa.DateTime(timezone=True), nullable=False, server_default=sa.func.now()), + sa.UniqueConstraint("group_id", "user_id", name="uq_group_members_group_user"), + ) + op.create_index("ix_group_members_user", "group_members", ["user_id"]) + + op.create_table( + "shares", + sa.Column("id", UUID(as_uuid=True), primary_key=True), + sa.Column("resource_type", sa.Text(), nullable=False), + sa.Column("resource_id", UUID(as_uuid=True), nullable=False), + sa.Column("shared_with_user_id", UUID(as_uuid=True), sa.ForeignKey("users.id", ondelete="CASCADE"), nullable=True), + sa.Column("shared_with_group_id", UUID(as_uuid=True), sa.ForeignKey("groups.id", ondelete="CASCADE"), nullable=True), + sa.Column("permission", sa.Text(), nullable=False, server_default="view"), + sa.Column("created_at", sa.DateTime(timezone=True), nullable=False, server_default=sa.func.now()), + sa.CheckConstraint( + "(shared_with_user_id IS NOT NULL) <> (shared_with_group_id IS NOT NULL)", + name="ck_shares_one_target", + ), + ) + op.create_index("ix_shares_resource", "shares", ["resource_type", "resource_id"]) + + +def downgrade() -> None: + op.drop_index("ix_shares_resource", table_name="shares") + op.drop_table("shares") + op.drop_index("ix_group_members_user", table_name="group_members") + op.drop_table("group_members") + op.drop_table("groups") + op.drop_index("ix_users_email", table_name="users") + op.drop_table("users") + # citext extension is left installed — it may be in use elsewhere. diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..8f61eac --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,39 @@ +[build-system] +requires = ["setuptools>=68.0"] +build-backend = "setuptools.build_meta" + +[project] +name = "thoughtsync" +version = "0.1.0" +description = "Self-hosted personal thought-capture web app (FabledSword family)" +requires-python = ">=3.12" +dependencies = [ + "quart>=0.19", + "sqlalchemy[asyncio]>=2.0", + "asyncpg>=0.29", + "alembic>=1.13", + "bcrypt>=4.0", + "hypercorn>=0.17", +] + +[project.optional-dependencies] +dev = [ + "pytest>=8.0", + "pytest-asyncio>=0.23", + "ruff>=0.6", +] + +[tool.setuptools.packages.find] +where = ["src"] + +[tool.pytest.ini_options] +asyncio_mode = "auto" +testpaths = ["tests"] + +[tool.ruff] +line-length = 120 +target-version = "py312" + +[tool.ruff.lint] +select = ["E", "F"] +ignore = ["E501", "E402", "F401"] diff --git a/src/thoughtsync/__init__.py b/src/thoughtsync/__init__.py new file mode 100644 index 0000000..0a50ac7 --- /dev/null +++ b/src/thoughtsync/__init__.py @@ -0,0 +1,3 @@ +"""ThoughtSync — self-hosted personal thought-capture web app (FabledSword family).""" + +__version__ = "0.1.0" diff --git a/src/thoughtsync/acl.py b/src/thoughtsync/acl.py new file mode 100644 index 0000000..6e078bc --- /dev/null +++ b/src/thoughtsync/acl.py @@ -0,0 +1,46 @@ +from __future__ import annotations + +import uuid + +from sqlalchemy import ColumnElement, exists, or_, select + +from .models.group import GroupMember +from .models.share import Share + + +def visible_to_user( + resource_type: str, + owner_column, + resource_id_column, + user_id: uuid.UUID, +) -> ColumnElement[bool]: + """Boolean SQL predicate: is this resource visible to ``user_id``? + + A resource is visible if the user OWNS it, OR it is shared DIRECTLY with the + user, OR it is shared with a GROUP the user belongs to. Scope every read (and + mutation) of shareable user data through this — never assume a single operator + (family rule 47). + + Usage (M1+), e.g. for notes:: + + stmt = select(Note).where(visible_to_user("note", Note.owner_id, Note.id, uid)) + + Args: + resource_type: the ``Share.resource_type`` tag for this entity (e.g. "note"). + owner_column: the resource's owner column (e.g. ``Note.owner_id``). + resource_id_column: the resource's primary-key column (e.g. ``Note.id``). + user_id: the viewer. + """ + user_group_ids = select(GroupMember.group_id).where(GroupMember.user_id == user_id) + + direct_share = exists().where( + Share.resource_type == resource_type, + Share.resource_id == resource_id_column, + Share.shared_with_user_id == user_id, + ) + group_share = exists().where( + Share.resource_type == resource_type, + Share.resource_id == resource_id_column, + Share.shared_with_group_id.in_(user_group_ids), + ) + return or_(owner_column == user_id, direct_share, group_share) diff --git a/src/thoughtsync/app.py b/src/thoughtsync/app.py new file mode 100644 index 0000000..0e7cb71 --- /dev/null +++ b/src/thoughtsync/app.py @@ -0,0 +1,43 @@ +from __future__ import annotations + +import os + +from quart import Quart, jsonify, send_from_directory + +from . import __version__ +from .auth import bp as auth_bp +from .config import Config + +STATIC_DIR = os.path.join(os.path.dirname(__file__), "static") + + +def create_app() -> Quart: + # static_folder=None: the SPA catch-all below owns static serving instead of + # Quart's default /static handler. + app = Quart(__name__, static_folder=None) + app.secret_key = Config.secret_key() + app.config["APP_VERSION"] = os.environ.get("APP_VERSION", __version__) + + app.register_blueprint(auth_bp) + + @app.get("/api/health") + async def health(): + return jsonify({"status": "ok", "version": app.config["APP_VERSION"]}) + + # Serve the built Vue SPA (baked into static/ by the Docker build) with a + # history-fallback to index.html. In dev the Vite server proxies /api here, so + # a missing static/ dir is expected and simply 404s the frontend. + @app.get("/", defaults={"path": ""}) + @app.get("/") + async def spa(path: str): + if path.startswith("api/"): + return jsonify({"error": "not found"}), 404 + candidate = os.path.join(STATIC_DIR, path) + if path and os.path.isfile(candidate): + return await send_from_directory(STATIC_DIR, path) + index = os.path.join(STATIC_DIR, "index.html") + if os.path.isfile(index): + return await send_from_directory(STATIC_DIR, "index.html") + return jsonify({"error": "frontend not built"}), 404 + + return app diff --git a/src/thoughtsync/auth.py b/src/thoughtsync/auth.py new file mode 100644 index 0000000..4a7f188 --- /dev/null +++ b/src/thoughtsync/auth.py @@ -0,0 +1,100 @@ +from __future__ import annotations + +import functools +import uuid + +from quart import Blueprint, g, jsonify, request, session +from sqlalchemy import select + +from .db import session_scope +from .models.user import User +from .security import hash_password, verify_password + +bp = Blueprint("auth", __name__, url_prefix="/api/auth") + +SESSION_KEY = "user_id" +MIN_PASSWORD_LEN = 8 + + +def _serialize_user(user: User) -> dict: + return { + "id": str(user.id), + "email": user.email, + "display_name": user.display_name, + "email_verified": user.email_verified, + } + + +def login_required(fn): + """Guard: 401 unless a valid session is present. Sets g.user_id for the view.""" + + @functools.wraps(fn) + async def wrapper(*args, **kwargs): + raw = session.get(SESSION_KEY) + if not raw: + return jsonify({"error": "authentication required"}), 401 + try: + g.user_id = uuid.UUID(raw) + except (ValueError, TypeError): + session.pop(SESSION_KEY, None) + return jsonify({"error": "authentication required"}), 401 + return await fn(*args, **kwargs) + + return wrapper + + +@bp.post("/register") +async def register(): + data = await request.get_json(silent=True) or {} + email = (data.get("email") or "").strip().lower() + password = data.get("password") or "" + display_name = (data.get("display_name") or "").strip() + + if not email or "@" not in email: + return jsonify({"error": "a valid email is required"}), 400 + if len(password) < MIN_PASSWORD_LEN: + return jsonify({"error": f"password must be at least {MIN_PASSWORD_LEN} characters"}), 400 + if not display_name: + display_name = email.split("@", 1)[0] + + async with session_scope() as db: + existing = await db.scalar(select(User).where(User.email == email)) + if existing is not None: + return jsonify({"error": "an account with that email already exists"}), 409 + user = User(email=email, password_hash=hash_password(password), display_name=display_name) + db.add(user) + await db.commit() + await db.refresh(user) + session[SESSION_KEY] = str(user.id) + return jsonify(_serialize_user(user)), 201 + + +@bp.post("/login") +async def login(): + data = await request.get_json(silent=True) or {} + email = (data.get("email") or "").strip().lower() + password = data.get("password") or "" + + async with session_scope() as db: + user = await db.scalar(select(User).where(User.email == email)) + if user is None or not user.password_hash or not verify_password(password, user.password_hash): + return jsonify({"error": "invalid email or password"}), 401 + session[SESSION_KEY] = str(user.id) + return jsonify(_serialize_user(user)) + + +@bp.post("/logout") +async def logout(): + session.pop(SESSION_KEY, None) + return jsonify({"ok": True}) + + +@bp.get("/me") +@login_required +async def me(): + async with session_scope() as db: + user = await db.get(User, g.user_id) + if user is None: + session.pop(SESSION_KEY, None) + return jsonify({"error": "authentication required"}), 401 + return jsonify(_serialize_user(user)) diff --git a/src/thoughtsync/config.py b/src/thoughtsync/config.py new file mode 100644 index 0000000..a9f3cf8 --- /dev/null +++ b/src/thoughtsync/config.py @@ -0,0 +1,52 @@ +from __future__ import annotations + +import os +import secrets +from pathlib import Path + + +class Config: + """Runtime configuration. + + Values come from environment variables with working local-dev defaults, so the + app boots with zero configuration (family rule 26 — integrations default to + working, never coerce setup). As the product grows, anything an operator wants + to tune moves into a DB-backed Settings UI (rule 25); env is bootstrap only. + """ + + # Where runtime-generated secrets + uploaded media live. + DATA_DIR = os.environ.get("THOUGHTSYNC_DATA_DIR", "/var/thoughtsync") + # Empty -> defaults to /media (see media_root()). + MEDIA_ROOT = os.environ.get("THOUGHTSYNC_MEDIA_ROOT", "") + + # postgresql+asyncpg URL. Mirrors alembic.ini's local-dev default. + DATABASE_URL = os.environ.get( + "THOUGHTSYNC_DATABASE_URL", + "postgresql+asyncpg://thoughtsync:thoughtsync@localhost:5432/thoughtsync", + ) + + # Auth session cookie: a capture app should rarely log you out, so keep it long. + AUTH_TTL_SECONDS = 60 * 60 * 24 * 30 # 30 days + + @classmethod + def media_root(cls) -> Path: + return Path(cls.MEDIA_ROOT or os.path.join(cls.DATA_DIR, "media")) + + @classmethod + def secret_key(cls) -> bytes: + """Secret used to sign the auth session cookie. + + Read from env if set; otherwise read/generate a persistent key file under + DATA_DIR so signed sessions survive restarts (no forced re-login on deploy). + """ + env = os.environ.get("THOUGHTSYNC_SECRET_KEY") + if env: + return env.encode() + data_dir = Path(cls.DATA_DIR) + data_dir.mkdir(parents=True, exist_ok=True) + key_file = data_dir / "secret_key" + if key_file.exists(): + return key_file.read_bytes() + key = secrets.token_bytes(32) + key_file.write_bytes(key) + return key diff --git a/src/thoughtsync/db.py b/src/thoughtsync/db.py new file mode 100644 index 0000000..c36e20e --- /dev/null +++ b/src/thoughtsync/db.py @@ -0,0 +1,41 @@ +from __future__ import annotations + +from sqlalchemy.ext.asyncio import AsyncEngine, AsyncSession, async_sessionmaker, create_async_engine + +from .config import Config + +_engine: AsyncEngine | None = None +_sessionmaker: async_sessionmaker[AsyncSession] | None = None + + +def get_engine() -> AsyncEngine: + """Lazily create the process-wide async engine. + + Lazy so the app (and unit tests) can import/boot without a live database — + only routes that actually touch data open a connection. + """ + global _engine, _sessionmaker + if _engine is None: + _engine = create_async_engine(Config.DATABASE_URL, pool_pre_ping=True) + _sessionmaker = async_sessionmaker(_engine, expire_on_commit=False) + return _engine + + +def get_sessionmaker() -> async_sessionmaker[AsyncSession]: + if _sessionmaker is None: + get_engine() + assert _sessionmaker is not None + return _sessionmaker + + +def session_scope() -> AsyncSession: + """Open a new AsyncSession. Use as `async with session_scope() as db: ...`.""" + return get_sessionmaker()() + + +async def dispose_engine() -> None: + global _engine, _sessionmaker + if _engine is not None: + await _engine.dispose() + _engine = None + _sessionmaker = None diff --git a/src/thoughtsync/models/__init__.py b/src/thoughtsync/models/__init__.py new file mode 100644 index 0000000..7545915 --- /dev/null +++ b/src/thoughtsync/models/__init__.py @@ -0,0 +1,7 @@ +from __future__ import annotations + +from sqlalchemy.orm import DeclarativeBase + + +class Base(DeclarativeBase): + """Declarative base for all ORM models. Alembic reads Base.metadata.""" diff --git a/src/thoughtsync/models/all.py b/src/thoughtsync/models/all.py new file mode 100644 index 0000000..265278f --- /dev/null +++ b/src/thoughtsync/models/all.py @@ -0,0 +1,6 @@ +"""Import every model module so Base.metadata is fully populated for Alembic. + +Imported for side effects only (model registration on Base.metadata). +""" + +from . import group, share, user # noqa: F401 diff --git a/src/thoughtsync/models/group.py b/src/thoughtsync/models/group.py new file mode 100644 index 0000000..857089f --- /dev/null +++ b/src/thoughtsync/models/group.py @@ -0,0 +1,39 @@ +from __future__ import annotations + +import uuid +from datetime import datetime + +from sqlalchemy import DateTime, ForeignKey, Text, UniqueConstraint, func +from sqlalchemy.dialects.postgresql import UUID +from sqlalchemy.orm import Mapped, mapped_column + +from . import Base + + +class Group(Base): + """A named set of users. Sharing a resource with a group grants it to every + member (see thoughtsync.acl.visible_to_user).""" + + __tablename__ = "groups" + + id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4) + name: Mapped[str] = mapped_column(Text(), nullable=False) + owner_id: Mapped[uuid.UUID | None] = mapped_column( + UUID(as_uuid=True), ForeignKey("users.id", ondelete="SET NULL"), nullable=True + ) + created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False, server_default=func.now()) + + +class GroupMember(Base): + __tablename__ = "group_members" + __table_args__ = (UniqueConstraint("group_id", "user_id", name="uq_group_members_group_user"),) + + id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4) + group_id: Mapped[uuid.UUID] = mapped_column( + UUID(as_uuid=True), ForeignKey("groups.id", ondelete="CASCADE"), nullable=False + ) + user_id: Mapped[uuid.UUID] = mapped_column( + UUID(as_uuid=True), ForeignKey("users.id", ondelete="CASCADE"), nullable=False + ) + role: Mapped[str] = mapped_column(Text(), nullable=False, server_default="member") + created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False, server_default=func.now()) diff --git a/src/thoughtsync/models/share.py b/src/thoughtsync/models/share.py new file mode 100644 index 0000000..08621c7 --- /dev/null +++ b/src/thoughtsync/models/share.py @@ -0,0 +1,41 @@ +from __future__ import annotations + +import uuid +from datetime import datetime + +from sqlalchemy import CheckConstraint, DateTime, ForeignKey, Text, func +from sqlalchemy.dialects.postgresql import UUID +from sqlalchemy.orm import Mapped, mapped_column + +from . import Base + + +class Share(Base): + """An additional access grant on one resource (identified by type + id). + + A resource's OWNER is tracked on the resource row itself (its owner_id column); + this table records grants BEYOND the owner — to a single user or to a whole + group. It is polymorphic (resource_type + resource_id) so every future + shareable entity (notes first, in M1) reuses one table and one ACL predicate. + Exactly one of shared_with_user_id / shared_with_group_id is set. + """ + + __tablename__ = "shares" + __table_args__ = ( + CheckConstraint( + "(shared_with_user_id IS NOT NULL) <> (shared_with_group_id IS NOT NULL)", + name="ck_shares_one_target", + ), + ) + + id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4) + resource_type: Mapped[str] = mapped_column(Text(), nullable=False) + resource_id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), nullable=False) + shared_with_user_id: Mapped[uuid.UUID | None] = mapped_column( + UUID(as_uuid=True), ForeignKey("users.id", ondelete="CASCADE"), nullable=True + ) + shared_with_group_id: Mapped[uuid.UUID | None] = mapped_column( + UUID(as_uuid=True), ForeignKey("groups.id", ondelete="CASCADE"), nullable=True + ) + permission: Mapped[str] = mapped_column(Text(), nullable=False, server_default="view") + created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False, server_default=func.now()) diff --git a/src/thoughtsync/models/user.py b/src/thoughtsync/models/user.py new file mode 100644 index 0000000..a6fe3ad --- /dev/null +++ b/src/thoughtsync/models/user.py @@ -0,0 +1,25 @@ +from __future__ import annotations + +import uuid +from datetime import datetime + +from sqlalchemy import Boolean, DateTime, Text, func +from sqlalchemy.dialects.postgresql import CITEXT, UUID +from sqlalchemy.orm import Mapped, mapped_column + +from . import Base + + +class User(Base): + __tablename__ = "users" + + id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4) + # CITEXT so lookups/uniqueness are case-insensitive without lower() everywhere. + email: Mapped[str] = mapped_column(CITEXT(), nullable=False, unique=True) + email_verified: Mapped[bool] = mapped_column(Boolean(), nullable=False, server_default=func.false()) + # Nullable: leaves room for external-identity-only accounts later (rule 26). + password_hash: Mapped[str | None] = mapped_column(Text(), nullable=True) + display_name: Mapped[str] = mapped_column(Text(), nullable=False) + avatar_path: Mapped[str | None] = mapped_column(Text(), nullable=True) + created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False, server_default=func.now()) + updated_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False, server_default=func.now()) diff --git a/src/thoughtsync/security.py b/src/thoughtsync/security.py new file mode 100644 index 0000000..4ba68f5 --- /dev/null +++ b/src/thoughtsync/security.py @@ -0,0 +1,18 @@ +from __future__ import annotations + +import bcrypt + +# bcrypt hashes at most 72 bytes and bcrypt>=4 raises on longer input, so we +# truncate defensively — long passphrases stay valid instead of erroring. +_MAX_BCRYPT_BYTES = 72 + + +def hash_password(password: str) -> str: + return bcrypt.hashpw(password.encode("utf-8")[:_MAX_BCRYPT_BYTES], bcrypt.gensalt()).decode("utf-8") + + +def verify_password(password: str, password_hash: str) -> bool: + try: + return bcrypt.checkpw(password.encode("utf-8")[:_MAX_BCRYPT_BYTES], password_hash.encode("utf-8")) + except (ValueError, TypeError): + return False diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 0000000..ce0bdc5 --- /dev/null +++ b/tests/conftest.py @@ -0,0 +1,13 @@ +import pytest + +from thoughtsync.config import Config + + +@pytest.fixture(autouse=True) +def _isolated_data_dir(tmp_path, monkeypatch): + """Point DATA_DIR at a writable temp dir so create_app() can generate its + signing key without needing /var/thoughtsync to exist (DB-free unit tests).""" + data_dir = tmp_path / "data" + monkeypatch.setattr(Config, "DATA_DIR", str(data_dir)) + monkeypatch.setattr(Config, "MEDIA_ROOT", str(data_dir / "media")) + yield diff --git a/tests/test_acl.py b/tests/test_acl.py new file mode 100644 index 0000000..cf7c7b9 --- /dev/null +++ b/tests/test_acl.py @@ -0,0 +1,18 @@ +import uuid + +from thoughtsync.acl import visible_to_user +from thoughtsync.models.user import User + + +def test_visible_to_user_builds_owner_or_shared_predicate(): + """DB-free smoke test: the predicate compiles and references both the direct + (shares) and group (group_members) grant paths. Functional row-visibility is + verified against a live database in M1, once notes are a real shareable + resource (family practice: DB-backed tests run against the dev image, not CI). + """ + uid = uuid.uuid4() + # User stands in as a shareable resource purely to exercise the SQL builder. + clause = visible_to_user("note", User.id, User.id, uid) + sql = str(clause).lower() + assert "shares" in sql + assert "group_members" in sql diff --git a/tests/test_app.py b/tests/test_app.py new file mode 100644 index 0000000..942563f --- /dev/null +++ b/tests/test_app.py @@ -0,0 +1,29 @@ +import pytest + +from thoughtsync.app import create_app + + +@pytest.fixture +def app(): + return create_app() + + +async def test_health_ok(app): + client = app.test_client() + resp = await client.get("/api/health") + assert resp.status_code == 200 + data = await resp.get_json() + assert data["status"] == "ok" + assert "version" in data + + +async def test_me_requires_auth(app): + client = app.test_client() + resp = await client.get("/api/auth/me") + assert resp.status_code == 401 + + +async def test_unknown_api_route_404s(app): + client = app.test_client() + resp = await client.get("/api/does-not-exist") + assert resp.status_code == 404 diff --git a/tests/test_security.py b/tests/test_security.py new file mode 100644 index 0000000..3e45742 --- /dev/null +++ b/tests/test_security.py @@ -0,0 +1,16 @@ +from thoughtsync.security import hash_password, verify_password + + +def test_password_roundtrip(): + h = hash_password("correct horse battery staple") + assert verify_password("correct horse battery staple", h) + assert not verify_password("wrong password", h) + + +def test_password_hash_is_salted(): + # Same input hashes differently each time (random salt). + assert hash_password("same-input") != hash_password("same-input") + + +def test_verify_rejects_garbage_hash(): + assert not verify_password("whatever", "not-a-bcrypt-hash")