diff --git a/.dockerignore b/.dockerignore index beed316..96ddfe5 100644 --- a/.dockerignore +++ b/.dockerignore @@ -1,5 +1,4 @@ node_modules -dist .git .env *.log diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..e051eee --- /dev/null +++ b/.env.example @@ -0,0 +1,15 @@ +# OAuth - VK ID +# Register at https://id.vk.com/about/business/go/ +VK_CLIENT_ID= +VK_CLIENT_SECRET= + +# OAuth - Yandex +# Register at https://oauth.yandex.com/ +YANDEX_CLIENT_ID= +YANDEX_CLIENT_SECRET= + +# Database +DATABASE_URL=postgresql://dmuser:dmpass@localhost:5432/dmdashboard + +# JWT +JWT_SECRET=your-jwt-secret-min-32-chars diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index c0aecbb..7046704 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -7,6 +7,24 @@ on: jobs: deploy: runs-on: ubuntu-latest + env: + DATABASE_URL: postgresql://postgres:postgres@localhost:5432/randify + NODE_ENV: test + services: + postgres: + image: postgres:16-alpine + env: + POSTGRES_USER: postgres + POSTGRES_PASSWORD: postgres + POSTGRES_DB: randify + options: >- + --health-cmd pg_isready + --health-interval 10s + --health-timeout 5s + --health-retries 5 + ports: + - 5432:5432 + steps: - uses: actions/checkout@v4 @@ -20,6 +38,9 @@ jobs: - run: npm run test - run: npm run build + - name: Run database migrations + run: npm run db:migrate + - name: Setup SSH key run: | mkdir -p ~/.ssh @@ -27,9 +48,14 @@ jobs: chmod 600 ~/.ssh/deploy_key ssh-keyscan -H ${{ secrets.DEPLOY_HOST }} >> ~/.ssh/known_hosts - - name: Deploy via rsync + - name: Deploy Node.js app run: | rsync -avz --delete \ -e "ssh -i ~/.ssh/deploy_key" \ - dist/ \ + dist/ package*.json docker-compose.yml Dockerfile \ ${{ secrets.DEPLOY_USER }}@${{ secrets.DEPLOY_HOST }}:~/www/randify.pro/ + + - name: Restart app + run: | + ssh -i ~/.ssh/deploy_key ${{ secrets.DEPLOY_USER }}@${{ secrets.DEPLOY_HOST }} \ + "cd ~/www/randify.pro && docker compose down && docker compose up -d --build && sleep 5 && curl -sf http://localhost:4321/api/health || echo 'Health check skipped'" diff --git a/.sisyphus/boulder.json b/.sisyphus/boulder.json index 1d8d67f..08e8da8 100644 --- a/.sisyphus/boulder.json +++ b/.sisyphus/boulder.json @@ -1,6 +1,6 @@ { "schema_version": 2, - "active_work_id": "blog-typography-fix-c9cf9be1", + "active_work_id": "dm-dashboard-c3118dfa", "works": { "critical-refactor-f063daf0": { "work_id": "critical-refactor-f063daf0", @@ -300,33 +300,145 @@ "elapsed_ms": 44283 } } + }, + "dm-dashboard-c3118dfa": { + "work_id": "dm-dashboard-c3118dfa", + "active_plan": "/home/emil/Desktop/Coding/AI/Randify.pro/.sisyphus/plans/dm-dashboard.md", + "plan_name": "dm-dashboard", + "status": "completed", + "started_at": "2026-05-14T17:56:40.328Z", + "updated_at": "2026-05-14T20:09:11.770Z", + "session_ids": [ + "ses_1d89ad89affe987QEXPgCGcx4S" + ], + "session_origins": { + "ses_1d89ad89affe987QEXPgCGcx4S": "direct" + }, + "agent": "atlas", + "task_sessions": { + "final-wave:f1": { + "task_key": "final-wave:f1", + "task_label": "F1", + "task_title": "**Plan Compliance Audit** — `oracle`", + "session_id": "ses_1d7f26069ffeYjfNhmNuooCc3p", + "agent": "Sisyphus-Junior", + "category": "quick", + "started_at": "2026-05-14T18:05:29.120Z", + "status": "completed", + "updated_at": "2026-05-14T20:01:43.840Z", + "ended_at": "2026-05-14T20:01:43.840Z", + "elapsed_ms": 6974720 + }, + "final-wave:f2": { + "task_key": "final-wave:f2", + "task_label": "F2", + "task_title": "Code Quality Review (re-run after fixes)", + "session_id": "ses_1d7eaed87ffen4vuG6WyFcGh7b", + "agent": "Sisyphus-Junior", + "category": "unspecified-high", + "started_at": "2026-05-14T19:44:00.709Z", + "status": "completed", + "updated_at": "2026-05-14T20:07:46.967Z", + "ended_at": "2026-05-14T20:07:46.967Z", + "elapsed_ms": 1426258 + }, + "final-wave:f3": { + "task_key": "final-wave:f3", + "task_label": "F3", + "task_title": "Real Manual QA (re-run after fixes)", + "session_id": "ses_1d7eab36dffeC7MQ1w6VXEOAnr", + "agent": "Sisyphus-Junior", + "category": "unspecified-high", + "started_at": "2026-05-14T19:46:49.796Z", + "status": "completed", + "updated_at": "2026-05-14T20:07:53.184Z", + "ended_at": "2026-05-14T20:07:53.184Z", + "elapsed_ms": 1263388 + }, + "final-wave:f4": { + "task_key": "final-wave:f4", + "task_label": "F4", + "task_title": "Scope Fidelity Check", + "session_id": "ses_1d7fd7992ffe3wLZZY1Zj6CrLD", + "agent": "Sisyphus-Junior", + "category": "deep", + "updated_at": "2026-05-14T20:01:50.576Z", + "started_at": "2026-05-14T19:50:33.428Z", + "status": "completed", + "ended_at": "2026-05-14T20:01:50.576Z", + "elapsed_ms": 677148 + } + }, + "ended_at": "2026-05-14T20:09:11.770Z", + "elapsed_ms": 7951442 } }, - "active_plan": "/home/emil/Desktop/Coding/AI/Randify.pro/.sisyphus/plans/blog-typography-fix.md", - "started_at": "2026-05-14T10:23:17.171Z", - "status": "active", - "updated_at": "2026-05-14T10:25:36.103Z", + "active_plan": "/home/emil/Desktop/Coding/AI/Randify.pro/.sisyphus/plans/dm-dashboard.md", + "started_at": "2026-05-14T17:56:40.328Z", + "status": "completed", + "updated_at": "2026-05-14T20:09:11.770Z", "session_ids": [ - "ses_1da346c55ffe2yhgC2v7zqrilE" + "ses_1d89ad89affe987QEXPgCGcx4S" ], "session_origins": { - "ses_1da346c55ffe2yhgC2v7zqrilE": "direct" + "ses_1d89ad89affe987QEXPgCGcx4S": "direct" }, - "plan_name": "blog-typography-fix", + "plan_name": "dm-dashboard", "task_sessions": { "final-wave:f1": { "task_key": "final-wave:f1", "task_label": "F1", - "task_title": "Build + Visual Check", - "session_id": "ses_1d9fc2935ffesVSRCGlGP4wnCU", + "task_title": "**Plan Compliance Audit** — `oracle`", + "session_id": "ses_1d7f26069ffeYjfNhmNuooCc3p", "agent": "Sisyphus-Junior", "category": "quick", - "updated_at": "2026-05-14T10:25:36.103Z", - "started_at": "2026-05-14T10:24:51.820Z", + "started_at": "2026-05-14T18:05:29.120Z", "status": "completed", - "ended_at": "2026-05-14T10:25:36.103Z", - "elapsed_ms": 44283 + "updated_at": "2026-05-14T20:01:43.840Z", + "ended_at": "2026-05-14T20:01:43.840Z", + "elapsed_ms": 6974720 + }, + "final-wave:f2": { + "task_key": "final-wave:f2", + "task_label": "F2", + "task_title": "Code Quality Review (re-run after fixes)", + "session_id": "ses_1d7eaed87ffen4vuG6WyFcGh7b", + "agent": "Sisyphus-Junior", + "category": "unspecified-high", + "started_at": "2026-05-14T19:44:00.709Z", + "status": "completed", + "updated_at": "2026-05-14T20:07:46.967Z", + "ended_at": "2026-05-14T20:07:46.967Z", + "elapsed_ms": 1426258 + }, + "final-wave:f3": { + "task_key": "final-wave:f3", + "task_label": "F3", + "task_title": "Real Manual QA (re-run after fixes)", + "session_id": "ses_1d7eab36dffeC7MQ1w6VXEOAnr", + "agent": "Sisyphus-Junior", + "category": "unspecified-high", + "started_at": "2026-05-14T19:46:49.796Z", + "status": "completed", + "updated_at": "2026-05-14T20:07:53.184Z", + "ended_at": "2026-05-14T20:07:53.184Z", + "elapsed_ms": 1263388 + }, + "final-wave:f4": { + "task_key": "final-wave:f4", + "task_label": "F4", + "task_title": "Scope Fidelity Check", + "session_id": "ses_1d7fd7992ffe3wLZZY1Zj6CrLD", + "agent": "Sisyphus-Junior", + "category": "deep", + "updated_at": "2026-05-14T20:01:50.576Z", + "started_at": "2026-05-14T19:50:33.428Z", + "status": "completed", + "ended_at": "2026-05-14T20:01:50.576Z", + "elapsed_ms": 677148 } }, - "agent": "atlas" + "agent": "atlas", + "ended_at": "2026-05-14T20:09:11.770Z", + "elapsed_ms": 7951442 } \ No newline at end of file diff --git a/.sisyphus/evidence/f3-dm-screenshot.png b/.sisyphus/evidence/f3-dm-screenshot.png new file mode 100644 index 0000000..293118a Binary files /dev/null and b/.sisyphus/evidence/f3-dm-screenshot.png differ diff --git a/.sisyphus/evidence/t16-health.json b/.sisyphus/evidence/t16-health.json new file mode 100644 index 0000000..1a36cf5 --- /dev/null +++ b/.sisyphus/evidence/t16-health.json @@ -0,0 +1 @@ +{"status":"ok"} \ No newline at end of file diff --git a/.sisyphus/evidence/t17-responsive-desktop.png b/.sisyphus/evidence/t17-responsive-desktop.png new file mode 100644 index 0000000..317c55b Binary files /dev/null and b/.sisyphus/evidence/t17-responsive-desktop.png differ diff --git a/.sisyphus/evidence/t17-responsive-mobile.png b/.sisyphus/evidence/t17-responsive-mobile.png new file mode 100644 index 0000000..3073753 Binary files /dev/null and b/.sisyphus/evidence/t17-responsive-mobile.png differ diff --git a/.sisyphus/notepads/dm-dashboard/learnings.md b/.sisyphus/notepads/dm-dashboard/learnings.md new file mode 100644 index 0000000..15b0c77 --- /dev/null +++ b/.sisyphus/notepads/dm-dashboard/learnings.md @@ -0,0 +1,523 @@ +- 2026-05-14: T3 Testing Infrastructure completed + - vitest.config.ts updated: happy-dom env, globals, setupFiles, aliases @/* + - Installed: @testing-library/dom, happy-dom, @playwright/test + - Created: tests/setup.ts (crypto mock), tests/utils.ts (mock helpers), playwright.config.ts, tests/example.test.ts + - Scripts added: test:unit, test:ui + - All 56 tests pass (existing + new) + - Key pattern: Use happy-dom instead of jsdom for lighter weight; mock crypto.getRandomValues for deterministic dice tests +# DM Dashboard Design System — Wave 1 Learnings + +## Design System Foundation (2026-05-14) + +### Theme Architecture +- Created `src/styles/dm-theme.css` with `:root` CSS custom properties for orange accent (`#E87722`) +- Variables defined: `--accent`, `--accent-light`, `--accent-dark`, `--bg-primary`, `--bg-secondary`, `--bg-card`, `--text-primary`, `--text-secondary`, `--border-color` +- DM layout wraps `BaseLayout` and imports theme CSS; components use Tailwind arbitrary values like `bg-[var(--bg-card)]` + +### Component Patterns +- **DmButton**: Supports `primary` / `secondary` / `ghost` variants and `sm` / `md` / `lg` sizes. Uses `focus-visible:ring-[var(--accent)]` for accessible focus states. Disabled state handled with `disabled:` modifiers. +- **DmCard**: Simple container with configurable padding (`none` / `sm` / `md` / `lg`). Base style: `rounded-xl bg-[var(--bg-card)] border border-[var(--border-color)] shadow-lg shadow-black/20`. +- **DmInput**: Wraps label + input. Dark theme input: `bg-[var(--bg-secondary)] border-[var(--border-color)]` with orange focus ring. Supports `required` indicator. +- **DmHeader**: Flex header with logo icon (pen/edit SVG), title, and slot for actions. Uses `backdrop-blur-sm` for modern glass effect. +- **DmLayout**: Wraps `BaseLayout`, imports `dm-theme.css`, sets `bg-[var(--bg-primary)]` on wrapper div. + +### Tailwind v4 Conventions +- Project uses `@import "tailwindcss"` and `@theme` block in `BaseLayout.astro` global styles +- Existing accent color (`#534AB7`) defined via `--color-accent` in `@theme`; DM theme uses separate `:root` variables to avoid collision +- `focus-visible:` pattern used consistently across interactive elements (copied from existing GeneratorCard/ResultBox patterns) + +### Dark Theme Consistency +- Base dark palette: `bg-zinc-950` / `text-zinc-100` from existing site +- DM theme maps closely: `--bg-primary: #0f0f0f`, `--bg-secondary: #1a1a1a`, `--bg-card: #242424` +- Orange accent (`#E87722`) provides high contrast against dark backgrounds + +### Verification +- Build passes successfully (`npm run build` exits 0) +- Temporary test page confirmed all components render and compile correctly +- No existing components modified + +## Initiative Tracker Component (T9) — 2026-05-14 + +### TDD Cycle +- RED: Wrote `tests/initiative-tracker.test.ts` with 11 tests covering `sortByInitiative`, `getNextActiveIndex`, and `rollInitiative` — all failed initially due to missing module. +- GREEN: Created `src/lib/client/initiative.ts` with pure helper functions; all 11 tests pass. +- Refactor: No changes needed — functions are already minimal and pure. + +### Files Created +- `src/lib/client/initiative.ts` — pure helpers: `sortByInitiative`, `getNextActiveIndex`, `rollInitiative` +- `tests/initiative-tracker.test.ts` — 11 tests (sorting, cycling, edge cases, dice roll bounds) +- `src/components/dm/InitiativeTracker.astro` — full interactive component + +### Component Design +- Uses `DmButton` and `DmInput` for consistent DM theme styling +- Form layout: Name (5 cols) + Modifier (3 cols) + Roll d20 (2 cols) + Add (2 cols) +- Initiative can be entered manually or auto-rolled (d20 + modifier) +- List sorted descending by initiative with numbered badges +- Active combatant: orange border (`border-[var(--accent)]`) + orange background tint + orange numbered badge +- Delete per combatant (trash icon), Clear All, Next Turn buttons +- Data persisted in `sessionStorage` (resets on refresh — MVP scope) +- Empty state shown when no combatants + +### Key Patterns +- Client script imports from `@/lib/client/initiative` — Astro bundles TypeScript imports in ` diff --git a/src/components/dm/DmButton.astro b/src/components/dm/DmButton.astro new file mode 100644 index 0000000..4906109 --- /dev/null +++ b/src/components/dm/DmButton.astro @@ -0,0 +1,51 @@ +--- +export interface Props { + variant?: "primary" | "secondary" | "ghost"; + size?: "sm" | "md" | "lg"; + type?: "button" | "submit" | "reset"; + class?: string; + disabled?: boolean; + href?: string; +} + +const { + variant = "primary", + size = "md", + type = "button", + class: className = "", + disabled = false, + href, +} = Astro.props; + +const variantClasses: Record = { + primary: + "bg-[var(--accent)] text-white hover:bg-[var(--accent-light)] active:bg-[var(--accent-dark)] shadow-lg shadow-[var(--accent)]/25", + secondary: + "bg-[var(--bg-card)] text-[var(--text-primary)] border border-[var(--border-color)] hover:border-[var(--accent)] hover:text-[var(--accent)]", + ghost: + "bg-transparent text-[var(--text-secondary)] hover:text-[var(--text-primary)] hover:bg-[var(--bg-secondary)]", +}; + +const sizeClasses: Record = { + sm: "px-4 py-1.5 text-sm rounded-lg", + md: "px-6 py-2.5 text-base rounded-xl", + lg: "px-8 py-3 text-lg rounded-xl", +}; + +const baseClasses = + "inline-flex items-center justify-center font-semibold focus:outline-none focus-visible:ring-2 focus-visible:ring-[var(--accent)] focus-visible:ring-offset-2 focus-visible:ring-offset-[var(--bg-primary)] transition-colors duration-150 cursor-pointer disabled:opacity-50 disabled:cursor-not-allowed"; + +const classes = `${baseClasses} ${variantClasses[variant]} ${sizeClasses[size]} ${className}`; +--- + +{ + href ? ( + + + + ) : ( + + + + ) +} diff --git a/src/components/dm/DmCard.astro b/src/components/dm/DmCard.astro new file mode 100644 index 0000000..a97be41 --- /dev/null +++ b/src/components/dm/DmCard.astro @@ -0,0 +1,24 @@ +--- +export interface Props { + class?: string; + padding?: "none" | "sm" | "md" | "lg"; +} + +const { class: className = "", padding = "md" } = Astro.props; + +const paddingClasses: Record = { + none: "", + sm: "p-4", + md: "p-5", + lg: "p-6", +}; + +const baseClasses = + "rounded-xl bg-[var(--bg-card)] border border-[var(--border-color)] shadow-lg shadow-black/20"; + +const classes = `${baseClasses} ${paddingClasses[padding]} ${className}`; +--- + + + + diff --git a/src/components/dm/DmHeader.astro b/src/components/dm/DmHeader.astro new file mode 100644 index 0000000..d953df5 --- /dev/null +++ b/src/components/dm/DmHeader.astro @@ -0,0 +1,44 @@ +--- +export interface Props { + title?: string; + class?: string; +} + +const { title = "DM Dashboard", class: className = "" } = Astro.props; +--- + + + + + + + + + + + + + {title} + + + + + + + + diff --git a/src/components/dm/DmInput.astro b/src/components/dm/DmInput.astro new file mode 100644 index 0000000..df60d9e --- /dev/null +++ b/src/components/dm/DmInput.astro @@ -0,0 +1,60 @@ +--- +export interface Props { + id?: string; + name?: string; + type?: string; + value?: string | number; + placeholder?: string; + label?: string; + required?: boolean; + class?: string; + min?: number | string; + max?: number | string; + step?: number | string; +} + +const { + id, + name, + type = "text", + value, + placeholder, + label, + required = false, + class: className = "", + min, + max, + step, +} = Astro.props; + +const inputClasses = + "w-full bg-[var(--bg-secondary)] border border-[var(--border-color)] rounded-lg px-3 py-2 text-[var(--text-primary)] text-base placeholder:text-[var(--text-secondary)] hover:border-[var(--text-secondary)] focus:outline-none focus:border-[var(--accent)] focus:ring-1 focus:ring-[var(--accent)] focus-visible:ring-2 focus-visible:ring-[var(--accent)] focus-visible:ring-offset-2 focus-visible:ring-offset-[var(--bg-primary)] transition-colors"; + +const classes = `${inputClasses} ${className}`; +--- + + + { + label && ( + + {label} + {required && *} + + ) + } + + diff --git a/src/components/dm/InitiativeTracker.astro b/src/components/dm/InitiativeTracker.astro new file mode 100644 index 0000000..58dcf76 --- /dev/null +++ b/src/components/dm/InitiativeTracker.astro @@ -0,0 +1,297 @@ +--- +import DmButton from "./DmButton.astro"; +import DmInput from "./DmInput.astro"; +--- + + + + + + + + + + + + + + Roll d20 + + + + + Add + + + + + + + Roll d20 + modifier, or enter total manually + + + + + + + + + Turn Order + + + Clear All + + + + + + + + Next Turn + + + + + + + + No combatants yet. Add a name and roll initiative to begin. + + + + + diff --git a/src/components/dm/NotesPanel.astro b/src/components/dm/NotesPanel.astro new file mode 100644 index 0000000..8772eb0 --- /dev/null +++ b/src/components/dm/NotesPanel.astro @@ -0,0 +1,77 @@ +--- +import DmButton from './DmButton.astro'; +--- + + + + + Saved + + 0 chars + + + + + + Clear + + + + diff --git a/src/components/dm/Open5eReference.astro b/src/components/dm/Open5eReference.astro new file mode 100644 index 0000000..a86f5c3 --- /dev/null +++ b/src/components/dm/Open5eReference.astro @@ -0,0 +1,435 @@ +--- +import DmCard from "./DmCard.astro"; +import DmInput from "./DmInput.astro"; +import DmButton from "./DmButton.astro"; +--- + + + + + + Монстры + + + Заклинания + + + + + + + + + + + + Все + + + + + + + + + Загрузка... + + + + + + + + + + Ничего не найдено. Попробуйте изменить запрос или фильтры. + + + + + + + + + ← Назад + + + + Вперёд → + + + + + + + + + + + + + + + + + + diff --git a/src/db/client.ts b/src/db/client.ts new file mode 100644 index 0000000..761612f --- /dev/null +++ b/src/db/client.ts @@ -0,0 +1,9 @@ +import { drizzle } from 'drizzle-orm/node-postgres'; +import { Pool } from 'pg'; +import * as schema from './schema'; + +const pool = new Pool({ + connectionString: process.env.DATABASE_URL, +}); + +export const db = drizzle(pool, { schema }); diff --git a/src/db/migrate.ts b/src/db/migrate.ts new file mode 100644 index 0000000..e13c053 --- /dev/null +++ b/src/db/migrate.ts @@ -0,0 +1,21 @@ +import { drizzle } from 'drizzle-orm/node-postgres'; +import { migrate } from 'drizzle-orm/node-postgres/migrator'; +import { Pool } from 'pg'; + +const pool = new Pool({ + connectionString: process.env.DATABASE_URL, +}); + +const db = drizzle(pool); + +async function runMigrations() { + console.log('Running migrations...'); + await migrate(db, { migrationsFolder: './drizzle' }); + console.log('Migrations completed.'); + await pool.end(); +} + +runMigrations().catch((err) => { + console.error('Migration failed:', err); + process.exit(1); +}); diff --git a/src/db/schema.ts b/src/db/schema.ts new file mode 100644 index 0000000..c0a2ab7 --- /dev/null +++ b/src/db/schema.ts @@ -0,0 +1,18 @@ +import { pgTable, serial, varchar, timestamp, integer } from 'drizzle-orm/pg-core'; + +export const users = pgTable('users', { + id: serial('id').primaryKey(), + vkId: varchar('vk_id', { length: 255 }), + yandexId: varchar('yandex_id', { length: 255 }), + email: varchar('email', { length: 255 }), + name: varchar('name', { length: 255 }).notNull(), + avatar: varchar('avatar', { length: 500 }), + createdAt: timestamp('created_at').defaultNow().notNull(), +}); + +export const sessions = pgTable('sessions', { + id: serial('id').primaryKey(), + userId: integer('user_id').references(() => users.id).notNull(), + token: varchar('token', { length: 500 }).notNull(), + expiresAt: timestamp('expires_at').notNull(), +}); diff --git a/src/env.d.ts b/src/env.d.ts index e16c13c..2e8f658 100644 --- a/src/env.d.ts +++ b/src/env.d.ts @@ -1 +1,17 @@ /// + +interface User { + id: number; + vkId: string | null; + yandexId: string | null; + email: string | null; + name: string; + avatar: string | null; + createdAt: Date; +} + +declare namespace App { + interface Locals { + user: User | null; + } +} diff --git a/src/i18n/dm-translations.ts b/src/i18n/dm-translations.ts new file mode 100644 index 0000000..173666c --- /dev/null +++ b/src/i18n/dm-translations.ts @@ -0,0 +1,16 @@ +export const dmTranslations = { + title: "DM Dashboard", + subtitle: "Инструменты для мастера подземелий", + dice: "Кубики", + initiative: "Инициатива", + reference: "Справочник", + notes: "Заметки", + backToRandify: "← Назад к Randify", + footer: "Часть Randify — генераторов случайных значений", + anchorDice: "Кубики", + anchorInitiative: "Инициатива", + anchorReference: "Справочник", + anchorNotes: "Заметки", +} as const; + +export type DmT = typeof dmTranslations; diff --git a/src/i18n/translations.ts b/src/i18n/translations.ts index bb6d463..07bdda9 100644 --- a/src/i18n/translations.ts +++ b/src/i18n/translations.ts @@ -81,6 +81,8 @@ export const translations = { catDecisionMaking: "Decision Making", catCreative: "Creative", catUtility: "Utility", + dmDashboard: "DM Dashboard", + dmDashboardDesc: "Tools for dungeon masters — dice, initiative, reference, and notes.", notFoundTitle: "Page Not Found", notFoundMessage: @@ -192,6 +194,8 @@ export const translations = { catDecisionMaking: "Принятие решений", catCreative: "Творчество", catUtility: "Утилиты", + dmDashboard: "Для мастера", + dmDashboardDesc: "Инструменты для мастера подземелий — кубики, инициатива, справочник и заметки.", notFoundTitle: "Страница не найдена", notFoundMessage: diff --git a/src/layouts/DmLayout.astro b/src/layouts/DmLayout.astro new file mode 100644 index 0000000..cb17412 --- /dev/null +++ b/src/layouts/DmLayout.astro @@ -0,0 +1,49 @@ +--- +import BaseLayout from "./BaseLayout.astro"; +import DmHeader from "../components/dm/DmHeader.astro"; +import AuthPanel from "../components/dm/AuthPanel.astro"; +import { dmTranslations as T } from "../i18n/dm-translations"; +import "../styles/dm-theme.css"; + +interface Props { + title?: string; + description?: string; +} + +const { + title = T.title, + description = "Dungeon Master Dashboard", +} = Astro.props; +--- + + + + + + {T.backToRandify} + + + + + + + + + + + diff --git a/src/lib/auth/jwt.ts b/src/lib/auth/jwt.ts new file mode 100644 index 0000000..80fb9a0 --- /dev/null +++ b/src/lib/auth/jwt.ts @@ -0,0 +1,38 @@ +import { SignJWT, jwtVerify } from 'jose'; +import type { APIContext } from 'astro'; + +const SECRET = new TextEncoder().encode(process.env.JWT_SECRET!); + +export async function createToken(userId: string): Promise { + return new SignJWT({ sub: userId }) + .setProtectedHeader({ alg: 'HS256' }) + .setIssuedAt() + .setExpirationTime('7d') + .sign(SECRET); +} + +export async function verifyToken(token: string): Promise<{ sub: string }> { + const { payload } = await jwtVerify(token, SECRET, { + clockTolerance: 60, + }); + if (!payload.sub || typeof payload.sub !== 'string') { + throw new Error('Invalid token payload'); + } + return { sub: payload.sub }; +} + +export function setAuthCookie(token: string, context: APIContext): void { + context.cookies.set('auth_token', token, { + httpOnly: true, + secure: true, + sameSite: 'strict', + maxAge: 60 * 60 * 24 * 7, + path: '/', + }); +} + +export function clearAuthCookie(context: APIContext): void { + context.cookies.delete('auth_token', { + path: '/', + }); +} diff --git a/src/lib/auth/oauth.ts b/src/lib/auth/oauth.ts new file mode 100644 index 0000000..9af5975 --- /dev/null +++ b/src/lib/auth/oauth.ts @@ -0,0 +1,73 @@ +import { SignJWT, jwtVerify } from 'jose'; + +const JWT_SECRET = new TextEncoder().encode(import.meta.env.JWT_SECRET || ''); + +export function generateCodeVerifier(): string { + const array = new Uint8Array(64); + crypto.getRandomValues(array); + return base64UrlEncode(array); +} + +export async function generateCodeChallenge(verifier: string): Promise { + const encoder = new TextEncoder(); + const data = encoder.encode(verifier); + const hash = await crypto.subtle.digest('SHA-256', data); + return base64UrlEncode(new Uint8Array(hash)); +} + +function base64UrlEncode(buffer: Uint8Array): string { + let binary = ''; + for (let i = 0; i < buffer.byteLength; i++) { + binary += String.fromCharCode(buffer[i]); + } + return btoa(binary).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, ''); +} + +export function generateState(): string { + const array = new Uint8Array(32); + crypto.getRandomValues(array); + return base64UrlEncode(array); +} + +export const vkOAuthConfig = { + clientId: import.meta.env.VK_CLIENT_ID || '', + clientSecret: import.meta.env.VK_CLIENT_SECRET || '', + authUrl: 'https://id.vk.com/oauth2/auth', + tokenUrl: 'https://id.vk.ru/oauth2/auth', + scope: 'email phone', +}; + +export const yandexOAuthConfig = { + clientId: import.meta.env.YANDEX_CLIENT_ID || '', + clientSecret: import.meta.env.YANDEX_CLIENT_SECRET || '', + authUrl: 'https://oauth.yandex.com/authorize', + tokenUrl: 'https://oauth.yandex.com/token', + scope: 'login:email login:info login:avatar', +}; + +export async function createSessionToken(userId: number): Promise { + return new SignJWT({ sub: String(userId) }) + .setProtectedHeader({ alg: 'HS256' }) + .setIssuedAt() + .setExpirationTime('7d') + .sign(JWT_SECRET); +} + +export async function verifySessionToken(token: string): Promise<{ userId: number } | null> { + try { + const { payload } = await jwtVerify(token, JWT_SECRET, { clockTolerance: 60 }); + if (!payload.sub) return null; + return { userId: Number(payload.sub) }; + } catch { + return null; + } +} + +export const COOKIE_NAME = 'session'; +export const VERIFIER_COOKIE_NAME = 'oauth_verifier'; + +export function getCookieValue(cookieHeader: string | null, name: string): string | null { + if (!cookieHeader) return null; + const match = cookieHeader.match(new RegExp(`(?:^|;\\s*)${name}=([^;]+)`)); + return match ? decodeURIComponent(match[1]) : null; +} diff --git a/src/lib/auth/session.ts b/src/lib/auth/session.ts new file mode 100644 index 0000000..38460ed --- /dev/null +++ b/src/lib/auth/session.ts @@ -0,0 +1,32 @@ +import { eq } from 'drizzle-orm'; +import { db } from '@/db/client'; +import { sessions } from '@/db/schema'; + +export async function createSession(userId: number, token: string) { + const expiresAt = new Date(); + expiresAt.setDate(expiresAt.getDate() + 7); + + await db.insert(sessions).values({ + userId, + token, + expiresAt, + }); +} + +export async function getSession(token: string) { + const result = await db + .select() + .from(sessions) + .where(eq(sessions.token, token)) + .limit(1); + + const session = result[0]; + if (!session) return null; + if (new Date() > session.expiresAt) return null; + + return session; +} + +export async function deleteSession(token: string) { + await db.delete(sessions).where(eq(sessions.token, token)); +} diff --git a/src/lib/client/auth-panel.ts b/src/lib/client/auth-panel.ts new file mode 100644 index 0000000..7127567 --- /dev/null +++ b/src/lib/client/auth-panel.ts @@ -0,0 +1,31 @@ +export interface AuthUser { + name: string; + avatar: string | null; +} + +export type AuthPanelState = + | { type: 'guest' } + | { type: 'authenticated'; user: AuthUser }; + +export function getAuthPanelState(user: AuthUser | null): AuthPanelState { + if (!user) { + return { type: 'guest' }; + } + return { type: 'authenticated', user }; +} + +export interface AuthPanelLink { + href: string; + label: string; + style: 'vk' | 'yandex' | 'logout'; +} + +export function getAuthLinks(state: AuthPanelState): AuthPanelLink[] { + if (state.type === 'guest') { + return [ + { href: '/api/auth/login/vk', label: 'Войти через VK', style: 'vk' }, + { href: '/api/auth/login/yandex', label: 'Войти через Яндекс', style: 'yandex' }, + ]; + } + return [{ href: '/api/auth/logout', label: 'Выйти', style: 'logout' }]; +} diff --git a/src/lib/client/dice.ts b/src/lib/client/dice.ts new file mode 100644 index 0000000..0e909d9 --- /dev/null +++ b/src/lib/client/dice.ts @@ -0,0 +1,54 @@ +import { randomInt } from "./random"; + +export interface DiceNotation { + count: number; + sides: number; + modifier: number; +} + +export interface DiceRollResult { + rolls: number[]; + total: number; + modifier: number; +} + +export function parseDiceNotation(notation: string): DiceNotation { + const trimmed = notation.trim().toLowerCase(); + if (!trimmed) { + throw new Error("Empty dice notation"); + } + + const match = trimmed.match(/^(\d*)\s*d\s*(\d+)\s*([+-]\s*\d+)?$/); + if (!match) { + throw new Error(`Invalid dice notation: ${notation}`); + } + + const count = match[1] === "" ? 1 : parseInt(match[1], 10); + const sides = parseInt(match[2], 10); + const modifier = match[3] ? parseInt(match[3].replace(/\s/g, ""), 10) : 0; + + if (count < 1 || sides < 1) { + throw new Error("Dice count and sides must be at least 1"); + } + + return { count, sides, modifier }; +} + +export function rollDice( + count: number, + sides: number, + modifier: number, +): DiceRollResult { + if (count < 1 || sides < 1) { + throw new Error("Dice count and sides must be at least 1"); + } + + const rolls: number[] = []; + for (let i = 0; i < count; i++) { + rolls.push(randomInt(1, sides)); + } + + const total = rolls.reduce((sum, roll) => sum + roll, 0) + modifier; + + return { rolls, total, modifier }; +} diff --git a/src/lib/client/initiative.ts b/src/lib/client/initiative.ts new file mode 100644 index 0000000..8158897 --- /dev/null +++ b/src/lib/client/initiative.ts @@ -0,0 +1,20 @@ +import { randomInt } from "./random"; + +export interface Combatant { + id: string; + name: string; + initiative: number; +} + +export function sortByInitiative(combatants: Combatant[]): Combatant[] { + return [...combatants].sort((a, b) => b.initiative - a.initiative); +} + +export function getNextActiveIndex(currentIndex: number, total: number): number { + if (total === 0) return 0; + return (currentIndex + 1) % total; +} + +export function rollInitiative(modifier: number = 0): number { + return randomInt(1, 20) + modifier; +} diff --git a/src/lib/client/notes.ts b/src/lib/client/notes.ts new file mode 100644 index 0000000..9e7dd9b --- /dev/null +++ b/src/lib/client/notes.ts @@ -0,0 +1,13 @@ +const STORAGE_KEY = 'dm-notes'; + +export function saveNotes(content: string): void { + localStorage.setItem(STORAGE_KEY, content); +} + +export function loadNotes(): string { + return localStorage.getItem(STORAGE_KEY) ?? ''; +} + +export function clearNotes(): void { + localStorage.removeItem(STORAGE_KEY); +} diff --git a/src/lib/client/open5e-ui.ts b/src/lib/client/open5e-ui.ts new file mode 100644 index 0000000..fadaa7f --- /dev/null +++ b/src/lib/client/open5e-ui.ts @@ -0,0 +1,188 @@ +import { + searchMonsters, + searchSpells, + getMonster, + getSpell, +} from "@/lib/open5e/client"; + +export type Tab = "monsters" | "spells"; + +export interface Open5eItem { + key: string; + name: string; +} + +export interface UIState { + tab: Tab; + query: string; + monsterCrFilter: string; + spellLevelFilter: string; + page: number; + pageSize: number; + results: Open5eItem[]; + totalPages: number; + loading: boolean; + error: string | null; + selectedItem: Open5eItem | null; +} + +export class Open5eUIManager { + state: UIState; + onUpdate?: (state: UIState) => void; + private debounceTimer: ReturnType | null = null; + + constructor(onUpdate?: (state: UIState) => void) { + this.onUpdate = onUpdate; + this.state = { + tab: "monsters", + query: "", + monsterCrFilter: "", + spellLevelFilter: "", + page: 1, + pageSize: 10, + results: [], + totalPages: 1, + loading: false, + error: null, + selectedItem: null, + }; + } + + private notify() { + this.onUpdate?.({ ...this.state }); + } + + async setTab(tab: Tab): Promise { + this.state.tab = tab; + this.state.page = 1; + this.state.selectedItem = null; + this.notify(); + await this.search(); + } + + setQuery(query: string): Promise { + this.state.query = query; + this.state.page = 1; + this.notify(); + return this.search(); + } + + setQueryDebounced(query: string): void { + this.state.query = query; + this.state.page = 1; + this.notify(); + if (this.debounceTimer) clearTimeout(this.debounceTimer); + this.debounceTimer = setTimeout(() => { + this.search(); + }, 300); + } + + setMonsterCrFilter(cr: string): void { + this.state.monsterCrFilter = cr; + this.state.page = 1; + this.notify(); + } + + setSpellLevelFilter(level: string): void { + this.state.spellLevelFilter = level; + this.state.page = 1; + this.notify(); + } + + async search(): Promise { + this.state.loading = true; + this.state.error = null; + this.notify(); + + try { + let results: Open5eItem[] = []; + if (this.state.tab === "monsters") { + const filters = this.state.monsterCrFilter + ? { challenge_rating_decimal: this.state.monsterCrFilter } + : undefined; + results = await searchMonsters(this.state.query, filters); + } else { + const filters = this.state.spellLevelFilter + ? { level: this.state.spellLevelFilter } + : undefined; + results = await searchSpells(this.state.query, filters); + } + this.state.results = results; + this.state.totalPages = Math.max( + 1, + Math.ceil(results.length / this.state.pageSize) + ); + } catch (err: unknown) { + this.state.error = err instanceof Error ? err.message : "Unknown error"; + this.state.results = []; + this.state.totalPages = 1; + } finally { + this.state.loading = false; + this.notify(); + } + } + + getPaginatedResults(): Open5eItem[] { + const start = (this.state.page - 1) * this.state.pageSize; + const end = start + this.state.pageSize; + return this.state.results.slice(start, end); + } + + nextPage(): void { + if (this.state.page < this.state.totalPages) { + this.state.page++; + this.notify(); + } + } + + prevPage(): void { + if (this.state.page > 1) { + this.state.page--; + this.notify(); + } + } + + async selectItem(key: string): Promise { + this.state.loading = true; + this.notify(); + try { + if (this.state.tab === "monsters") { + this.state.selectedItem = await getMonster(key); + } else { + this.state.selectedItem = await getSpell(key); + } + } catch (err: unknown) { + this.state.error = err instanceof Error ? err.message : "Failed to load details"; + } finally { + this.state.loading = false; + this.notify(); + } + } + + clearSelectedItem(): void { + this.state.selectedItem = null; + this.notify(); + } + + static formatCr(cr: string): string { + const crMap: Record = { + "0.125": "1/8", + "0.25": "1/4", + "0.5": "1/2", + "1.00": "1", + "2.00": "2", + "3.00": "3", + "5.00": "5", + "10.00": "10", + "15.00": "15", + "20.00": "20", + "30.00": "30", + }; + return crMap[cr] ?? String(parseFloat(cr)); + } + + static formatSpellLevel(level: number): string { + if (level === 0) return "Заговор"; + return `${level}-й уровень`; + } +} diff --git a/src/lib/open5e/cache.ts b/src/lib/open5e/cache.ts new file mode 100644 index 0000000..5db587b --- /dev/null +++ b/src/lib/open5e/cache.ts @@ -0,0 +1,37 @@ +const ONE_DAY_MS = 24 * 60 * 60 * 1000; + +interface CacheEntry { + data: T; + timestamp: number; +} + +export function getCached(key: string): T | null { + if (typeof localStorage === "undefined") return null; + try { + const raw = localStorage.getItem(key); + if (!raw) return null; + const entry: CacheEntry = JSON.parse(raw); + if (Date.now() - entry.timestamp > ONE_DAY_MS) { + localStorage.removeItem(key); + return null; + } + return entry.data; + } catch { + return null; + } +} + +function safeStorageSet(key: string, value: string): boolean { + try { + localStorage.setItem(key, value); + return true; + } catch { + return false; + } +} + +export function setCached(key: string, data: T): void { + if (typeof localStorage === "undefined") return; + const entry: CacheEntry = { data, timestamp: Date.now() }; + safeStorageSet(key, JSON.stringify(entry)); +} diff --git a/src/lib/open5e/client.ts b/src/lib/open5e/client.ts new file mode 100644 index 0000000..b95378d --- /dev/null +++ b/src/lib/open5e/client.ts @@ -0,0 +1,131 @@ +import { getCached, setCached } from "./cache"; + +const API_BASE = "https://api.open5e.com/v2"; + +export interface Monster { + name: string; + key: string; + challenge_rating_decimal: string; + type: string; + hit_points: number; + armor_class: number; +} + +export interface Spell { + name: string; + key: string; + level: number; + school: string; +} + +interface SearchFilters { + [key: string]: string; +} + +async function apiFetch(url: string): Promise { + const res = await fetch(url, { + headers: { Accept: "application/json" }, + }); + if (!res.ok) { + throw new Error(`${res.status}: ${res.statusText}`); + } + return res.json(); +} + +function buildUrl(endpoint: string, query?: string, filters?: SearchFilters): string { + const url = new URL(endpoint, API_BASE); + if (query) { + url.searchParams.set("name__icontains", query); + } + if (filters) { + for (const [key, value] of Object.entries(filters)) { + url.searchParams.set(key, value); + } + } + return url.toString(); +} + +export async function searchMonsters( + query: string, + filters?: SearchFilters +): Promise { + const cacheKey = `open5e:monsters:${query}`; + const cached = getCached(cacheKey); + if (cached) return cached; + + const url = buildUrl("/creatures/", query, filters); + const urlObj = new URL(url); + urlObj.searchParams.set( + "fields", + "name,key,challenge_rating_decimal,type,hit_points,armor_class" + ); + + try { + const data = await apiFetch<{ results: Monster[] }>(urlObj.toString()); + setCached(cacheKey, data.results); + return data.results; + } catch (err) { + const stale = getCached(cacheKey); + if (stale) return stale; + throw err; + } +} + +export async function getMonster(key: string): Promise { + const cacheKey = `open5e:monster:${key}`; + const cached = getCached(cacheKey); + if (cached) return cached; + + const url = `${API_BASE}/creatures/${key}/`; + + try { + const data = await apiFetch(url); + setCached(cacheKey, data); + return data; + } catch (err) { + const stale = getCached(cacheKey); + if (stale) return stale; + throw err; + } +} + +export async function searchSpells( + query: string, + filters?: SearchFilters +): Promise { + const cacheKey = `open5e:spells:${query}`; + const cached = getCached(cacheKey); + if (cached) return cached; + + const url = buildUrl("/spells/", query, filters); + const urlObj = new URL(url); + urlObj.searchParams.set("fields", "name,key,level,school"); + + try { + const data = await apiFetch<{ results: Spell[] }>(urlObj.toString()); + setCached(cacheKey, data.results); + return data.results; + } catch (err) { + const stale = getCached(cacheKey); + if (stale) return stale; + throw err; + } +} + +export async function getSpell(key: string): Promise { + const cacheKey = `open5e:spell:${key}`; + const cached = getCached(cacheKey); + if (cached) return cached; + + const url = `${API_BASE}/spells/${key}/`; + + try { + const data = await apiFetch(url); + setCached(cacheKey, data); + return data; + } catch (err) { + const stale = getCached(cacheKey); + if (stale) return stale; + throw err; + } +} diff --git a/src/middleware/index.ts b/src/middleware/index.ts new file mode 100644 index 0000000..7c1273f --- /dev/null +++ b/src/middleware/index.ts @@ -0,0 +1,38 @@ +import { defineMiddleware } from 'astro:middleware'; +import { verifyToken } from '@/lib/auth/jwt'; +import { getSession } from '@/lib/auth/session'; +import { db } from '@/db/client'; +import { users } from '@/db/schema'; +import { eq } from 'drizzle-orm'; + +export const onRequest = defineMiddleware(async (context, next) => { + context.locals.user = null; + + const token = context.cookies.get('auth_token')?.value; + if (!token) { + return next(); + } + + try { + await verifyToken(token); + const session = await getSession(token); + if (!session) { + return next(); + } + + const userResult = await db + .select() + .from(users) + .where(eq(users.id, session.userId)) + .limit(1); + + const user = userResult[0]; + if (user) { + context.locals.user = user; + } + } catch { + /* ignore invalid tokens */ + } + + return next(); +}); diff --git a/src/pages/404.astro b/src/pages/404.astro index 91cb6f9..d867580 100644 --- a/src/pages/404.astro +++ b/src/pages/404.astro @@ -18,6 +18,7 @@ const notFoundSchema = { inLanguage: lang, }; --- +export const prerender = true; diff --git a/src/pages/about.astro b/src/pages/about.astro index 3be90c6..a1153db 100644 --- a/src/pages/about.astro +++ b/src/pages/about.astro @@ -15,6 +15,7 @@ const aboutSchema = { inLanguage: lang, }; --- +export const prerender = true; diff --git a/src/pages/api/auth/callback/vk.ts b/src/pages/api/auth/callback/vk.ts new file mode 100644 index 0000000..a468ea8 --- /dev/null +++ b/src/pages/api/auth/callback/vk.ts @@ -0,0 +1,119 @@ +import type { APIRoute } from 'astro'; +import { eq } from 'drizzle-orm'; +import { db } from '@/db/client'; +import { users, sessions } from '@/db/schema'; +import { + vkOAuthConfig, + VERIFIER_COOKIE_NAME, + getCookieValue, + createSessionToken, + COOKIE_NAME, +} from '@/lib/auth/oauth'; + +export const prerender = false; + +export const GET: APIRoute = async ({ url, request }) => { + const code = url.searchParams.get('code'); + const state = url.searchParams.get('state'); + const cookieHeader = request.headers.get('cookie'); + const verifier = getCookieValue(cookieHeader, VERIFIER_COOKIE_NAME); + const storedState = getCookieValue(cookieHeader, 'oauth_state'); + + if (!code || !state || !verifier || state !== storedState) { + return new Response(JSON.stringify({ error: 'Invalid or missing parameters' }), { + status: 400, + headers: { 'Content-Type': 'application/json' }, + }); + } + + const redirectUri = `${url.origin}/api/auth/callback/vk`; + + const tokenRes = await fetch(vkOAuthConfig.tokenUrl, { + method: 'POST', + headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, + body: new URLSearchParams({ + client_id: vkOAuthConfig.clientId, + client_secret: vkOAuthConfig.clientSecret, + grant_type: 'authorization_code', + code, + redirect_uri: redirectUri, + code_verifier: verifier, + }), + }); + + if (!tokenRes.ok) { + return new Response(JSON.stringify({ error: 'Token exchange failed' }), { + status: 500, + headers: { 'Content-Type': 'application/json' }, + }); + } + + const tokenData = await tokenRes.json(); + const accessToken = tokenData.access_token; + const userIdStr = tokenData.user_id || tokenData.user?.user_id; + + if (!accessToken || !userIdStr) { + return new Response(JSON.stringify({ error: 'Invalid token response' }), { + status: 500, + headers: { 'Content-Type': 'application/json' }, + }); + } + + const userInfoRes = await fetch( + `https://api.vk.com/method/users.get?user_ids=${userIdStr}&fields=photo_200,email&access_token=${accessToken}&v=5.199` + ); + const userInfoData = await userInfoRes.json(); + const vkUser = userInfoData.response?.[0]; + + if (!vkUser) { + return new Response(JSON.stringify({ error: 'Failed to fetch user info' }), { + status: 500, + headers: { 'Content-Type': 'application/json' }, + }); + } + + const email = tokenData.email || null; + const name = `${vkUser.first_name || ''} ${vkUser.last_name || ''}`.trim(); + const avatar = vkUser.photo_200 || null; + + const existingUsers = await db.select().from(users).where(eq(users.vkId, String(userIdStr))); + let user; + + if (existingUsers.length > 0) { + user = existingUsers[0]; + await db + .update(users) + .set({ name, email: email || user.email, avatar: avatar || user.avatar }) + .where(eq(users.id, user.id)); + } else { + const inserted = await db + .insert(users) + .values({ vkId: String(userIdStr), email, name, avatar }) + .returning(); + user = inserted[0]; + } + + const sessionToken = await createSessionToken(user.id); + const expiresAt = new Date(); + expiresAt.setDate(expiresAt.getDate() + 7); + + await db.insert(sessions).values({ + userId: user.id, + token: sessionToken, + expiresAt, + }); + + const cookies = [ + `${COOKIE_NAME}=${sessionToken}; HttpOnly; SameSite=Strict; Max-Age=604800; Path=/`, + `${VERIFIER_COOKIE_NAME}=; HttpOnly; SameSite=Strict; Max-Age=0; Path=/`, + `oauth_state=; HttpOnly; SameSite=Strict; Max-Age=0; Path=/`, + ]; + + return new Response(null, { + status: 302, + headers: { + Location: '/dm/', + 'Set-Cookie': cookies.join(', '), + }, + }); +}; diff --git a/src/pages/api/auth/callback/yandex.ts b/src/pages/api/auth/callback/yandex.ts new file mode 100644 index 0000000..c1237e6 --- /dev/null +++ b/src/pages/api/auth/callback/yandex.ts @@ -0,0 +1,120 @@ +import type { APIRoute } from 'astro'; +import { eq } from 'drizzle-orm'; +import { db } from '@/db/client'; +import { users, sessions } from '@/db/schema'; +import { + yandexOAuthConfig, + VERIFIER_COOKIE_NAME, + getCookieValue, + createSessionToken, + COOKIE_NAME, +} from '@/lib/auth/oauth'; + +export const prerender = false; + +export const GET: APIRoute = async ({ url, request }) => { + const code = url.searchParams.get('code'); + const state = url.searchParams.get('state'); + const cookieHeader = request.headers.get('cookie'); + const verifier = getCookieValue(cookieHeader, VERIFIER_COOKIE_NAME); + const storedState = getCookieValue(cookieHeader, 'oauth_state'); + + if (!code || !state || !verifier || state !== storedState) { + return new Response(JSON.stringify({ error: 'Invalid or missing parameters' }), { + status: 400, + headers: { 'Content-Type': 'application/json' }, + }); + } + + const redirectUri = `${url.origin}/api/auth/callback/yandex`; + + const tokenRes = await fetch(yandexOAuthConfig.tokenUrl, { + method: 'POST', + headers: { + 'Content-Type': 'application/x-www-form-urlencoded', + Authorization: `Basic ${btoa(`${yandexOAuthConfig.clientId}:${yandexOAuthConfig.clientSecret}`)}`, + }, + body: new URLSearchParams({ + grant_type: 'authorization_code', + code, + redirect_uri: redirectUri, + code_verifier: verifier, + }), + }); + + if (!tokenRes.ok) { + return new Response(JSON.stringify({ error: 'Token exchange failed' }), { + status: 500, + headers: { 'Content-Type': 'application/json' }, + }); + } + + const tokenData = await tokenRes.json(); + const accessToken = tokenData.access_token; + + if (!accessToken) { + return new Response(JSON.stringify({ error: 'Invalid token response' }), { + status: 500, + headers: { 'Content-Type': 'application/json' }, + }); + } + + const userInfoRes = await fetch('https://login.yandex.ru/info?format=json', { + headers: { Authorization: `OAuth ${accessToken}` }, + }); + const yandexUser = await userInfoRes.json(); + + if (!yandexUser || !yandexUser.id) { + return new Response(JSON.stringify({ error: 'Failed to fetch user info' }), { + status: 500, + headers: { 'Content-Type': 'application/json' }, + }); + } + + const email = yandexUser.default_email || yandexUser.emails?.[0] || null; + const name = [yandexUser.first_name, yandexUser.last_name].filter(Boolean).join(' ').trim() || yandexUser.login || yandexUser.display_name || 'Yandex User'; + const avatar = yandexUser.default_avatar_id + ? `https://avatars.yandex.net/get-yapic/${yandexUser.default_avatar_id}/islands-200` + : null; + + const existingUsers = await db.select().from(users).where(eq(users.yandexId, String(yandexUser.id))); + let user; + + if (existingUsers.length > 0) { + user = existingUsers[0]; + await db + .update(users) + .set({ name, email: email || user.email, avatar: avatar || user.avatar }) + .where(eq(users.id, user.id)); + } else { + const inserted = await db + .insert(users) + .values({ yandexId: String(yandexUser.id), email, name, avatar }) + .returning(); + user = inserted[0]; + } + + const sessionToken = await createSessionToken(user.id); + const expiresAt = new Date(); + expiresAt.setDate(expiresAt.getDate() + 7); + + await db.insert(sessions).values({ + userId: user.id, + token: sessionToken, + expiresAt, + }); + + const cookies = [ + `${COOKIE_NAME}=${sessionToken}; HttpOnly; SameSite=Strict; Max-Age=604800; Path=/`, + `${VERIFIER_COOKIE_NAME}=; HttpOnly; SameSite=Strict; Max-Age=0; Path=/`, + `oauth_state=; HttpOnly; SameSite=Strict; Max-Age=0; Path=/`, + ]; + + return new Response(null, { + status: 302, + headers: { + Location: '/dm/', + 'Set-Cookie': cookies.join(', '), + }, + }); +}; diff --git a/src/pages/api/auth/login/vk.ts b/src/pages/api/auth/login/vk.ts new file mode 100644 index 0000000..b2ffdb9 --- /dev/null +++ b/src/pages/api/auth/login/vk.ts @@ -0,0 +1,37 @@ +import type { APIRoute } from 'astro'; +import { generateCodeVerifier, generateCodeChallenge, generateState, vkOAuthConfig, VERIFIER_COOKIE_NAME } from '@/lib/auth/oauth'; + +export const prerender = false; + +export const GET: APIRoute = async ({ url }) => { + const verifier = generateCodeVerifier(); + const challenge = await generateCodeChallenge(verifier); + const state = generateState(); + + const redirectUri = `${import.meta.env.PUBLIC_APP_URL || url.origin}/api/auth/callback/vk`; + + const params = new URLSearchParams({ + client_id: vkOAuthConfig.clientId, + redirect_uri: redirectUri, + response_type: 'code', + scope: vkOAuthConfig.scope, + state, + code_challenge: challenge, + code_challenge_method: 'S256', + }); + + const redirectUrl = `${vkOAuthConfig.authUrl}?${params.toString()}`; + + const cookies = [ + `${VERIFIER_COOKIE_NAME}=${encodeURIComponent(verifier)}; HttpOnly; SameSite=Strict; Max-Age=600; Path=/`, + `oauth_state=${encodeURIComponent(state)}; HttpOnly; SameSite=Strict; Max-Age=600; Path=/`, + ]; + + return new Response(null, { + status: 302, + headers: { + Location: redirectUrl, + 'Set-Cookie': cookies.join(', '), + }, + }); +}; diff --git a/src/pages/api/auth/login/yandex.ts b/src/pages/api/auth/login/yandex.ts new file mode 100644 index 0000000..e503525 --- /dev/null +++ b/src/pages/api/auth/login/yandex.ts @@ -0,0 +1,37 @@ +import type { APIRoute } from 'astro'; +import { generateCodeVerifier, generateCodeChallenge, generateState, yandexOAuthConfig, VERIFIER_COOKIE_NAME } from '@/lib/auth/oauth'; + +export const prerender = false; + +export const GET: APIRoute = async ({ url }) => { + const verifier = generateCodeVerifier(); + const challenge = await generateCodeChallenge(verifier); + const state = generateState(); + + const redirectUri = `${import.meta.env.PUBLIC_APP_URL || url.origin}/api/auth/callback/yandex`; + + const params = new URLSearchParams({ + client_id: yandexOAuthConfig.clientId, + redirect_uri: redirectUri, + response_type: 'code', + scope: yandexOAuthConfig.scope, + state, + code_challenge: challenge, + code_challenge_method: 'S256', + }); + + const redirectUrl = `${yandexOAuthConfig.authUrl}?${params.toString()}`; + + const cookies = [ + `${VERIFIER_COOKIE_NAME}=${encodeURIComponent(verifier)}; HttpOnly; SameSite=Strict; Max-Age=600; Path=/`, + `oauth_state=${encodeURIComponent(state)}; HttpOnly; SameSite=Strict; Max-Age=600; Path=/`, + ]; + + return new Response(null, { + status: 302, + headers: { + Location: redirectUrl, + 'Set-Cookie': cookies.join(', '), + }, + }); +}; diff --git a/src/pages/api/auth/logout.ts b/src/pages/api/auth/logout.ts new file mode 100644 index 0000000..cc1a6cf --- /dev/null +++ b/src/pages/api/auth/logout.ts @@ -0,0 +1,18 @@ +import type { APIRoute } from 'astro'; +import { COOKIE_NAME } from '@/lib/auth/oauth'; + +export const prerender = false; + +export const GET: APIRoute = async () => { + const cookies = [ + `${COOKIE_NAME}=; HttpOnly; SameSite=Strict; Max-Age=0; Path=/`, + ]; + + return new Response(null, { + status: 302, + headers: { + Location: '/dm/', + 'Set-Cookie': cookies.join(', '), + }, + }); +}; diff --git a/src/pages/api/health.ts b/src/pages/api/health.ts new file mode 100644 index 0000000..5c0ed98 --- /dev/null +++ b/src/pages/api/health.ts @@ -0,0 +1,15 @@ +import type { APIRoute } from "astro"; + +export const prerender = false; + +export const GET: APIRoute = () => { + return new Response( + JSON.stringify({ status: "ok" }), + { + status: 200, + headers: { + "Content-Type": "application/json", + }, + } + ); +}; diff --git a/src/pages/blog/[slug].astro b/src/pages/blog/[slug].astro index 9cfb9df..51e3b4f 100644 --- a/src/pages/blog/[slug].astro +++ b/src/pages/blog/[slug].astro @@ -31,6 +31,7 @@ export async function getStaticPaths() { const { post, prev, next } = Astro.props; const { Content } = await post.render(); --- +export const prerender = true;
+ No combatants yet. Add a name and roll initiative to begin. +
Загрузка...
Ничего не найдено. Попробуйте изменить запрос или фильтры.