diff --git a/backend/Dockerfile b/backend/Dockerfile index 88cbb1c..6fe78ad 100644 --- a/backend/Dockerfile +++ b/backend/Dockerfile @@ -16,7 +16,8 @@ COPY app ./app ENV ATTACHMENTS_PATH=/data/attachments -RUN mkdir -p /data/attachments +ENV FAVICONS_PATH=/data/favicons +RUN mkdir -p /data/attachments /data/favicons EXPOSE 8134 CMD ["sh", "-c", "uv run --no-dev alembic upgrade head && uv run --no-dev uvicorn app.main:app --host 0.0.0.0 --port 8134"] diff --git a/backend/alembic/versions/b1c2d3e4f5a6_project_color_site.py b/backend/alembic/versions/b1c2d3e4f5a6_project_color_site.py new file mode 100644 index 0000000..e78aa5e --- /dev/null +++ b/backend/alembic/versions/b1c2d3e4f5a6_project_color_site.py @@ -0,0 +1,29 @@ +"""projects.color и projects.site_url (цвет-метка и сайт проекта, 0.63). + +Revision ID: b1c2d3e4f5a6 +Revises: a9b8c7d6e5f4 +Create Date: 2026-10-10 + +""" + +from collections.abc import Sequence + +import sqlalchemy as sa + +from alembic import op + +revision: str = "b1c2d3e4f5a6" +down_revision: str | Sequence[str] | None = "a9b8c7d6e5f4" +branch_labels: str | Sequence[str] | None = None +depends_on: str | Sequence[str] | None = None + + +def upgrade() -> None: + # Оба поля необязательные — существующие проекты остаются без метки и сайта + op.add_column("projects", sa.Column("color", sa.String(length=7), nullable=True)) + op.add_column("projects", sa.Column("site_url", sa.String(length=2048), nullable=True)) + + +def downgrade() -> None: + op.drop_column("projects", "site_url") + op.drop_column("projects", "color") diff --git a/backend/alembic/versions/c3d4e5f6a7b8_project_pinned.py b/backend/alembic/versions/c3d4e5f6a7b8_project_pinned.py new file mode 100644 index 0000000..c3c6fcf --- /dev/null +++ b/backend/alembic/versions/c3d4e5f6a7b8_project_pinned.py @@ -0,0 +1,30 @@ +"""projects.pinned (булавка проекта, 0.66). + +Revision ID: c3d4e5f6a7b8 +Revises: b1c2d3e4f5a6 +Create Date: 2026-10-10 + +""" + +from collections.abc import Sequence + +import sqlalchemy as sa + +from alembic import op + +revision: str = "c3d4e5f6a7b8" +down_revision: str | Sequence[str] | None = "b1c2d3e4f5a6" +branch_labels: str | Sequence[str] | None = None +depends_on: str | Sequence[str] | None = None + + +def upgrade() -> None: + # NOT NULL со server_default: у существующих проектов булавки нет + op.add_column( + "projects", + sa.Column("pinned", sa.Boolean(), nullable=False, server_default=sa.false()), + ) + + +def downgrade() -> None: + op.drop_column("projects", "pinned") diff --git a/backend/alembic/versions/d4e5f6a7b8c9_project_repository.py b/backend/alembic/versions/d4e5f6a7b8c9_project_repository.py new file mode 100644 index 0000000..75585da --- /dev/null +++ b/backend/alembic/versions/d4e5f6a7b8c9_project_repository.py @@ -0,0 +1,27 @@ +"""projects.repository_url (репозиторий проекта, 0.67). + +Revision ID: d4e5f6a7b8c9 +Revises: c3d4e5f6a7b8 +Create Date: 2026-10-10 + +""" + +from collections.abc import Sequence + +import sqlalchemy as sa + +from alembic import op + +revision: str = "d4e5f6a7b8c9" +down_revision: str | Sequence[str] | None = "c3d4e5f6a7b8" +branch_labels: str | Sequence[str] | None = None +depends_on: str | Sequence[str] | None = None + + +def upgrade() -> None: + # Поле необязательное, данные не трогаем: описания-ссылки остаются как были + op.add_column("projects", sa.Column("repository_url", sa.String(2048), nullable=True)) + + +def downgrade() -> None: + op.drop_column("projects", "repository_url") diff --git a/backend/app/api/projects.py b/backend/app/api/projects.py index 2641a98..a91d7ad 100644 --- a/backend/app/api/projects.py +++ b/backend/app/api/projects.py @@ -3,17 +3,23 @@ from typing import Any, cast from fastapi import APIRouter, BackgroundTasks, HTTPException, Query, Response +from pydantic import BaseModel from sqlalchemy import select from app.dependencies import DbDep, UserIdDep from app.models import CoinEvent, Project from app.realtime import publish from app.schemas import ProjectCreate, ProjectOut, ProjectUpdate +from app.services import favicon, readme from app.services.detailing import summarize_project from app.services.xp import XP_CREATE_PROJECT, coins_for_create, grant_create_xp router = APIRouter(prefix="/api/projects", tags=["projects"]) + +class ProjectReadmeOut(BaseModel): + content: str + # «Закрыт» (archived) больше нет — вместо него архив (ТЗ 3.8.1) VALID_RELEVANCE = {"active", "paused"} @@ -38,7 +44,15 @@ ).first() if exists is not None: raise HTTPException(status_code=409, detail="Project name already exists") - project = Project(user_id=user_id, name=schema.name, priority=schema.priority, note=schema.note) + project = Project( + user_id=user_id, + name=schema.name, + priority=schema.priority, + note=schema.note, + color=schema.color, + site_url=schema.site_url, + repository_url=schema.repository_url, + ) db.add(project) db.flush() # Микронаграда за создание (ТЗ 3.13): чуть больше XP, чем за задачу @@ -128,6 +142,48 @@ return project +@router.get("/{project_id}/favicon") +async def project_favicon(project_id: int, db: DbDep, user_id: UserIdDep) -> Response: + """Иконка сайта проекта (0.63): тянет бэкенд-прокси с кэшем (ТЗ 3.11). + + Привязка к проекту, а не ?url= — иначе эндпоинт стал бы открытым прокси + для сканирования произвольных адресов. Нет site_url или иконки — 404. + """ + project = _get_project_or_404(db, project_id, user_id) + if not project.site_url: + raise HTTPException(status_code=404, detail="Project has no site_url") + found = await favicon.fetch_favicon(project.site_url) + if found is None: + raise HTTPException(status_code=404, detail="Favicon not found") + data, mime = found + return Response( + content=data, + media_type=mime, + headers={ + # Ресурс за сессией; nosniff — как у вложений (api/attachments.py) + "Cache-Control": "private, max-age=86400", + "X-Content-Type-Options": "nosniff", + }, + ) + + +@router.get("/{project_id}/readme", response_model=ProjectReadmeOut) +async def project_readme(project_id: int, db: DbDep, user_id: UserIdDep) -> ProjectReadmeOut: + """README репозитория проекта (0.67) — второе описание рядом с заметкой. + + Привязка к проекту, а не `?url=` — как у фавикона: эндпоинт за сессией и не + превращается в открытый прокси для чтения произвольных адресов. Нет + repository_url или README не нашёлся — 404 (фронт показывает «README не найден»). + """ + project = _get_project_or_404(db, project_id, user_id) + if not project.repository_url: + raise HTTPException(status_code=404, detail="Project has no repository_url") + content = await readme.fetch_readme(project.repository_url) + if content is None: + raise HTTPException(status_code=404, detail="README not found") + return ProjectReadmeOut(content=content) + + @router.delete("/{project_id}") async def delete_project(project_id: int, db: DbDep, user_id: UserIdDep) -> dict[str, bool]: project = _get_project_or_404(db, project_id, user_id) diff --git a/backend/app/api/repo_readme.py b/backend/app/api/repo_readme.py index 0617975..1c4d2c1 100644 --- a/backend/app/api/repo_readme.py +++ b/backend/app/api/repo_readme.py @@ -1,8 +1,11 @@ -"""README.md репозитория для задач, где описание — только ссылка на git. +"""README.md репозитория для задачи, где описание — только ссылка на git. GET /api/repo-readme?url=... — селф-хостед git-системы поддерживаются эвристикой (пробуем известные пути к сырому README: API github/gitlab/gitea, raw-пути web-интерфейсов). Не нашли — 404, фронт оставляет описание как было. + +У проекта это снято (0.67): там репозиторий — отдельное поле, а README тянет +привязанный к проекту `/api/projects/{id}/readme` (api/projects.py). """ from fastapi import APIRouter, HTTPException diff --git a/backend/app/config.py b/backend/app/config.py index 20973b9..20f7eaa 100644 --- a/backend/app/config.py +++ b/backend/app/config.py @@ -37,6 +37,9 @@ # Хранилище вложений (M2) attachments_path: str = "data/attachments" + # Кэш favicon сайтов проектов (0.63) + favicons_path: str = "data/favicons" + @model_validator(mode="after") def _session_secret_must_be_set(self) -> "Settings": if self.session_secret == DEFAULT_SESSION_SECRET: diff --git a/backend/app/mcp_server.py b/backend/app/mcp_server.py index aeb4aa3..35f168b 100644 --- a/backend/app/mcp_server.py +++ b/backend/app/mcp_server.py @@ -23,7 +23,13 @@ from app.db import get_session_factory from app.models import Attachment, CoinEvent, Document, Project, Tag, Task, utcnow from app.realtime import publish -from app.schemas import DEADLINE_PERIODS, RECUR_KINDS +from app.schemas import ( + DEADLINE_PERIODS, + RECUR_KINDS, + normalize_color, + normalize_repository_url, + normalize_site_url, +) from app.services.closing import handle_task_closed from app.services.detailing import detail_task, summarize_project from app.services.xp import coins_for_create, grant_create_xp @@ -72,8 +78,15 @@ по её id. УПРАВЛЕНИЕ ПРОЕКТАМИ -create_project(name, note) — новый проект; update_project — переименование, -заметка, relevance_status ("active" | "paused"), priority. Архив вместо +create_project(name, note, priority, color, site_url, repository_url) — новый проект; +update_project — переименование, заметка, relevance_status ("active" | +"paused"), priority, color, site_url, repository_url, pinned. color — цвет-метка +проекта в HEX #rrggbb, site_url — сайт проекта (http(s)://...), по нему в +интерфейсе подтягивается иконка сайта; repository_url — репозиторий проекта +(http(s)://...), из него в интерфейсе подтягивается README вторым описанием. +pinned=true закрепляет проект, pinned=false открепляет — закреплённые идут +первыми в списке проектов. color, site_url и repository_url необязательны; +пустая строка ("") в update_project убирает значение. Архив вместо удаления: archive_project прячет проект вместе с задачами из активных (история сохраняется), restore_project возвращает. Безвозвратного удаления проектов в MCP нет намеренно. @@ -230,6 +243,10 @@ "name": project.name, "relevance_status": project.relevance_status, "is_archived": project.is_archived, + "pinned": project.pinned, + "color": project.color, + "site_url": project.site_url, + "repository_url": project.repository_url, "open_tasks": open_tasks, "note": note[:300] + "…" if len(note) > 300 else note, } @@ -597,6 +614,7 @@ ) -> list[dict[str, Any]]: """Список проектов: id, имя, заметка (сокращённая), число открытых задач. + Закреплённые проекты (pinned) идут первыми — как в списке проектов в UI. Вызывайте ПЕРЕД созданием задач: выберите подходящий проект и передавайте его project_id (или project_name) в create_task/update_task — так задачи остаются в контексте своего проекта. @@ -604,7 +622,12 @@ user_id = _mcp_user(ctx) session = get_session_factory()() try: - stmt = select(Project).where(Project.user_id == user_id).order_by(Project.name) + stmt = ( + select(Project) + .where(Project.user_id == user_id) + # закреплённые первыми (0.66) — как в списке проектов в UI + .order_by(Project.pinned.desc(), Project.name) + ) if not include_archived: stmt = stmt.where(Project.is_archived.is_(False)) return [_project_compact(p, session) for p in session.scalars(stmt).all()] @@ -675,6 +698,18 @@ Field(description="Заметка проекта: цель, договорённости, ссылки (markdown)"), ] = None, priority: Annotated[int | None, Field(description="Приоритет 0-10")] = None, + color: Annotated[ + str | None, + Field(description="Цвет-метка проекта: HEX #rrggbb (например #7aa2f7)"), + ] = None, + site_url: Annotated[ + str | None, + Field(description="Сайт проекта, http(s)://... — по нему подтягивается favicon"), + ] = None, + repository_url: Annotated[ + str | None, + Field(description="Репозиторий проекта, http(s)://... — из него подтягивается README"), + ] = None, ctx: Context[Any, Any, Any] | None = None, ) -> dict[str, Any]: """Создать проект — контейнер задач со своей заметкой. @@ -686,6 +721,10 @@ user_id = _mcp_user(ctx) if not name.strip(): raise ValueError("name cannot be empty") + # нормализаторы сами объясняют формат (color=#rrggbb, site_url=http(s)://host/...) + color = normalize_color(color) + site_url = normalize_site_url(site_url) + repository_url = normalize_repository_url(repository_url) session = get_session_factory()() try: exists = cast( @@ -697,7 +736,15 @@ f"Project '{name}' already exists (id {exists.id}) — pass project_id=... " "to create_task/update_task instead of creating a duplicate." ) - project = Project(user_id=user_id, name=name, note=note or "", priority=priority) + project = Project( + user_id=user_id, + name=name, + note=note or "", + priority=priority, + color=color, + site_url=site_url, + repository_url=repository_url, + ) session.add(project) session.flush() grant_create_xp(session, "create_project", user_id) @@ -727,10 +774,28 @@ str | None, Field(description="active (в работе) | paused (приостановлен)") ] = None, priority: Annotated[int | None, Field(description="Приоритет 0-10")] = None, + color: Annotated[ + str | None, + Field(description="Новый цвет-метка проекта, HEX #rrggbb; пустая строка '' — убрать"), + ] = None, + site_url: Annotated[ + str | None, + Field(description="Новый сайт проекта, http(s)://...; пустая строка '' — убрать"), + ] = None, + repository_url: Annotated[ + str | None, + Field( + description="Новый репозиторий проекта, http(s)://...; пустая строка '' — убрать" + ), + ] = None, + pinned: Annotated[ + bool | None, Field(description="Закрепить проект (true) / открепить (false)") + ] = None, ctx: Context[Any, Any, Any] | None = None, ) -> dict[str, Any]: """Частично обновить проект — передавайте только нужные поля. + Цвет, сайт и репозиторий: пустая строка их убирает, None (не передан) — не меняет. Заметка проекта — главный контекст для агента (get_project) и автодетализации, обновляйте её по итогам договорённостей. """ @@ -767,6 +832,14 @@ project.relevance_status = relevance_status if priority is not None: project.priority = priority + if color is not None: + project.color = normalize_color(color) + if site_url is not None: + project.site_url = normalize_site_url(site_url) + if repository_url is not None: + project.repository_url = normalize_repository_url(repository_url) + if pinned is not None: + project.pinned = pinned session.commit() if note_changed: # суммаризация заметки для контекста детализации — не блокирует ответ diff --git a/backend/app/models.py b/backend/app/models.py index 6cd8326..8fa2d68 100644 --- a/backend/app/models.py +++ b/backend/app/models.py @@ -212,10 +212,19 @@ relevance_status: Mapped[str] = mapped_column(String(20), default="active") # Архив (ТЗ 3.8.1): проект уезжает в историю со всеми задачами is_archived: Mapped[bool] = mapped_column(Boolean, default=False) + # Булавка проекта (0.66): закреплённые держатся отдельной секцией сверху списка + pinned: Mapped[bool] = mapped_column(Boolean, default=False) priority: Mapped[int | None] = mapped_column(Integer, nullable=True, default=None) # Краткая суммаризация заметки для контекста LLM (генерится при правке # заметки; null — суммаризации ещё нет, контекст берётся обрезкой заметки) summary: Mapped[str | None] = mapped_column(Text, nullable=True, default=None) + # Цвет-метка проекта (0.63): кружок в списках и метка у задач, HEX #rrggbb + color: Mapped[str | None] = mapped_column(String(7), nullable=True, default=None) + # Сайт проекта (0.63): быстрый переход и favicon (тянет бэкенд-прокси, ТЗ 3.11) + site_url: Mapped[str | None] = mapped_column(String(2048), nullable=True, default=None) + # Репозиторий проекта (0.67): README оттуда показывается вторым описанием — + # карточкой рядом с заметкой (тянет бэкенд-прокси, ТЗ 3.8) + repository_url: Mapped[str | None] = mapped_column(String(2048), nullable=True, default=None) tasks: Mapped[list[Task]] = relationship(back_populates="project") diff --git a/backend/app/schemas.py b/backend/app/schemas.py index 4888250..1425e07 100644 --- a/backend/app/schemas.py +++ b/backend/app/schemas.py @@ -1,15 +1,63 @@ """Pydantic-схемы API M1.""" +import re from datetime import date, datetime from typing import Any +from urllib.parse import urlsplit -from pydantic import BaseModel, ConfigDict, Field +from pydantic import BaseModel, ConfigDict, Field, field_validator # --- Task --- DEADLINE_PERIODS = ("day", "week", "month", "year") RECUR_KINDS = ("interval", "weekdays", "monthly") +# --- Цвет и сайт проекта (0.63) --- + +HEX_COLOR_RE = re.compile(r"^#[0-9a-fA-F]{6}$") +MAX_SITE_URL = 2048 + + +def normalize_color(value: str | None) -> str | None: + """Цвет проекта: ''/None → None (убрать), иначе #rrggbb в нижнем регистре. + + Проверяем только формат, не палитру: палитра — представление фронта (она + меняется вместе с китом), а из MCP валиден любой #rrggbb. + """ + if value is None: + return None + text = value.strip() + if not text: + return None + if not HEX_COLOR_RE.match(text): + raise ValueError("color must be HEX #rrggbb") + return text.lower() + + +def _normalize_http_url(value: str | None, field: str) -> str | None: + """URL проекта (сайт/репозиторий): ''/None → None (убрать), иначе http(s) с хостом.""" + if value is None: + return None + text = value.strip() + if not text: + return None + if len(text) > MAX_SITE_URL: + raise ValueError(f"{field} is too long (max {MAX_SITE_URL})") + parts = urlsplit(text) + if parts.scheme not in ("http", "https") or not parts.netloc: + raise ValueError(f"{field} must be http(s)://host/...") + return text + + +def normalize_site_url(value: str | None) -> str | None: + return _normalize_http_url(value, "site_url") + + +def normalize_repository_url(value: str | None) -> str | None: + """Репозиторий проекта (0.67): форму «owner/repo» не проверяем — не угадали, + так эндпоинт README честно ответит 404.""" + return _normalize_http_url(value, "repository_url") + class TagOut(BaseModel): model_config = ConfigDict(from_attributes=True) @@ -25,9 +73,13 @@ name: str relevance_status: str is_archived: bool + pinned: bool priority: int | None note: str document_id: int | None + color: str | None + site_url: str | None + repository_url: str | None class TaskCreate(BaseModel): @@ -129,13 +181,52 @@ name: str = Field(min_length=1, max_length=200) priority: int | None = None note: str = "" + color: str | None = None + site_url: str | None = None + repository_url: str | None = None + + @field_validator("color") + @classmethod + def _color(cls, value: str | None) -> str | None: + return normalize_color(value) + + @field_validator("site_url") + @classmethod + def _site_url(cls, value: str | None) -> str | None: + return normalize_site_url(value) + + @field_validator("repository_url") + @classmethod + def _repository_url(cls, value: str | None) -> str | None: + return normalize_repository_url(value) class ProjectUpdate(BaseModel): name: str | None = Field(None, max_length=200) relevance_status: str | None = None + # Булавка (0.66): закрепление проекта из списка и со страницы проекта + pinned: bool | None = None priority: int | None = None note: str | None = None + color: str | None = None + site_url: str | None = None + # Репозиторий (0.67): из него подтягивается README — второе описание проекта + repository_url: str | None = None + + @field_validator("color") + @classmethod + def _color(cls, value: str | None) -> str | None: + return normalize_color(value) + + @field_validator("site_url") + @classmethod + def _site_url(cls, value: str | None) -> str | None: + return normalize_site_url(value) + + @field_validator("repository_url") + @classmethod + def _repository_url(cls, value: str | None) -> str | None: + return normalize_repository_url(value) class TagCreate(BaseModel): diff --git a/backend/app/services/favicon.py b/backend/app/services/favicon.py new file mode 100644 index 0000000..9d45998 --- /dev/null +++ b/backend/app/services/favicon.py @@ -0,0 +1,269 @@ +"""Favicon сайта проекта: бэкенд-прокси с кэшем на диске. + +Данные не покидают VPS (ТЗ 3.11): никаких сторонних favicon-сервисов, иконку +тянем с самого сайта проекта. `site_url` может задать агент через MCP, то есть +URL недоверенный: проверяем схему, резолвим хост и отвергаем непубличные адреса +(SSRF), редиректы проходим вручную с проверкой каждого хоста, ограничиваем +размер и тип. SVG не принимаем — скриптуемый формат (ср. FORBIDDEN_MIMES +в api/attachments.py: прямой переход на иконку в origin приложения = XSS). + +Кэш — файлы в `settings.favicons_path`: + . — найденная иконка, ext из снаффа magic-bytes (TTL 7 дней) + .none — «иконки нет», маркер с коротким TTL (1 час) +Ключ глобальный (не per-user): сайт один и тот же для всех проектов. +""" + +from __future__ import annotations + +import asyncio +import hashlib +import ipaddress +import os +import re +import socket +import time +from pathlib import Path +from urllib.parse import urljoin, urlsplit + +import httpx + +from app.config import get_settings +from app.schemas import MAX_SITE_URL + +CONNECT_TIMEOUT = 3.0 +READ_TIMEOUT = 4.0 +TOTAL_TIMEOUT = 6.0 # общий бюджет на одну иконку +MAX_BYTES = 256_000 # favicon больше не бывает; защита от бесконечного тела +MAX_HTML_BYTES = 256_000 # сколько читаем из корня сайта +MAX_REDIRECTS = 3 +CACHE_TTL = 7 * 24 * 3600.0 # найденная иконка живёт неделю +NEGATIVE_TTL = 3600.0 # «иконки нет» перепроверяем через час +MAX_CACHE_FILES = 1024 # защита от разрастания каталога + +_UA = "gntodo/1.0 (project favicon)" +_EXT_MIME = { + ".png": "image/png", + ".jpg": "image/jpeg", + ".gif": "image/gif", + ".webp": "image/webp", + ".ico": "image/x-icon", +} +# Расширение определяем по содержимому: content-type от чужого сервера — не довод +_MAGIC: tuple[tuple[bytes, str], ...] = ( + (b"\x89PNG\r\n\x1a\n", ".png"), + (b"\xff\xd8\xff", ".jpg"), + (b"GIF87a", ".gif"), + (b"GIF89a", ".gif"), + (b"\x00\x00\x01\x00", ".ico"), +) +_TAG_RE = re.compile(r"]+>", re.I) +_REL_RE = re.compile(r"rel\s*=\s*[\"']([^\"']+)[\"']", re.I) +_HREF_RE = re.compile(r"href\s*=\s*[\"']([^\"']+)[\"']", re.I) + +# Параллельные загрузки одной иконки сериализуем: не долбим чужой сайт +_FETCH_LOCK = asyncio.Lock() + + +def _cache_dir() -> Path: + path = Path(get_settings().favicons_path) + path.mkdir(parents=True, exist_ok=True) + return path + + +def _key(url: str) -> str: + return hashlib.sha1(url.encode()).hexdigest() + + +def is_public_host(host: str) -> bool: + """Хост резолвится только в публичные адреса (SSRF-барьер). + + is_global=False покрывает private/loopback/link-local/reserved/CGNAT — + в том числе 169.254.169.254 (метаданные облака). + """ + if not host: + return False + try: + infos = socket.getaddrinfo(host, None) + except (socket.gaierror, UnicodeError, OSError): + return False + if not infos: + return False + for info in infos: + raw = str(info[4][0]) + # IPv6-адреса приходят с зоной (%eth0) — для разбора она лишняя + raw = raw.split("%", 1)[0] + try: + ip = ipaddress.ip_address(raw) + except ValueError: + return False + if not ip.is_global: + return False + return True + + +def _validated(url: str) -> bool: + """URL безопасен для запроса: http(s), есть хост, хост публичный.""" + if len(url) > MAX_SITE_URL: + return False + parts = urlsplit(url) + if parts.scheme not in ("http", "https") or not parts.netloc: + return False + return is_public_host(parts.hostname or "") + + +def _sniff_ext(data: bytes) -> str | None: + """Расширение картинки по magic-bytes; None — не поддерживаемый формат.""" + for magic, ext in _MAGIC: + if data.startswith(magic): + return ext + if data[:4] == b"RIFF" and data[8:12] == b"WEBP": + return ".webp" + return None + + +async def _read_limited(resp: httpx.Response, limit: int) -> bytes | None: + """Тело ответа не длиннее limit; None — превысило (иконка не бывает такой).""" + buf = bytearray() + async for chunk in resp.aiter_bytes(): + buf += chunk + if len(buf) > limit: + return None + return bytes(buf) + + +async def _download(client: httpx.AsyncClient, url: str) -> tuple[bytes, str] | None: + """Скачать картинку по уже проверенному url; None — не картинка/ошибка. + + Возвращает байты и расширение из magic-bytes. Редиректы идём сами: + хост каждого хопа тоже проверяется. + """ + for _ in range(MAX_REDIRECTS + 1): + if not _validated(url): + return None + try: + async with client.stream("GET", url, follow_redirects=False) as resp: + if resp.status_code in (301, 302, 303, 307, 308): + location = resp.headers.get("location") + if not location: + return None + url = urljoin(url, location) + continue + if resp.status_code != 200: + return None + if "image/" not in resp.headers.get("content-type", "").lower(): + return None # html/svg/что угодно — не favicon + data = await _read_limited(resp, MAX_BYTES) + except httpx.HTTPError: + return None + if data is None: + return None + ext = _sniff_ext(data) + if ext is None: # magic-bytes не сошлись: это не png/jpg/gif/webp/ico + return None + return data, ext + return None + + +async def _icon_hrefs(client: httpx.AsyncClient, site_url: str) -> list[str]: + """Объявленные из корня сайта.""" + if not _validated(site_url): + return [] + try: + async with client.stream("GET", site_url, follow_redirects=False) as resp: + if resp.status_code != 200: + return [] + if "html" not in resp.headers.get("content-type", "").lower(): + return [] + raw = await _read_limited(resp, MAX_HTML_BYTES) + except httpx.HTTPError: + return [] + if raw is None: + return [] + out: list[str] = [] + for tag in _TAG_RE.findall(raw.decode("utf-8", "ignore")): + m_rel = _REL_RE.search(tag) + m_href = _HREF_RE.search(tag) + if not m_rel or not m_href or "icon" not in m_rel.group(1).lower(): + continue + resolved = urljoin(site_url, m_href.group(1)) + if resolved not in out: + out.append(resolved) + return out + + +def _read_cache(key: str) -> tuple[bytes, str] | None: + """Свежая иконка из кэша; None — нет или протухла.""" + for entry in _cache_dir().glob(f"{key}.*"): + if entry.suffix == ".none": + continue + mime = _EXT_MIME.get(entry.suffix) + if mime is None: + continue + try: + if time.time() - entry.stat().st_mtime < CACHE_TTL: + return entry.read_bytes(), mime + except OSError: + continue + return None + + +def _has_fresh_negative(key: str) -> bool: + """Негативный маркер ещё свежий — сайт повторно не дёргаем.""" + marker = _cache_dir() / f"{key}.none" + try: + return marker.is_file() and time.time() - marker.stat().st_mtime < NEGATIVE_TTL + except OSError: + return False + + +def _write(key: str, data: bytes, ext: str) -> None: + _cleanup() + path = _cache_dir() / f"{key}{ext}" + tmp = path.with_suffix(f"{ext}.tmp") + tmp.write_bytes(data) + os.replace(tmp, path) # атомарно: читатель не увидит половину файла + + +def _write_negative(key: str) -> None: + _cleanup() + (_cache_dir() / f"{key}.none").write_bytes(b"") + + +def _cleanup() -> None: + """Каталог не растёт бесконечно: при переполнении сносим самые старые файлы.""" + directory = _cache_dir() + entries = sorted(directory.glob("*"), key=lambda p: p.stat().st_mtime) + while len(entries) >= MAX_CACHE_FILES: + oldest = entries.pop(0) + oldest.unlink(missing_ok=True) + + +async def fetch_favicon(site_url: str) -> tuple[bytes, str] | None: + """Байты и mime favicon по site_url; None — не нашли (результат кэшируется).""" + key = _key(site_url) + cached = _read_cache(key) + if cached is not None or _has_fresh_negative(key): + return cached + async with _FETCH_LOCK: + cached = _read_cache(key) + if cached is not None or _has_fresh_negative(key): + return cached + timeout = httpx.Timeout(TOTAL_TIMEOUT, connect=CONNECT_TIMEOUT, read=READ_TIMEOUT) + headers = {"User-Agent": _UA, "Accept": "image/*;q=0.9,*/*;q=0.1"} + async with httpx.AsyncClient(timeout=timeout, headers=headers) as client: + # Сначала объявленная в HTML иконка (там png/webp, которых нет по + # /favicon.ico), затем общепринятые пути + candidates = await _icon_hrefs(client, site_url) + candidates += [ + urljoin(site_url, "/favicon.ico"), + urljoin(site_url, "/favicon.png"), + urljoin(site_url, "/apple-touch-icon.png"), + ] + for candidate in candidates: + found = await _download(client, candidate) + if found is not None: + data, ext = found + _write(key, data, ext) + return data, _EXT_MIME[ext] + _write_negative(key) + return None diff --git a/backend/app/services/readme.py b/backend/app/services/readme.py index 8734050..58a7ac8 100644 --- a/backend/app/services/readme.py +++ b/backend/app/services/readme.py @@ -1,9 +1,13 @@ -"""README.md репозитория, если описание задачи — только ссылка на git. +"""README.md по ссылке на репозиторий. + +Читателей двое: описание-ссылка задачи (`/api/repo-readme?url=`) и поле +`repository_url` проекта (0.67, `/api/projects/{id}/readme` — второе описание). Не привязываемся к GitHub/GitLab (у пользователя может быть селф-хостед): пробуем набор известных путей к сырому README — API github/gitlab/gitea и raw-пути web-интерфейсов — пока какой-нибудь не ответит markdown'ом. -Нашли — отдаём, нет — None (фронт оставляет описание как было). +Нашли — отдаём, нет — None (у задачи остаётся описание-ссылка, у проекта +фронт показывает «README не найден»). """ from __future__ import annotations @@ -25,7 +29,7 @@ _HEADERS = { # GitHub API отдаёт сырой README только с raw-мимом; остальным всё равно "Accept": "application/vnd.github.raw+json, text/markdown;q=0.9, text/plain;q=0.8, */*;q=0.7", - "User-Agent": "gntodo/1.0 (README for task description)", + "User-Agent": "gntodo/1.0 (README)", } diff --git a/backend/tests/conftest.py b/backend/tests/conftest.py index 2210dff..f1f80dc 100644 --- a/backend/tests/conftest.py +++ b/backend/tests/conftest.py @@ -9,6 +9,7 @@ # env-переменные — ДО импорта app.*: get_settings() кэшируется при первом вызове _tmpdir = tempfile.mkdtemp(prefix="gntodo-att-") os.environ.setdefault("ATTACHMENTS_PATH", _tmpdir) +os.environ.setdefault("FAVICONS_PATH", tempfile.mkdtemp(prefix="gntodo-fav-")) os.environ.setdefault("SESSION_SECRET", "test-session-secret") import pytest # noqa: E402 diff --git a/backend/tests/test_favicon.py b/backend/tests/test_favicon.py new file mode 100644 index 0000000..1eba3ed --- /dev/null +++ b/backend/tests/test_favicon.py @@ -0,0 +1,268 @@ +"""Favicon сайта проекта: SSRF-барьер, снафф типа, кэш на диске, эндпоинт.""" + +import asyncio +import time +from pathlib import Path + +import httpx +import pytest +from fastapi.testclient import TestClient + +from app.services import favicon + +PNG = b"\x89PNG\r\n\x1a\n" + b"0" * 32 + + +@pytest.fixture(autouse=True) +def clean_cache() -> None: + """Кэш-каталог между тестами чистим: путь один на процесс.""" + for entry in favicon._cache_dir().glob("*"): + entry.unlink() + + +@pytest.fixture +def public_dns(monkeypatch: pytest.MonkeyPatch) -> None: + """Резолв «всё публичное»: сеть в тестах не трогаем.""" + + def fake_getaddrinfo(*args: object, **kwargs: object) -> list[tuple]: + return [(2, 1, 6, "", ("93.184.216.34", 0))] + + monkeypatch.setattr(favicon.socket, "getaddrinfo", fake_getaddrinfo) + + +def test_private_hosts_rejected() -> None: + # приватные/loopback/link-local адреса — отказ (в т.ч. метаданные облака) + for host in ("127.0.0.1", "10.0.0.1", "192.168.1.5", "169.254.169.254", "::1"): + assert favicon.is_public_host(host) is False, host + + +def test_public_host_accepted(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setattr( + favicon.socket, "getaddrinfo", lambda *a, **k: [(2, 1, 6, "", ("93.184.216.34", 0))] + ) + assert favicon.is_public_host("example.com") is True + + +def test_unresolvable_host_rejected(monkeypatch: pytest.MonkeyPatch) -> None: + def boom(*args: object, **kwargs: object) -> list[tuple]: + raise favicon.socket.gaierror("no such host") + + monkeypatch.setattr(favicon.socket, "getaddrinfo", boom) + assert favicon.is_public_host("nope.invalid") is False + + +def test_validated_urls(public_dns: None) -> None: + assert favicon._validated("https://example.com") is True + assert favicon._validated("ftp://example.com") is False + assert favicon._validated("https://") is False + + +def test_sniff_supported_formats() -> None: + assert favicon._sniff_ext(PNG) == ".png" + assert favicon._sniff_ext(b"\x00\x00\x01\x00" + b"x" * 8) == ".ico" + assert favicon._sniff_ext(b"GIF89a" + b"x" * 8) == ".gif" + assert favicon._sniff_ext(b"\xff\xd8\xff" + b"x" * 8) == ".jpg" + assert favicon._sniff_ext(b"RIFF\x00\x00\x00\x00WEBPVP8 ") == ".webp" + # SVG не принимаем (скриптуемый формат), произвольный мусор — тоже + assert favicon._sniff_ext(b"") is None + assert favicon._sniff_ext(b"") is None + + +def _client(handler: object) -> httpx.AsyncClient: + return httpx.AsyncClient(transport=httpx.MockTransport(handler), timeout=5.0) # type: ignore[arg-type] + + +def test_download_rejects_non_image(public_dns: None) -> None: + def handler(request: httpx.Request) -> httpx.Response: + return httpx.Response(200, headers={"content-type": "text/html"}, content=b"") + + async def run() -> tuple[bytes, str] | None: + async with _client(handler) as client: + return await favicon._download(client, "https://example.com/favicon.ico") + + assert asyncio.run(run()) is None + + +def test_download_rejects_svg(public_dns: None) -> None: + svg = b'' + + def handler(request: httpx.Request) -> httpx.Response: + return httpx.Response(200, headers={"content-type": "image/svg+xml"}, content=svg) + + async def run() -> tuple[bytes, str] | None: + async with _client(handler) as client: + return await favicon._download(client, "https://example.com/icon.svg") + + # content-type картинки, но magic-bytes чужие — иконкой не считаем + assert asyncio.run(run()) is None + + +def test_download_stops_on_oversize(public_dns: None) -> None: + def handler(request: httpx.Request) -> httpx.Response: + return httpx.Response( + 200, + headers={"content-type": "image/png"}, + content=PNG + b"0" * favicon.MAX_BYTES, + ) + + async def run() -> tuple[bytes, str] | None: + async with _client(handler) as client: + return await favicon._download(client, "https://example.com/favicon.png") + + assert asyncio.run(run()) is None + + +def test_download_follows_redirect_and_checks_host(public_dns: None) -> None: + seen: list[str] = [] + + def handler(request: httpx.Request) -> httpx.Response: + seen.append(request.url.path) + if request.url.path == "/favicon.ico": + return httpx.Response(302, headers={"location": "/icon.png"}) + return httpx.Response(200, headers={"content-type": "image/png"}, content=PNG) + + async def run() -> tuple[bytes, str] | None: + async with _client(handler) as client: + return await favicon._download(client, "https://example.com/favicon.ico") + + assert asyncio.run(run()) == (PNG, ".png") + assert seen == ["/favicon.ico", "/icon.png"] + + +def test_download_redirect_to_private_host_rejected(monkeypatch: pytest.MonkeyPatch) -> None: + def fake_getaddrinfo(*args: object, **kwargs: object) -> list[tuple]: + host = str(args[0]) if args else "" + ip = "127.0.0.1" if host == "internal.corp" else "93.184.216.34" + return [(2, 1, 6, "", (ip, 0))] + + monkeypatch.setattr(favicon.socket, "getaddrinfo", fake_getaddrinfo) + + def handler(request: httpx.Request) -> httpx.Response: + return httpx.Response(302, headers={"location": "http://internal.corp/secret"}) + + async def run() -> tuple[bytes, str] | None: + async with _client(handler) as client: + return await favicon._download(client, "https://example.com/favicon.ico") + + # первый хоп публичный, второй — приватный: редирект не проходим + assert asyncio.run(run()) is None + + +def test_fetch_caches_and_reuses(public_dns: None, monkeypatch: pytest.MonkeyPatch) -> None: + calls: list[str] = [] + + async def fake_download(client: object, url: str) -> tuple[bytes, str] | None: + calls.append(url) + return (PNG, ".png") + + monkeypatch.setattr(favicon, "_download", fake_download) + + async def run() -> tuple[bytes, str] | None: + return await favicon.fetch_favicon("https://example.com") + + first = asyncio.run(run()) + assert first == (PNG, "image/png") + fetched = len(calls) + # второй запрос отдаётся с диска — сеть не трогаем + assert asyncio.run(run()) == (PNG, "image/png") + assert len(calls) == fetched + # кэш переживает рестарт процесса (файл на диске) + files = [p for p in favicon._cache_dir().glob("*") if p.suffix == ".png"] + assert files and files[0].read_bytes() == PNG + + +def test_fetch_negative_cached(public_dns: None, monkeypatch: pytest.MonkeyPatch) -> None: + calls: list[str] = [] + + async def fake_download(client: object, url: str) -> tuple[bytes, str] | None: + calls.append(url) + return None + + monkeypatch.setattr(favicon, "_download", fake_download) + + async def run() -> tuple[bytes, str] | None: + return await favicon.fetch_favicon("https://no-icon.example") + + assert asyncio.run(run()) is None + tried = len(calls) + assert tried > 0 + # негативный маркер: повторно сайт не долбим + assert asyncio.run(run()) is None + assert len(calls) == tried + # маркер протух — перепроверяем + real_time = time.time + monkeypatch.setattr(favicon.time, "time", lambda: real_time() + favicon.NEGATIVE_TTL + 1) + assert asyncio.run(run()) is None + assert len(calls) > tried + + +def test_cache_dir_created(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: + target = tmp_path / "favicons" + settings = favicon.get_settings() + monkeypatch.setattr(settings, "favicons_path", str(target)) + assert favicon._cache_dir() == target + assert target.is_dir() + + +def test_endpoint_404_without_site_url(client: TestClient) -> None: + project_id = client.post("/api/projects", json={"name": "Без сайта"}).json()["id"] + assert client.get(f"/api/projects/{project_id}/favicon").status_code == 404 + + +def test_endpoint_404_when_not_found( + client: TestClient, monkeypatch: pytest.MonkeyPatch +) -> None: + project_id = client.post( + "/api/projects", json={"name": "Сайт", "site_url": "https://example.com"} + ).json()["id"] + + async def none_found(url: str) -> tuple[bytes, str] | None: + return None + + monkeypatch.setattr(favicon, "fetch_favicon", none_found) + assert client.get(f"/api/projects/{project_id}/favicon").status_code == 404 + + +def test_endpoint_serves_favicon(client: TestClient, monkeypatch: pytest.MonkeyPatch) -> None: + project_id = client.post( + "/api/projects", json={"name": "Сайт", "site_url": "https://example.com"} + ).json()["id"] + + async def found(url: str) -> tuple[bytes, str] | None: + return PNG, "image/png" + + monkeypatch.setattr(favicon, "fetch_favicon", found) + resp = client.get(f"/api/projects/{project_id}/favicon") + assert resp.status_code == 200 + assert resp.headers["content-type"] == "image/png" + assert resp.content == PNG + assert resp.headers["cache-control"] == "private, max-age=86400" + # иконки чужого сайта не должны исполняться как документ + assert resp.headers["x-content-type-options"] == "nosniff" + + +def test_endpoint_scoped_to_user(client: TestClient, monkeypatch: pytest.MonkeyPatch) -> None: + project_id = client.post( + "/api/projects", json={"name": "Сайт", "site_url": "https://example.com"} + ).json()["id"] + + async def found(url: str) -> tuple[bytes, str] | None: + return PNG, "image/png" + + monkeypatch.setattr(favicon, "fetch_favicon", found) + # чужой пользователь не должен получить иконку чужого проекта + from app.dependencies import require_user + from app.main import app as fastapi_app + + original = fastapi_app.dependency_overrides[require_user] + fastapi_app.dependency_overrides[require_user] = lambda: { + "user_id": "999", + "email": "other@example.com", + "locale": "ru", + } + try: + assert client.get(f"/api/projects/{project_id}/favicon").status_code == 404 + finally: + # возвращаем тот же объект: conftest-овский lambda закрыт на общий + # AUTH_USER, его подменяют тесты мультиюзера + fastapi_app.dependency_overrides[require_user] = original diff --git a/backend/tests/test_mcp.py b/backend/tests/test_mcp.py index 6fd6e22..941fb34 100644 --- a/backend/tests/test_mcp.py +++ b/backend/tests/test_mcp.py @@ -238,6 +238,40 @@ mcp_server.get_project() +def test_mcp_pin_project(client: TestClient) -> None: + """Булавка (0.66): pinned в update_project, закреплённые первыми в списке.""" + pid = _make_project("Булавка") + assert mcp_server.update_project(project_id=pid, pinned=True)["pinned"] is True + assert mcp_server.list_projects()[0]["id"] == pid + + # None (не передан) булавку не трогает, false — снимает + assert mcp_server.get_project(project_id=pid)["pinned"] is True + assert mcp_server.update_project(project_id=pid, pinned=False)["pinned"] is False + assert mcp_server.update_project(project_id=pid, note="заметка")["pinned"] is False + + +def test_mcp_project_repository(client: TestClient) -> None: + """Репозиторий (0.67): зеркалится в create/update и виден в list/get.""" + created = mcp_server.create_project("С репо", repository_url="https://git.example.com/o/r") + assert created["repository_url"] == "https://git.example.com/o/r" + listed = next(p for p in mcp_server.list_projects() if p["id"] == created["id"]) + assert listed["repository_url"] == "https://git.example.com/o/r" + assert mcp_server.get_project(project_id=created["id"])["repository_url"] == ( + "https://git.example.com/o/r" + ) + + # None (не передан) поле не трогает, пустая строка убирает + assert mcp_server.update_project(project_id=created["id"], note="заметка")[ + "repository_url" + ] == "https://git.example.com/o/r" + assert mcp_server.update_project(project_id=created["id"], repository_url="")[ + "repository_url" + ] is None + # форму «owner/repo» не угадываем — только http(s) + with pytest.raises(ValueError, match="http"): + mcp_server.create_project("Кривой репо", repository_url="git@github.com:o/r.git") + + def test_mcp_task_by_project_name_and_hint(client: TestClient) -> None: _make_project("Дом и быт") created = mcp_server.create_task("Повесить полку", project_name=" дом и быт ") @@ -327,6 +361,30 @@ mcp_server.update_project(project_id=p["id"], relevance_status="done") +def test_mcp_project_color_and_site(client: TestClient) -> None: + created = mcp_server.create_project( + "С меткой", color="#7AA2F7", site_url="https://example.com" + ) + # цвет нормализуется, оба поля видны в ответе проекта + assert created["color"] == "#7aa2f7" + assert created["site_url"] == "https://example.com" + listed = next(p for p in mcp_server.list_projects() if p["id"] == created["id"]) + assert listed["color"] == "#7aa2f7" and listed["site_url"] == "https://example.com" + assert mcp_server.get_project(project_id=created["id"])["color"] == "#7aa2f7" + + # правка и очистка пустой строкой (None = «не менять») + edited = mcp_server.update_project(project_id=created["id"], color="#9ece6a") + assert edited["color"] == "#9ece6a" and edited["site_url"] == "https://example.com" + cleared = mcp_server.update_project(project_id=created["id"], color="", site_url="") + assert cleared["color"] is None and cleared["site_url"] is None + + # мусор отклоняем с подсказкой формата + with pytest.raises(ValueError, match="#rrggbb"): + mcp_server.update_project(project_id=created["id"], color="red") + with pytest.raises(ValueError, match="http"): + mcp_server.create_project("Плохой сайт", site_url="ftp://example.com") + + def test_mcp_archive_and_restore_project(client: TestClient) -> None: """Архив — не удаление: задачи и история переживают архив (ТЗ 3.10).""" p = mcp_server.create_project("Архивный") diff --git a/backend/tests/test_project_fields.py b/backend/tests/test_project_fields.py new file mode 100644 index 0000000..3b40458 --- /dev/null +++ b/backend/tests/test_project_fields.py @@ -0,0 +1,122 @@ +"""Цвет-метка, сайт (0.63), булавка (0.66), репозиторий (0.67): валидация, очистка.""" + +from fastapi.testclient import TestClient + + +def test_color_and_site_roundtrip(client: TestClient) -> None: + resp = client.post( + "/api/projects", + json={ + "name": "Метка", + "color": "#7AA2F7", + "site_url": "https://example.com", + "repository_url": "https://git.example.com/owner/repo", + }, + ) + assert resp.status_code == 200 + project = resp.json() + # цвет нормализуется в нижний регистр — с ним сравнивается палитра фронта + assert project["color"] == "#7aa2f7" + assert project["site_url"] == "https://example.com" + assert project["repository_url"] == "https://git.example.com/owner/repo" + # поля видны и в одиночном чтении + single = client.get(f"/api/projects/{project['id']}").json() + assert single["color"] == "#7aa2f7" + assert single["site_url"] == "https://example.com" + assert single["repository_url"] == "https://git.example.com/owner/repo" + + +def test_project_without_color_and_site(client: TestClient) -> None: + project = client.post("/api/projects", json={"name": "Пусто"}).json() + # поля необязательные: по умолчанию метки, сайта и репозитория нет + assert project["color"] is None + assert project["site_url"] is None + assert project["repository_url"] is None + + +def test_patch_sets_and_clears(client: TestClient) -> None: + project_id = client.post("/api/projects", json={"name": "Правки"}).json()["id"] + patched = client.patch( + f"/api/projects/{project_id}", + json={ + "color": "#9ECE6A", + "site_url": "https://example.org/page", + "repository_url": "https://git.example.org/team/repo.git", + }, + ).json() + assert patched["color"] == "#9ece6a" + assert patched["site_url"] == "https://example.org/page" + assert patched["repository_url"] == "https://git.example.org/team/repo.git" + # пустая строка — «убрать» (и через PATCH, и через MCP) + cleared = client.patch( + f"/api/projects/{project_id}", json={"color": "", "site_url": "", "repository_url": ""} + ).json() + assert cleared["color"] is None + assert cleared["site_url"] is None + assert cleared["repository_url"] is None + # явный null тоже очищает + again = client.patch(f"/api/projects/{project_id}", json={"color": "#ff9e64"}).json() + assert again["color"] == "#ff9e64" + assert client.patch(f"/api/projects/{project_id}", json={"color": None}).json()["color"] is None + # PATCH без репозитория его не трогает + repo = client.patch( + f"/api/projects/{project_id}", json={"repository_url": "https://git.example.org/a/b"} + ).json() + assert repo["repository_url"] == "https://git.example.org/a/b" + renamed = client.patch(f"/api/projects/{project_id}", json={"name": "Правки-2"}).json() + assert renamed["repository_url"] == "https://git.example.org/a/b" + + +def test_invalid_color_rejected(client: TestClient) -> None: + project_id = client.post("/api/projects", json={"name": "Цвет"}).json()["id"] + for bad in ("#GGGGGG", "#7aa2f", "7aa2f7", "red"): + resp = client.patch(f"/api/projects/{project_id}", json={"color": bad}) + assert resp.status_code == 422, bad + + +def test_invalid_site_url_rejected(client: TestClient) -> None: + project_id = client.post("/api/projects", json={"name": "Сайт"}).json()["id"] + for bad in ("ftp://example.com", "javascript:alert(1)", "example.com", "https://"): + resp = client.patch(f"/api/projects/{project_id}", json={"site_url": bad}) + assert resp.status_code == 422, bad + + +def test_invalid_repository_url_rejected(client: TestClient) -> None: + """Репозиторий (0.67) валидируется как сайт: только http(s) с хостом.""" + project_id = client.post("/api/projects", json={"name": "Репозиторий"}).json()["id"] + for bad in ("git@github.com:owner/repo.git", "ftp://git.example.com/a/b", "/owner/repo", ""): + resp = client.patch(f"/api/projects/{project_id}", json={"repository_url": bad}) + # пустая строка — не ошибка, а очистка: проверяем остальные + assert resp.status_code == (200 if bad == "" else 422), bad + + +def test_task_carries_project_color(client: TestClient) -> None: + project_id = client.post( + "/api/projects", json={"name": "С меткой", "color": "#bb9af7"} + ).json()["id"] + task_id = client.post("/api/tasks", json={"title": "Задача"}).json()["id"] + client.patch(f"/api/tasks/{task_id}", json={"project_id": project_id}) + # ProjectOut вложен в TaskOut — метка проекта доезжает до задач без правок схемы + task = next(t for t in client.get("/api/tasks").json() if t["project"]) + assert task["project"]["color"] == "#bb9af7" + + +def test_pinned_default_and_toggle(client: TestClient) -> None: + """Булавка проекта (0.66): по умолчанию снята, PATCH ставит и снимает.""" + project = client.post("/api/projects", json={"name": "Булавка"}).json() + assert project["pinned"] is False + project_id = project["id"] + + pinned = client.patch(f"/api/projects/{project_id}", json={"pinned": True}).json() + assert pinned["pinned"] is True + # в одиночном чтении и в списке — тоже закреплён + assert client.get(f"/api/projects/{project_id}").json()["pinned"] is True + listed = next(p for p in client.get("/api/projects").json() if p["id"] == project_id) + assert listed["pinned"] is True + + # PATCH без pinned (частичное обновление) булавку не трогает + renamed = client.patch(f"/api/projects/{project_id}", json={"name": "Булавка-2"}).json() + assert renamed["pinned"] is True + + unpinned = client.patch(f"/api/projects/{project_id}", json={"pinned": False}).json() + assert unpinned["pinned"] is False diff --git a/backend/tests/test_project_readme.py b/backend/tests/test_project_readme.py new file mode 100644 index 0000000..8beff37 --- /dev/null +++ b/backend/tests/test_project_readme.py @@ -0,0 +1,57 @@ +"""README репозитория проекта (0.67): прокси привязан к проекту, не нашли — 404.""" + +import pytest +from fastapi.testclient import TestClient + +from app.services import readme + + +@pytest.fixture(autouse=True) +def clean_cache() -> None: + """Кэш README между тестами чистим: ключ — ссылка, живет на процесс.""" + readme._CACHE.clear() + + +def _fake_readme(content: str | None, seen: list[str] | None = None): + async def fetch(url: str) -> str | None: + if seen is not None: + seen.append(url) + return content + + return fetch + + +def test_readme_of_project(client: TestClient, monkeypatch: pytest.MonkeyPatch) -> None: + seen: list[str] = [] + monkeypatch.setattr(readme, "fetch_readme", _fake_readme("# Заголовок\n\nтекст", seen)) + project = client.post( + "/api/projects", + json={"name": "С README", "repository_url": "https://git.example.com/owner/repo"}, + ).json() + + resp = client.get(f"/api/projects/{project['id']}/readme") + assert resp.status_code == 200 + assert resp.json() == {"content": "# Заголовок\n\nтекст"} + # читаем README именно по repository_url проекта (URL фронт не передаёт) + assert seen == ["https://git.example.com/owner/repo"] + + +def test_readme_without_repository(client: TestClient, monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setattr(readme, "fetch_readme", _fake_readme("не должно вызваться")) + project = client.post("/api/projects", json={"name": "Без репозитория"}).json() + resp = client.get(f"/api/projects/{project['id']}/readme") + assert resp.status_code == 404 + + +def test_readme_not_found(client: TestClient, monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setattr(readme, "fetch_readme", _fake_readme(None)) + project = client.post( + "/api/projects", + json={"name": "Битый репозиторий", "repository_url": "https://git.example.com/owner/nope"}, + ).json() + resp = client.get(f"/api/projects/{project['id']}/readme") + assert resp.status_code == 404 + + +def test_readme_of_unknown_project(client: TestClient) -> None: + assert client.get("/api/projects/9999/readme").status_code == 404 diff --git a/docker-compose.yml b/docker-compose.yml index d1e8c63..8ec19b9 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -35,8 +35,10 @@ OLLAMA_BASE_URL: ${OLLAMA_BASE_URL} OLLAMA_MODEL: ${OLLAMA_MODEL:-qwen3.5:2b-q4_K_M} ATTACHMENTS_PATH: /data/attachments + FAVICONS_PATH: /data/favicons volumes: - attachments:/data/attachments + - favicons:/data/favicons expose: - "8134" healthcheck: @@ -60,4 +62,5 @@ volumes: pgdata: - attachments: \ No newline at end of file + attachments: + favicons: \ No newline at end of file diff --git a/docs/TZ.md b/docs/TZ.md index 89bae87..f2d19c5 100644 --- a/docs/TZ.md +++ b/docs/TZ.md @@ -4,8 +4,8 @@ | | | |---|---| -| Версия ТЗ | 0.62 | -| Дата | 2026-10-09 | +| Версия ТЗ | 0.67 | +| Дата | 2026-10-10 | | Статус | На обсуждении | --- @@ -102,20 +102,23 @@ поля тоже показываются («— не указано») и редактируются кликом. То же — на странице проекта (название, заметка, актуальность, приоритет). Полный режим правки (вся форма целиком) сохраняется наряду с инлайном. -- **README вместо описания-ссылки** (0.41): если описание задачи **или заметка - проекта** состоит из одной ссылки на git-репозиторий, вместо неё показывается - `README.md` из репозитория (с подписью-ссылкой на источник). Хост не обязан быть +- **README вместо описания-ссылки** (0.41): если **описание задачи** состоит из + одной ссылки на git-репозиторий, вместо неё показывается `README.md` из + репозитория (с подписью-ссылкой на источник). Хост не обязан быть GitHub/GitLab — селф-хостед системы ищутся эвристикой (API github/gitlab/gitea + raw-пути web-интерфейсов, дефолтная ветка HEAD); не нашли — остаётся ссылка. Сырой текст в БД не меняется; кэш прочтённого README — 10 минут. Пользователю подсказывается об опции: в форме создания задачи (подпись редактора) и в пустом описании/инлайн-правке на странице задачи. + У проекта этого механизма больше нет (0.67): ссылка на репозиторий живёт + отдельным полем, а README показывается вторым описанием (см. 3.8). - **Линки описания/заметки — в новую вкладку** (0.41): всё, что рендерится из Markdown (описание, заметка, README), открывается во внешность (`target="_blank"`, `rel="noopener"`) — PWA не ловит навигацию в своё окно. - **Длинная заметка сворачивается** (0.41): если блок заметки (или README из - ссылки) выше ~420px, показывается начало + подпись «Заметка большая — - показано начало» и кнопка «Развернуть» (обратно — «Свернуть»). + ссылки, а с 0.67 — и README репозитория проекта) выше ~420px, показывается + начало + подпись «Заметка большая — показано начало» и кнопка «Развернуть» + (обратно — «Свернуть»). ### 3.4. Статусы, дедлайны, прогнозы, бюджет @@ -189,17 +192,120 @@ разъезжалась по страницам. - Карточки проектов раскладываются **сеткой по две в ряд** (0.61): в две колонки список читается компактнее, на узких экранах — одна колонка (ломающаяся точка - 860px). Заголовок секции занимает всю ширину строки, карточки разной высоты - выравниваются по верху; скелетон загрузки повторяет ту же сетку. + 860px). Заголовок секции занимает всю ширину строки; скелетон загрузки повторяет + ту же сетку. Карточки ряда **одной высоты с подвалом по низу** (0.66; в 0.61 они + выравнивались по верху и подвал «плясал» между соседями). - **Карточка в списке — метрики, без заметки** (0.62): заметка (Markdown с ресурсами) остаётся только на странице проекта — в списке она делала карточки разной высоты и мешала сравнивать проекты. В шапке карточки справа — актуальность; ниже ряд метрик: приоритет, «N в работе», «просрочено N» (нулевые метрики не показываются — иначе ряд бейджей шумит одинаковыми «0»); полоса прогресса «Закрыто N из M» - с процентом; подвал — ближайший дедлайн по открытым задачам (просроченный красным) - и кнопка «Открыть». У проекта без задач вместо полосы — подсказка «Задач в проекте + с процентом (оформление полосы изменено в 0.64); подвал — ближайший дедлайн + по открытым задачам (просроченный красным). + У проекта без задач вместо полосы — подсказка «Задач в проекте пока нет». Метрики считаются по всем задачам проекта (как и полоса прогресса), просрочка — по незакрытым. +- **Блок задач на карточке — счётчики и клетки по задачам** (0.64): подпись + «Закрыто N из M» заменена счётчиком **«✓ N / M»** (закрытых — крупнее, всего — + тише; полная фраза осталась в подсказке при наведении), процент кита — справа. + Полоса разбита насечками: **одна клетка — одна задача**, закрытые клетки залиты + **цветом-меткой проекта** (у проекта без цвета — акцент кита). Клетки рисуются + при 2..24 задачах: у одной задачи дробить нечего, при большем числе клетка + становится уже 4px и полоса рябит — там она сплошная. Заливка считается по + клеткам (`100%/M × N`), а не процентом кита: у процента округление до целого, + и на границе клетки оставалась бы щель. Проект закрыт целиком — полоса и счётчик + уходят в зелёный кита. Цвет проекта **в тексте** не используется (тёмные оттенки + палитры, светлота от 38%, нечитаемы как текст) — им закрашена только полоса. +- **Метка проекта — кружок и фавикон сайта** (0.65): в шапке страницы проекта и на + карточке списка проект помечен парой «цвет-кружок + иконка сайта»: на карточке + кружок 16px и фавикон 18px (было 9px без иконки), в шапке страницы — 18px и 21px + при H1 23px. Метка идёт размером от кегля имени (в карточке база — тайтл), поэтому + растёт вместе с ним. Иконку тянет тот же бэкенд-прокси (3.11): нет сайта или + иконки — остаётся один кружок, **битой картинки нет** (404 — штатный ответ). + В шапке страницы фавикон — **ссылка на сайт** (короткий путь к нему рядом с + именем), на карточке — просто картинка: карточка целиком ссылка, вложенные + ссылки невалидны (0.63). Заодно **тайтл карточки списка крупнее** (16px вместо + 14px, приходивших наследованием): имя проекта — главный ориентир в списке. +- **Ссылка на сайт — чип в шапке проекта** (0.65): прежняя подпись (13px + приглушённым цветом, без рамки) выпадала из ряда бейджей и читалась подписью, а + не действием. Теперь это чип геометрии бейджа кита (высота 24px, радиус 3px, + 13px/600) с иконкой сайта 20px и подсветкой акцентом при наведении и фокусе; + капса нет — домен в верхнем регистре читается хуже. Иконки нет — глобус. + Обводку чип не носит (снята в 0.65 по просьбе пользователя): рамка вокруг + ссылки-действия читалась как второй бейдж в ряду. +- **Карточка списка кликабельна целиком** (0.63): кнопка «Открыть» дублировала + заголовок-ссылку и занимала подвал карточки. Теперь карточка — обёртка-ссылка + (семантичный ``: клик в любом месте, фокус с клавиатуры, «открыть в новой + вкладке»), заголовок внутри — не ссылка (вложенные ссылки невалидны). В архиве + (3.8.1) кнопка «Открыть» остаётся — там рядом действия восстановления/удаления. +- **Цвет-метка проекта** (0.63): у проекта может быть цвет-кружок — «узнаваемость» + проекта в списках. Поле необязательное: новым проектам цвет **не назначается**, + пока пользователь сам не выберет (пусто — кружка нет). Выбор — палитра ≈100 + оттенков, выведенных из 8 базовых цветов кита (3.19), на странице проекта + (инлайн-строка «Цвет») и в форме правки; в БД — HEX `#rrggbb` (палитра — + представление фронта, бэкенд проверяет только формат, поэтому цвет из MCP + вне палитры тоже валиден и показывается отдельной клеткой). Показывается + в трёх местах: **карточка в списке проектов, шапка страницы проекта, метка + проекта у задач** (списки, дашборд, страница задачи). В фильтрах и селектах + проектов цвета нет: в выпадашке это шум, а не узнаваемость. +- **Сайт проекта** (0.63): ссылка на сайт проекта — отдельное поле, отдельно от + заметки (в заметке ссылка тонула в тексте). Показывается **только на странице + проекта**: в шапке — имя хоста с иконкой сайта, клик открывает сайт в новой + вкладке (вид ссылки уточнён в 0.65); та же ссылка правится строкой «Сайт» + в параметрах и в форме правки. На карточке списка ссылки нет (карточка целиком + ведёт на проект), но **иконка сайта стоит в метке проекта** (0.65). Иконку тянет + бэкенд-прокси с кэшем (3.11); если её нет — глобус. Поле необязательное. +- **Репозиторий проекта** (0.67): ссылка на git-репозиторий — **отдельное поле** + `repository_url`, отдельно и от заметки, и от сайта. Раньше её клали в заметку, + и она блокировала саму заметку (заметка из одной ссылки **подменялась** README): + у проекта теперь **два описания** — заметка остаётся заметкой, а из репозитория + подтягивается и показывается **README как дополнительное описание**. Механизм + распознавания ссылки в заметке проекта снят (у задачи README-вместо-описания + остаётся, см. 3.3). README показывается **карточкой «README из репозитория»** + под заметкой: в шапке карточки — ссылка на репозиторий (хост и путь без схемы, + открывается в новой вкладке), в теле — отрендеренный Markdown; длинный README + сворачивается тем же порогом ~420px (3.3). Тянет его **бэкенд-прокси** + `/api/projects/{id}/readme` с кэшем 10 минут (данные не покидают VPS — как + у фавикона, 3.11): привязка к проекту, а не `?url=`, чтобы эндпоинт не стал + открытым прокси для чтения произвольных адресов. README в репозитории не нашёлся + — тихая подпись «README не найден» (поле необязательное, проект живёт и без + него). Правится строкой «Репозиторий» в параметрах проекта и в форме правки. + Форма ссылки не проверяется (кроме http(s) с хостом): не похожа на репозиторий — + прокси честно ответит «не найден». Существующие заметки-ссылки в БД не + переписываются: ссылку нужно указать в новом поле. +- **Закрепление проектов** (0.66): у проекта есть булавка `pinned` — важные проекты + держатся наверху списка и не уезжают вниз с пагинацией. Закреплённые живут + **отдельной секцией «Закреплённые» вверху** (иконка `ph-push-pin`, янтарный + бейдж-счётчик), независимо от наличия задач: закреплённый проект без задач + остаётся в «Закреплённых», а не в «Без задач». Переключателей два, поле одно: + **булавка в правом верхнем углу карточки списка** — рядом с бейджем актуальности + (шапка сдвигается левее), видна всегда, в покое **не приглушена**; и **строка + «Закреплён» в параметрах страницы проекта** (значение кликабельно, как у + остальных строк). + Место булавки переставлено в 0.67: в правом нижнем углу карточки она читалась + частью подвала, а на странице проекта её и вовсе не находили — поэтому у имени + проекта появилась **кнопка-булавка рядом с названием** (в архиве скрыта: + страница только для чтения, состояние видно строкой «Закреплён»; строка + в параметрах остаётся — второй переключатель). Новый проект + создаётся незакреплённым. В MCP — поле `pinned` у `update_project` (3.10). +- **Сортировка списка по свежести активности** (0.66): выше те проекты, где + недавно что-то происходило. Порядок — закреплённые → с задачами → без задач, + внутри группы по убыванию активности, затем по id. **Активность — самая поздняя + из задач проекта** (`created_at` или `done_at`); правки самого проекта (заметка, + цвет, статус) активностью не считаются — сортировка про работу над проектом, + а не про правку его карточки. Проект без задач стоит в конце своей группы. + Порядок применяется **до** пагинации (3.6), а счётчики секций и показ заголовков + считаются **по всему списку**, не по странице: иначе на второй странице заголовки + то появлялись бы, то исчезали. +- **Подвал карточки — отсчёт до дедлайна** (0.66): ближайший срок показывается + словами — «сегодня», «завтра», «через N дн.», «просрочено на N дн.» (просрочка + красным), полная дата уходит в подсказку при наведении: дата без отсчёта + требовала сравнения с календарём на каждом проекте. Сокращение «дн.» — потому что + плюрализации в проекте нет (счётчики везде одной формой), а «3 дня / 5 дней» + потребовали бы форм в каждой локали. Дедлайн отсчитывается по календарным суткам + (без часовых поясов и перехода на летнее время). Красный в подвале — про сам + ближайший срок, а не про наличие просроченных задач: бейдж «просрочено N» в ряду + метрик остаётся независимым. #### 3.8.1. Архив @@ -235,12 +341,19 @@ - `list_tasks` — фильтры по проекту (id или имени), тегу, статусу, подстроке; - `get_task` — полное описание с вложениями; - `list_projects` / `get_project` — обнаружение проектов и **восстановление - контекста проекта** (заметка проекта + его открытые задачи); - - `create_project` — новый проект (имя, опционально заметка и приоритет); - дубликат имени отклоняется с подсказкой; заметка уходит в фоновую - суммаризацию; микронаграда как при создании из UI; + контекста проекта** (заметка проекта + его открытые задачи); список отдаётся + **закреплёнными первыми** (0.66), затем по имени — как в UI; + - `create_project` — новый проект (имя, опционально заметка, приоритет, + `color`, `site_url` и `repository_url`); дубликат имени отклоняется с подсказкой; + заметка уходит в фоновую суммаризацию; микронаграда как при создании из UI; - `update_project` — переименование, заметка, relevance_status - (`active`/`paused`), приоритет; адресация по id или имени; + (`active`/`paused`), приоритет, `color`, `site_url`, `repository_url`, `pinned` + (true — закрепить, false — открепить; 0.66); адресация по id или имени; + - `color` (`#rrggbb`), `site_url` и `repository_url` (`http(s)://host/...`) обоих + тудов — необязательные поля проекта (3.8); как принято в файле, `None` — «не менять», + а **пустая строка убирает** значение; невалидный формат отклоняется с подсказкой + формата (`repository_url` — репозиторий, из которого в интерфейсе подтягивается + README вторым описанием, 0.67). - `archive_project` / `restore_project` — **архив вместо удаления** (0.45): проект прячется вместе с задачами из активных (история сохраняется), restore возвращает; вызовы идемпотентны; архивные проекты видны через @@ -288,6 +401,25 @@ классификации задач). Суммаризация генерируется той же LLM фоном при создании проекта с заметкой и при правке заметки (кэш в БД); без суммаризации в контекст идёт обрезка заметки. +- **Иконка сайта проекта (favicon, 0.63)** — бэкенд-прокси с кэшем: фронт + запрашивает `/api/projects/{id}/favicon`, бэкенд тянет иконку с сайта проекта + и кэширует её на диске (7 дней; негативный результат «иконки нет» — 1 час, + чтобы не долбить сайт). Показывается в метке проекта (карточка списка и шапка + страницы проекта) и в чипе-ссылке на сайт (0.65). Сторонние favicon-сервисы + **не используются**: адрес + проекта не должен утекать третьей стороне (принцип «данные не покидают VPS»). + Кандидаты: `` сайта, затем `/favicon.ico`, + `/favicon.png`, `/apple-touch-icon.png`. Тип определяется по magic-bytes + (PNG/JPEG/GIF/ICO/WebP); **SVG-иконки не принимаются** — скриптуемый формат + (как запрещённые mime вложений). Ограничения запроса — в 4. +- **README репозитория проекта (0.67)** — такой же бэкенд-прокси с кэшем: фронт + запрашивает `/api/projects/{id}/readme`, бэкенд читает `repository_url` проекта + и тянет сырой README перебором известных путей (см. 3.3), кэшируя прочтённое + на 10 минут (в том числе негативный результат — чтобы не долбить git-хост). + Привязка к проекту, а не параметр `?url=`: открытым прокси для чтения + произвольных адресов эндпоинт не становится. Ссылку на репозиторий фронт + не передаёт — её знает только бэкенд. По той же причине, что и у фавикона, + сторонние сервисы чтения README **не используются**. - Все вызовы LLM — на собственном сервере, данные не покидают VPS. - JSON-колонки хранят кириллицу без \u-эскейпов (0.40): MCP-агенты читают читаемый текст (ensure_ascii=False). @@ -663,6 +795,40 @@ имя шапки карточки в ките (`header.card-title`, у него `padding: 15px 15px 0`); тот же класс на вложенной ссылке давал вторые 15px, и текст заголовка уезжал вправо от остального содержимого. Ссылка стека носит собственный `card-link`. +- **Селектор цвета проекта — сырой блок в стиле кита** (0.63): в ките компонента + выбора цвета нет (проверено: `GnChip`/`GnChipGroup`/`GnPopover`/`GnDropdown`/ + `GnRadioGroup`/`GnRange` цвета не принимают), а `GnPopover` оборачивает + содержимое в `

` — сетку свотчей туда не положить. Панель + выбора — свой блок с поверхностью/рамкой/радиусом кита (как `.popover-panel`), + свотчи — отдельные кнопки с `aria-pressed`/`aria-label`; закрытие по клику вне + и Escape. Палитра (≈100 оттенков) строится детерминированно из 8 базовых цветов + кита (accent/info/cyan/фиолетовый/зелёный/песочный/розовый/оранжевый) — 6 ступеней + светлоты × 2 ступени насыщенности, дубликаты соседних ступеней отсеиваются, чтобы + ряды оставались различимыми; палитра — представление фронта, в БД идёт HEX. +- **Ссылка-чип и крупный тайтл карточки** (0.65): в ките бейдж — `span`, ссылки + среди бейджей нет, поэтому чип сайта — сырой `` в геометрии бейджа кита + (24px/радиус 3px/13px/600), капс не берём. Тайтл карточки крупнее: размер + поднимается правилом `:deep(.card-title)` — в ките `.card .card-title` размера + шрифта не задаёт (14px приходят наследованием от `body`), поэтому правило ничего + не «перебивает». Метка проекта (кружок + фавикон) — свой компонент + `ProjectMark`; размеры в `em` от кегля имени: в карточке база — тайтл (16px), + в шапке страницы задаётся 18px. Кружок внутри метки крупнее одиночного + (`ProjectColorDot` в бейджах задач и дашборда оставлен прежним) — селектор + потомка бьёт правило дочернего компонента без `!important`. Кольца у фавикона + нет (тонкая обводка `--gn-border-color-muted` снята в 0.65 по просьбе + пользователя): край иконки и без неё читается на тёмном фоне. +- **Цвет-метка — общий компонент-кружок** (0.63): кружок рисуется одним компонентом + (`width/height: .65em`, `border-radius: 50%`) в четырёх местах (карточка списка, + шапка проекта, бейдж проекта у задачи, строки проектов дашборда) — иначе стиль + дублировался бы по файлам. Нет цвета — нет и кружка. +- **Клетки прогресса — поверх полосы кита** (0.64): сегментированной полосы в ките + нет (`GnProgress` — одна заливка, `GnProgressStages` — сетка этапов), поэтому + карточка проекта рисует клетки насечками поверх трека (`::after` трека с + `repeating-linear-gradient`, шаг `100%/M`), а сама полоса остаётся `GnProgress` — + с ролью `progressbar`, `aria-valuenow/max` и анимацией ширины. Переменные + оформления (`--card-color`, `--task-cells`, `--done-cells`) вешаются на корень + карточки, а не на полосу: кит в `GnProgress` ставит свой `style` после `...attrs`, + и переданный извне `style` теряется. ## 4. Нефункциональные требования @@ -687,6 +853,25 @@ - `gnexus-ui-kit` — часто обновляется, использовать только публичный API кита; - `gnexus-gauth` — клиентская библиотека SSO. +**Исходящие запросы за иконкой сайта (0.63, 3.11)** — единственный случай, когда +бэкенд ходит в интернет по адресу, заданному пользователем (в т.ч. агентом через +MCP), поэтому запрос ограничен барьером SSRF: + +- только схема http/https и непустой хост, длина адреса ≤ 2048; +- **только публичные адреса**: имя резолвится, и каждый полученный IP обязан быть + глобальным (`ipaddress.is_global`) — private/loopback/link-local/reserved/CGNAT + (включая метаданные облака `169.254.169.254`) отклоняются; +- редиректы проходятся вручную (≤ 3), хост проверяется **на каждом хопе**; +- лимиты: connect 3 c / read 4 c / всего 6 c, тело ≤ 256 КБ, `content-type` обязан + быть изображением, а расширение определяется по magic-bytes; +- ответ отдаётся с `X-Content-Type-Options: nosniff` и `Cache-Control: private`, + без возможности исполниться как документ. + +Остаточный риск: между проверкой хоста и соединением HTTP-клиент резолвит имя +повторно (DNS-rebinding, TOCTOU); полное закрытие требует соединения по IP с +подменой Host/SNI и ломает проверку TLS-сертификата. Для личного приложения риск +принят — основной вектор (публичный URL → метаданные облака) закрыт. + ## 5. Модель данных (черновик) Мультиюзерность (с 0.34): на задачах, проектах, тегах, документах, XP/монетах, @@ -726,9 +911,19 @@ relevance_status active | paused — статус актуальности is_archived bool — архив (3.8.1): проект и его задачи скрыты из рабочих видов, восстановимы из «Архива» + pinned bool — булавка (3.8, 0.66): закреплённые проекты + держатся секцией «Закреплённые» вверху списка priority note markdown — живёт в Document (owner_type = project): - ссылки, контекст, картинки + ссылки, контекст, картинки; ссылка на репозиторий — + в отдельном поле repository_url (0.67) + color nullable — цвет-метка проекта, HEX #rrggbb (3.8, 0.63); + палитра выбора — на фронте, бэкенд валидирует только формат + site_url nullable — сайт проекта, http(s)://... (3.8, 0.63); + иконка сайта тянется бэкенд-прокси с кэшем (3.11) + repository_url nullable — репозиторий проекта, http(s)://... (3.8, 0.67); + README оттуда тянется бэкенд-прокси с кэшем (3.11) + и показывается вторым описанием рядом с заметкой Tag — справочник тегов Document — markdown-текст с полиморфной привязкой к владельцу: diff --git a/frontend/src/api.ts b/frontend/src/api.ts index 3abf5c3..44caddd 100644 --- a/frontend/src/api.ts +++ b/frontend/src/api.ts @@ -12,9 +12,17 @@ name: string relevance_status: string is_archived: boolean + // Булавка проекта (0.66): закреплённые держатся отдельной секцией сверху списка + pinned: boolean priority: number | null note: string document_id: number | null + // Цвет-метка проекта (0.63): HEX #rrggbb или null — метки нет + color: string | null + // Сайт проекта (0.63): http(s)://... или null + site_url: string | null + // Репозиторий проекта (0.67): отдельное поле, из него тянется README (второе описание) + repository_url: string | null } // Черновое предложение автодетализации (LLM): только заголовок, описание, теги. @@ -316,6 +324,11 @@ request('/api/projects', { method: 'POST', body: JSON.stringify({ name }) }), updateProject: (id: number, patch: Partial>) => request(`/api/projects/${id}`, { method: 'PATCH', body: JSON.stringify(patch) }), + // Иконка сайта проекта (0.63): тянет бэкенд-прокси с кэшем — просто + projectFaviconUrl: (id: number) => `/api/projects/${id}/favicon`, + // README репозитория проекта (0.67) — второе описание; ссылку фронт не передаёт: + // роут сам читает repository_url проекта и отвечает 404, если README не нашёлся + projectReadme: (id: number) => request<{ content: string }>(`/api/projects/${id}/readme`), archiveProject: (id: number) => request(`/api/projects/${id}/archive`, { method: 'POST' }), restoreProject: (id: number) => diff --git a/frontend/src/components/Dashboard.vue b/frontend/src/components/Dashboard.vue index ecbfc5b..8492925 100644 --- a/frontend/src/components/Dashboard.vue +++ b/frontend/src/components/Dashboard.vue @@ -11,6 +11,7 @@ deadlineOverdue, } from '../taskui' import PriorityBadge from './PriorityBadge.vue' +import ProjectColorDot from './ProjectColorDot.vue' const props = defineProps<{ tasks: Task[] @@ -139,8 +140,11 @@ >