Compare commits
10 Commits
29d240424b
...
93b1624191
| Author | SHA1 | Date | |
|---|---|---|---|
| 93b1624191 | |||
| fdffc9bdf6 | |||
| 464c858970 | |||
| 5f18274b2c | |||
| 11c1e012d5 | |||
| 01f5b90c99 | |||
| cc6ceac3a0 | |||
| b7faf2d891 | |||
| 41f8e3ef42 | |||
| 6c341045b8 |
@@ -15,3 +15,4 @@ server/uploads/
|
|||||||
|
|
||||||
# Plans and design docs
|
# Plans and design docs
|
||||||
.agents
|
.agents
|
||||||
|
server/prisma/prisma/dev.db
|
||||||
|
|||||||
Generated
+71
-3
@@ -708,9 +708,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@emnapi/wasi-threads": {
|
"node_modules/@emnapi/wasi-threads": {
|
||||||
"version": "1.2.1",
|
"version": "1.2.2",
|
||||||
"resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.1.tgz",
|
"resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.2.tgz",
|
||||||
"integrity": "sha512-uTII7OYF+/Mes/MrcIOYp5yOtSMLBWSIoLPpcgwipoiKbli6k322tcoFsxoIIxPDqW01SQGAgko4EzZi2BNv2w==",
|
"integrity": "sha512-c95qOXkHdydNKhscBTebqEC1CVAZpyqOfVfBzQ1qgzyl3gfeldUjIggDbIZgDKsHLgnsM+igH7TJ/eAasaVuMA==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
@@ -2214,6 +2214,40 @@
|
|||||||
"node": "^20.19.0 || >=22.12.0"
|
"node": "^20.19.0 || >=22.12.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@rolldown/binding-wasm32-wasi/node_modules/@emnapi/core": {
|
||||||
|
"version": "1.10.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.10.0.tgz",
|
||||||
|
"integrity": "sha512-yq6OkJ4p82CAfPl0u9mQebQHKPJkY7WrIuk205cTYnYe+k2Z8YBh11FrbRG/H6ihirqcacOgl2BIO8oyMQLeXw==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"dependencies": {
|
||||||
|
"@emnapi/wasi-threads": "1.2.1",
|
||||||
|
"tslib": "^2.4.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@rolldown/binding-wasm32-wasi/node_modules/@emnapi/runtime": {
|
||||||
|
"version": "1.10.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.10.0.tgz",
|
||||||
|
"integrity": "sha512-ewvYlk86xUoGI0zQRNq/mC+16R1QeDlKQy21Ki3oSYXNgLb45GV1P6A0M+/s6nyCuNDqe5VpaY84BzXGwVbwFA==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"dependencies": {
|
||||||
|
"tslib": "^2.4.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@rolldown/binding-wasm32-wasi/node_modules/@emnapi/wasi-threads": {
|
||||||
|
"version": "1.2.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.1.tgz",
|
||||||
|
"integrity": "sha512-uTII7OYF+/Mes/MrcIOYp5yOtSMLBWSIoLPpcgwipoiKbli6k322tcoFsxoIIxPDqW01SQGAgko4EzZi2BNv2w==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"dependencies": {
|
||||||
|
"tslib": "^2.4.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@rolldown/binding-win32-arm64-msvc": {
|
"node_modules/@rolldown/binding-win32-arm64-msvc": {
|
||||||
"version": "1.0.2",
|
"version": "1.0.2",
|
||||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.0.2.tgz",
|
"resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.0.2.tgz",
|
||||||
@@ -3826,6 +3860,40 @@
|
|||||||
"node": ">=14.0.0"
|
"node": ">=14.0.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@unrs/resolver-binding-wasm32-wasi/node_modules/@emnapi/core": {
|
||||||
|
"version": "1.10.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.10.0.tgz",
|
||||||
|
"integrity": "sha512-yq6OkJ4p82CAfPl0u9mQebQHKPJkY7WrIuk205cTYnYe+k2Z8YBh11FrbRG/H6ihirqcacOgl2BIO8oyMQLeXw==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"dependencies": {
|
||||||
|
"@emnapi/wasi-threads": "1.2.1",
|
||||||
|
"tslib": "^2.4.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@unrs/resolver-binding-wasm32-wasi/node_modules/@emnapi/runtime": {
|
||||||
|
"version": "1.10.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.10.0.tgz",
|
||||||
|
"integrity": "sha512-ewvYlk86xUoGI0zQRNq/mC+16R1QeDlKQy21Ki3oSYXNgLb45GV1P6A0M+/s6nyCuNDqe5VpaY84BzXGwVbwFA==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"dependencies": {
|
||||||
|
"tslib": "^2.4.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@unrs/resolver-binding-wasm32-wasi/node_modules/@emnapi/wasi-threads": {
|
||||||
|
"version": "1.2.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.1.tgz",
|
||||||
|
"integrity": "sha512-uTII7OYF+/Mes/MrcIOYp5yOtSMLBWSIoLPpcgwipoiKbli6k322tcoFsxoIIxPDqW01SQGAgko4EzZi2BNv2w==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"dependencies": {
|
||||||
|
"tslib": "^2.4.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@unrs/resolver-binding-win32-arm64-msvc": {
|
"node_modules/@unrs/resolver-binding-win32-arm64-msvc": {
|
||||||
"version": "1.12.2",
|
"version": "1.12.2",
|
||||||
"resolved": "https://registry.npmjs.org/@unrs/resolver-binding-win32-arm64-msvc/-/resolver-binding-win32-arm64-msvc-1.12.2.tgz",
|
"resolved": "https://registry.npmjs.org/@unrs/resolver-binding-win32-arm64-msvc/-/resolver-binding-win32-arm64-msvc-1.12.2.tgz",
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { AppRoutes } from '@/app/routes'
|
|||||||
import { NotificationStack } from '@/shared/ui/NotificationStack'
|
import { NotificationStack } from '@/shared/ui/NotificationStack'
|
||||||
import { ErrorBoundary } from '@/shared/ui/ErrorBoundary'
|
import { ErrorBoundary } from '@/shared/ui/ErrorBoundary'
|
||||||
import { NoiseOverlay } from '@/shared/ui/NoiseOverlay'
|
import { NoiseOverlay } from '@/shared/ui/NoiseOverlay'
|
||||||
|
import { DemoOverlay } from '@/shared/ui/DemoOverlay'
|
||||||
|
|
||||||
export function App() {
|
export function App() {
|
||||||
return (
|
return (
|
||||||
@@ -14,6 +15,7 @@ export function App() {
|
|||||||
</ErrorBoundary>
|
</ErrorBoundary>
|
||||||
<NotificationStack />
|
<NotificationStack />
|
||||||
<NoiseOverlay />
|
<NoiseOverlay />
|
||||||
|
<DemoOverlay />
|
||||||
</BrowserRouter>
|
</BrowserRouter>
|
||||||
</AppProviders>
|
</AppProviders>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ import Typography from '@mui/material/Typography'
|
|||||||
import { Controller, type UseFormReturn } from 'react-hook-form'
|
import { Controller, type UseFormReturn } from 'react-hook-form'
|
||||||
import type { Category } from '@/entities/product/model/types'
|
import type { Category } from '@/entities/product/model/types'
|
||||||
import { OptimizedImage } from '@/shared/ui/OptimizedImage'
|
import { OptimizedImage } from '@/shared/ui/OptimizedImage'
|
||||||
|
import { RichTextMessageEditor } from '@/shared/ui/RichTextMessageEditor'
|
||||||
import { isValidProductPriceRub, isValidProductQuantity } from '../lib/use-product-form-helpers'
|
import { isValidProductPriceRub, isValidProductQuantity } from '../lib/use-product-form-helpers'
|
||||||
import type { FormState } from '../model/types'
|
import type { FormState } from '../model/types'
|
||||||
|
|
||||||
@@ -63,11 +64,19 @@ export function ProductFormFields({
|
|||||||
<TextField label="Краткое описание (для каталога)" fullWidth multiline minRows={2} {...field} />
|
<TextField label="Краткое описание (для каталога)" fullWidth multiline minRows={2} {...field} />
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
|
<Box>
|
||||||
|
<Typography variant="subtitle2" sx={{ mb: 0.5 }}>
|
||||||
|
Описание
|
||||||
|
</Typography>
|
||||||
|
<FormHelperText sx={{ mt: 0, mb: 1 }}>Стилизованный текст: жирный, курсив, список</FormHelperText>
|
||||||
<Controller
|
<Controller
|
||||||
control={form.control}
|
control={form.control}
|
||||||
name="description"
|
name="description"
|
||||||
render={({ field }) => <TextField label="Описание" fullWidth multiline minRows={2} {...field} />}
|
render={({ field }) => (
|
||||||
|
<RichTextMessageEditor value={field.value} onChange={field.onChange} placeholder="Описание товара" />
|
||||||
|
)}
|
||||||
/>
|
/>
|
||||||
|
</Box>
|
||||||
<Controller
|
<Controller
|
||||||
control={form.control}
|
control={form.control}
|
||||||
name="materials"
|
name="materials"
|
||||||
|
|||||||
@@ -173,7 +173,7 @@ export function ProductFilters({
|
|||||||
value={String(pageSize)}
|
value={String(pageSize)}
|
||||||
onChange={handlePageSizeChange}
|
onChange={handlePageSizeChange}
|
||||||
>
|
>
|
||||||
{[6, 12, 18, 24].map((n) => (
|
{[8, 12, 16, 20, 24].map((n) => (
|
||||||
<MenuItem key={n} value={String(n)}>
|
<MenuItem key={n} value={String(n)}>
|
||||||
{n}
|
{n}
|
||||||
</MenuItem>
|
</MenuItem>
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ import { useParams } from 'react-router-dom'
|
|||||||
import { Navigation } from 'swiper/modules'
|
import { Navigation } from 'swiper/modules'
|
||||||
import { Swiper, SwiperSlide } from 'swiper/react'
|
import { Swiper, SwiperSlide } from 'swiper/react'
|
||||||
import 'swiper/css'
|
import 'swiper/css'
|
||||||
|
import 'swiper/css/navigation'
|
||||||
import { fetchPublicProduct } from '@/entities/product/api/product-api'
|
import { fetchPublicProduct } from '@/entities/product/api/product-api'
|
||||||
import { ToggleCartIcon } from '@/features/cart/toggle-cart-icon'
|
import { ToggleCartIcon } from '@/features/cart/toggle-cart-icon'
|
||||||
import { ProductReviewsList } from '@/features/product-review'
|
import { ProductReviewsList } from '@/features/product-review'
|
||||||
@@ -25,6 +26,7 @@ import { reviewsCountRu } from '@/shared/lib/reviews-count-ru'
|
|||||||
import { usePageTitle } from '@/shared/lib/use-page-title'
|
import { usePageTitle } from '@/shared/lib/use-page-title'
|
||||||
import { $user } from '@/shared/model/auth'
|
import { $user } from '@/shared/model/auth'
|
||||||
import { OptimizedImage } from '@/shared/ui/OptimizedImage'
|
import { OptimizedImage } from '@/shared/ui/OptimizedImage'
|
||||||
|
import { RichTextMessageContent } from '@/shared/ui/RichTextMessageContent'
|
||||||
|
|
||||||
export function ProductPage() {
|
export function ProductPage() {
|
||||||
const user = useUnit($user)
|
const user = useUnit($user)
|
||||||
@@ -165,8 +167,8 @@ export function ProductPage() {
|
|||||||
|
|
||||||
{!isAdmin && p.quantity > 0 ? <ToggleCartIcon productId={p.id} size="medium" /> : null}
|
{!isAdmin && p.quantity > 0 ? <ToggleCartIcon productId={p.id} size="medium" /> : null}
|
||||||
|
|
||||||
{p.description || p.shortDescription ? (
|
{p.description ? (
|
||||||
<Typography sx={{ whiteSpace: 'pre-wrap' }}>{p.description}</Typography>
|
<RichTextMessageContent value={p.description ?? ''} tone="product" />
|
||||||
) : (
|
) : (
|
||||||
<Typography color="text.secondary">Описание появится позже.</Typography>
|
<Typography color="text.secondary">Описание появится позже.</Typography>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -0,0 +1,56 @@
|
|||||||
|
import Box from '@mui/material/Box'
|
||||||
|
import { useTheme } from '@mui/material/styles'
|
||||||
|
import { IS_DEMO_MODE } from '@/shared/config'
|
||||||
|
|
||||||
|
export function DemoOverlay() {
|
||||||
|
const theme = useTheme()
|
||||||
|
const isDark = theme.palette.mode === 'dark'
|
||||||
|
|
||||||
|
if (!IS_DEMO_MODE) return null
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Box
|
||||||
|
aria-hidden="true"
|
||||||
|
sx={{
|
||||||
|
position: 'fixed',
|
||||||
|
inset: 0,
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
pointerEvents: 'none',
|
||||||
|
zIndex: 9990,
|
||||||
|
fontSize: '10vw',
|
||||||
|
fontWeight: 900,
|
||||||
|
color: isDark ? 'rgba(255,255,255,0.04)' : 'rgba(0,0,0,0.04)',
|
||||||
|
transform: 'rotate(-30deg)',
|
||||||
|
userSelect: 'none',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
ДЕМО
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
<Box
|
||||||
|
aria-hidden="true"
|
||||||
|
sx={{
|
||||||
|
position: 'fixed',
|
||||||
|
bottom: 16,
|
||||||
|
right: 16,
|
||||||
|
pointerEvents: 'none',
|
||||||
|
zIndex: 9991,
|
||||||
|
px: 2,
|
||||||
|
py: 0.75,
|
||||||
|
borderRadius: 1,
|
||||||
|
fontSize: 12,
|
||||||
|
fontWeight: 600,
|
||||||
|
letterSpacing: '0.04em',
|
||||||
|
color: isDark ? 'rgba(255,255,255,0.6)' : '#fff',
|
||||||
|
bgcolor: isDark ? 'rgba(255,255,255,0.08)' : 'rgba(0,0,0,0.6)',
|
||||||
|
userSelect: 'none',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
ДЕМО-РЕЖИМ
|
||||||
|
</Box>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -5,7 +5,7 @@ import TiptapStarterKit from '@tiptap/starter-kit'
|
|||||||
|
|
||||||
type RichTextMessageContentProps = {
|
type RichTextMessageContentProps = {
|
||||||
value: string
|
value: string
|
||||||
tone?: 'default' | 'review' | 'chat'
|
tone?: 'default' | 'review' | 'chat' | 'product'
|
||||||
}
|
}
|
||||||
|
|
||||||
export function RichTextMessageContent({ value, tone = 'default' }: RichTextMessageContentProps) {
|
export function RichTextMessageContent({ value, tone = 'default' }: RichTextMessageContentProps) {
|
||||||
@@ -32,7 +32,7 @@ export function RichTextMessageContent({ value, tone = 'default' }: RichTextMess
|
|||||||
return (
|
return (
|
||||||
<Box
|
<Box
|
||||||
sx={{
|
sx={{
|
||||||
...(tone === 'chat' ? { color: 'text.primary' } : {}),
|
...(tone === 'chat' || tone === 'product' ? { color: 'text.primary' } : {}),
|
||||||
'& .ProseMirror': {
|
'& .ProseMirror': {
|
||||||
outline: 'none',
|
outline: 'none',
|
||||||
whiteSpace: 'pre-wrap',
|
whiteSpace: 'pre-wrap',
|
||||||
@@ -48,20 +48,25 @@ export function RichTextMessageContent({ value, tone = 'default' }: RichTextMess
|
|||||||
fontSize: '0.95rem',
|
fontSize: '0.95rem',
|
||||||
lineHeight: 1.45,
|
lineHeight: 1.45,
|
||||||
}
|
}
|
||||||
|
: tone === 'product'
|
||||||
|
? {
|
||||||
|
fontSize: '1rem',
|
||||||
|
lineHeight: 1.6,
|
||||||
|
}
|
||||||
: {}),
|
: {}),
|
||||||
},
|
},
|
||||||
'& .ProseMirror p': {
|
'& .ProseMirror p': {
|
||||||
m: 0,
|
m: 0,
|
||||||
},
|
},
|
||||||
'& .ProseMirror p + p': {
|
'& .ProseMirror p + p': {
|
||||||
mt: tone === 'review' ? 0.75 : tone === 'chat' ? 0.5 : 0.5,
|
mt: tone === 'review' ? 0.75 : tone === 'chat' ? 0.5 : 0.6,
|
||||||
},
|
},
|
||||||
'& .ProseMirror ul, & .ProseMirror ol': {
|
'& .ProseMirror ul, & .ProseMirror ol': {
|
||||||
my: tone === 'review' ? 0.75 : tone === 'chat' ? 0.25 : 0,
|
my: tone === 'review' ? 0.75 : tone === 'chat' ? 0.25 : 0.5,
|
||||||
pl: 3,
|
pl: 3,
|
||||||
},
|
},
|
||||||
'& .ProseMirror li + li': {
|
'& .ProseMirror li + li': {
|
||||||
mt: tone === 'review' ? 0.25 : tone === 'chat' ? 0.15 : 0,
|
mt: tone === 'review' ? 0.25 : tone === 'chat' ? 0.15 : 0.15,
|
||||||
},
|
},
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -0,0 +1,44 @@
|
|||||||
|
import { render } from '@testing-library/react'
|
||||||
|
import { describe, expect, it, vi } from 'vitest'
|
||||||
|
|
||||||
|
let mockDemoMode = true
|
||||||
|
|
||||||
|
vi.mock('@/shared/config', async () => {
|
||||||
|
const actual = await vi.importActual<typeof import('@/shared/config')>('@/shared/config')
|
||||||
|
return {
|
||||||
|
...actual,
|
||||||
|
get IS_DEMO_MODE() {
|
||||||
|
return mockDemoMode
|
||||||
|
},
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
import { DemoOverlay } from '../DemoOverlay'
|
||||||
|
|
||||||
|
describe('DemoOverlay', () => {
|
||||||
|
it('рендерит водяной знак и плашку когда демо включён', () => {
|
||||||
|
mockDemoMode = true
|
||||||
|
const { container } = render(<DemoOverlay />)
|
||||||
|
|
||||||
|
const text = container.textContent
|
||||||
|
expect(text).toContain('ДЕМО')
|
||||||
|
expect(text).toContain('ДЕМО-РЕЖИМ')
|
||||||
|
|
||||||
|
const allBoxes = container.querySelectorAll('.MuiBox-root')
|
||||||
|
expect(allBoxes.length).toBeGreaterThanOrEqual(2)
|
||||||
|
|
||||||
|
const [watermark, badge] = allBoxes
|
||||||
|
|
||||||
|
expect(watermark.getAttribute('aria-hidden')).toBe('true')
|
||||||
|
expect(watermark.textContent).toBe('ДЕМО')
|
||||||
|
|
||||||
|
expect(badge.getAttribute('aria-hidden')).toBe('true')
|
||||||
|
expect(badge.textContent).toBe('ДЕМО-РЕЖИМ')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('не рендерит ничего когда демо выключен', () => {
|
||||||
|
mockDemoMode = false
|
||||||
|
const { container } = render(<DemoOverlay />)
|
||||||
|
expect(container.textContent).toBe('')
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,485 @@
|
|||||||
|
# Split Monorepo Implementation Plan
|
||||||
|
|
||||||
|
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||||
|
|
||||||
|
**Goal:** Split monorepo into shop-server and shop-client repos on Gitea (192.168.1.110:3000) with shared/ in server repo.
|
||||||
|
|
||||||
|
**Architecture:** Two independent repos. shared/ lives in shop-server. Client references it via relative alias `../shop-server/shared`. Clean init commits, no filter-branch.
|
||||||
|
|
||||||
|
**Tech Stack:** git, bash, Gitea at 192.168.1.110:3000
|
||||||
|
|
||||||
|
**Prerequisite:** Создать пустые репозитории в Gitea через веб-интерфейс:
|
||||||
|
- http://192.168.1.110:3000/admin/shop-server.git
|
||||||
|
- http://192.168.1.110:3000/admin/shop-client.git
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 1: Prepare workspace
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Create: `/tmp/shop-repos/shop-server/` (empty dir)
|
||||||
|
- Create: `/tmp/shop-repos/shop-client/` (empty dir)
|
||||||
|
|
||||||
|
- [ ] **Step 1: Create workspace directories**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
mkdir -p /tmp/shop-repos/shop-server /tmp/shop-repos/shop-client
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 2: shop-server — .gitignore
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Create: `/tmp/shop-repos/shop-server/.gitignore`
|
||||||
|
|
||||||
|
- [ ] **Step 1: Write .gitignore**
|
||||||
|
|
||||||
|
```
|
||||||
|
node_modules
|
||||||
|
dist
|
||||||
|
*.log
|
||||||
|
.env
|
||||||
|
scripts/deploy.env
|
||||||
|
server/prisma/dev.db
|
||||||
|
server/prisma/dev.db-journal
|
||||||
|
server/uploads/
|
||||||
|
uploads/.cache/
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Copy server/, shared/, scripts/**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
mkdir -p /tmp/shop-repos/shop-server/scripts
|
||||||
|
cp -r /mnt/d/my_projects/shop_deploy/server /tmp/shop-repos/shop-server/server
|
||||||
|
cp -r /mnt/d/my_projects/shop_deploy/shared /tmp/shop-repos/shop-server/shared
|
||||||
|
cp /mnt/d/my_projects/shop_deploy/scripts/backup-db.sh /tmp/shop-repos/shop-server/scripts/backup-db.sh
|
||||||
|
cp /mnt/d/my_projects/shop_deploy/scripts/craftshop-backup.service /tmp/shop-repos/shop-server/scripts/craftshop-backup.service
|
||||||
|
cp /mnt/d/my_projects/shop_deploy/scripts/craftshop-backup.timer /tmp/shop-repos/shop-server/scripts/craftshop-backup.timer
|
||||||
|
cp /mnt/d/my_projects/shop_deploy/scripts/SERVER_SETUP.md /tmp/shop-repos/shop-server/scripts/SERVER_SETUP.md
|
||||||
|
cp /mnt/d/my_projects/shop_deploy/scripts/craftshop-netbird.conf /tmp/shop-repos/shop-server/scripts/craftshop-netbird.conf
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 3: shop-server — README.md + AGENTS.md
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Create: `/tmp/shop-repos/shop-server/README.md`
|
||||||
|
- Create: `/tmp/shop-repos/shop-server/AGENTS.md`
|
||||||
|
|
||||||
|
- [ ] **Step 1: Write README.md**
|
||||||
|
|
||||||
|
```
|
||||||
|
# shop-server — бэкенд магазина ручной работы
|
||||||
|
|
||||||
|
Fastify + Prisma + SQLite. API, админка, загрузка изображений.
|
||||||
|
|
||||||
|
## Запуск
|
||||||
|
|
||||||
|
cd server
|
||||||
|
cp .env.example .env
|
||||||
|
npm ci
|
||||||
|
npx prisma migrate dev
|
||||||
|
npm run dev
|
||||||
|
|
||||||
|
Сервер: http://127.0.0.1:3333. Health: GET /health.
|
||||||
|
|
||||||
|
## Деплой
|
||||||
|
|
||||||
|
См. scripts/SERVER_SETUP.md.
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Write AGENTS.md**
|
||||||
|
|
||||||
|
```
|
||||||
|
# AGENTS.md — shop-server
|
||||||
|
|
||||||
|
## Project structure
|
||||||
|
|
||||||
|
- `server/` — Fastify + Prisma + SQLite backend
|
||||||
|
- `shared/constants/` — JS + .d.ts shared with client (order statuses, delivery carriers, payment methods, upload limits)
|
||||||
|
|
||||||
|
## Developer commands
|
||||||
|
|
||||||
|
| Command | What it does |
|
||||||
|
|---|---|
|
||||||
|
| `npm run dev` | node --env-file=.env --watch src/index.js (requires Node 20.6+) |
|
||||||
|
| `npm run dev:classic` | node --watch src/index.js (loads .env via dotenv) |
|
||||||
|
| `npm run lint` | ESLint (flat config) |
|
||||||
|
| `npm run lint:fix` | ESLint with --fix |
|
||||||
|
| `npm run format` | Prettier write all |
|
||||||
|
| `npm run format:check` | Prettier check only |
|
||||||
|
| `npm test` | vitest run |
|
||||||
|
| `npm run db:reset:test` | Reset SQLite DB + re-run migrations + seed (uses .env) |
|
||||||
|
|
||||||
|
## Conventions
|
||||||
|
|
||||||
|
- **Language**: Отвечай пользователю на русском.
|
||||||
|
- **Single quotes**, no semicolons, trailing commas, 120 print width (Prettier + ESLint enforce).
|
||||||
|
- **Alias**: @shared → shared/ (configured in vitest.config.js for tests).
|
||||||
|
- **Admin access**: Only users with email matching ADMIN_EMAIL env var can access admin routes. Server auto-creates the admin user on startup.
|
||||||
|
- **Server helpers**: slugify, parseMaterialsInput, mapProductForApi are decorated on fastify instance, accessed via request.server.*.
|
||||||
|
|
||||||
|
## Testing
|
||||||
|
|
||||||
|
- Vitest with globals enabled.
|
||||||
|
- Test files live in __tests__/ directories next to the code they test.
|
||||||
|
|
||||||
|
## OAuth
|
||||||
|
|
||||||
|
- VK callback: {SERVER_PUBLIC_URL}/api/auth/oauth/vk/callback
|
||||||
|
- Yandex callback: {SERVER_PUBLIC_URL}/api/auth/oauth/yandex/callback
|
||||||
|
|
||||||
|
## Infrastructure (deployment)
|
||||||
|
|
||||||
|
- VPS runs Nginx Proxy Manager (NPM), connected via Netbird VPN to the server machine
|
||||||
|
- Server machine runs the project
|
||||||
|
- Traffic flow: Browser → Domain → VPS (NPM) → Netbird → Server machine (3333)
|
||||||
|
- trustProxy: true on Fastify
|
||||||
|
|
||||||
|
## Notable quirks
|
||||||
|
|
||||||
|
- .env is gitignored. Copy .env.example to .env for local dev.
|
||||||
|
- db:reset:test runs prisma migrate reset --force, which destroys all data.
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 4: shop-server — git init, commit, push
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- None new
|
||||||
|
|
||||||
|
- [ ] **Step 1: Init git repo and commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd /tmp/shop-repos/shop-server
|
||||||
|
git init
|
||||||
|
git add -A
|
||||||
|
git commit -m "initial: server + shared"
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Add remote and push**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd /tmp/shop-repos/shop-server
|
||||||
|
git remote add origin http://192.168.1.110:3000/admin/shop-server.git
|
||||||
|
git push -u origin main
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected: push successful, repo populated on Gitea.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 5: shop-client — .gitignore
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Create: `/tmp/shop-repos/shop-client/.gitignore`
|
||||||
|
|
||||||
|
- [ ] **Step 1: Write .gitignore**
|
||||||
|
|
||||||
|
```
|
||||||
|
node_modules
|
||||||
|
dist
|
||||||
|
dist-ssr
|
||||||
|
*.log
|
||||||
|
*.local
|
||||||
|
.env
|
||||||
|
.vscode/*
|
||||||
|
!.vscode/extensions.json
|
||||||
|
.idea
|
||||||
|
.DS_Store
|
||||||
|
*.suo
|
||||||
|
*.ntvs*
|
||||||
|
*.njsproj
|
||||||
|
*.sln
|
||||||
|
*.sw?
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Copy client/ directory**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cp -r /mnt/d/my_projects/shop_deploy/client /tmp/shop-repos/shop-client/client
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 6: shop-client — fix shared/ alias
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `/tmp/shop-repos/shop-client/client/vite.config.ts:6-15`
|
||||||
|
- Modify: `/tmp/shop-repos/shop-client/client/tsconfig.app.json:3-7`
|
||||||
|
|
||||||
|
- [ ] **Step 1: Fix vite.config.ts**
|
||||||
|
|
||||||
|
Change lines 6-7, 15:
|
||||||
|
|
||||||
|
Old:
|
||||||
|
```ts
|
||||||
|
const rootDir = fileURLToPath(new URL('.', import.meta.url))
|
||||||
|
const projectRoot = path.resolve(rootDir, '..')
|
||||||
|
// ...
|
||||||
|
'@shared': path.resolve(projectRoot, 'shared'),
|
||||||
|
```
|
||||||
|
|
||||||
|
New:
|
||||||
|
```ts
|
||||||
|
const rootDir = fileURLToPath(new URL('.', import.meta.url))
|
||||||
|
const projectRoot = path.resolve(rootDir, '..', '..', 'shop-server')
|
||||||
|
// ...
|
||||||
|
'@shared': path.resolve(projectRoot, 'shared'),
|
||||||
|
```
|
||||||
|
|
||||||
|
Full file after:
|
||||||
|
```ts
|
||||||
|
import path from 'node:path'
|
||||||
|
import { fileURLToPath } from 'node:url'
|
||||||
|
import react from '@vitejs/plugin-react'
|
||||||
|
import { defineConfig } from 'vite'
|
||||||
|
|
||||||
|
const rootDir = fileURLToPath(new URL('.', import.meta.url))
|
||||||
|
const projectRoot = path.resolve(rootDir, '..', '..', 'shop-server')
|
||||||
|
|
||||||
|
// https://vite.dev/config/
|
||||||
|
export default defineConfig({
|
||||||
|
plugins: [react()],
|
||||||
|
resolve: {
|
||||||
|
alias: {
|
||||||
|
'@': path.resolve(rootDir, 'src'),
|
||||||
|
'@shared': path.resolve(projectRoot, 'shared'),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
server: {
|
||||||
|
fs: {
|
||||||
|
allow: [path.resolve(rootDir, '..'), projectRoot],
|
||||||
|
},
|
||||||
|
port: 5173,
|
||||||
|
proxy: {
|
||||||
|
'/api': {
|
||||||
|
target: 'http://127.0.0.1:3333',
|
||||||
|
changeOrigin: true,
|
||||||
|
},
|
||||||
|
'/uploads': {
|
||||||
|
target: 'http://127.0.0.1:3333',
|
||||||
|
changeOrigin: true,
|
||||||
|
},
|
||||||
|
'/uploads-resized': {
|
||||||
|
target: 'http://127.0.0.1:3333',
|
||||||
|
changeOrigin: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
build: {
|
||||||
|
rollupOptions: {
|
||||||
|
output: {
|
||||||
|
manualChunks(id) {
|
||||||
|
if (id.includes('node_modules')) {
|
||||||
|
if (id.includes('react-router')) return 'vendor-react'
|
||||||
|
if (id.includes('react-dom')) return 'vendor-react'
|
||||||
|
if (id.includes('node_modules/react/')) return 'vendor-react'
|
||||||
|
if (id.includes('@mui')) return 'vendor-mui'
|
||||||
|
if (id.includes('@emotion')) return 'vendor-mui'
|
||||||
|
if (id.includes('swiper')) return 'vendor-swiper'
|
||||||
|
if (id.includes('@tanstack/react-query')) return 'vendor-query'
|
||||||
|
if (id.includes('effector')) return 'vendor-effector'
|
||||||
|
}
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Fix tsconfig.app.json**
|
||||||
|
|
||||||
|
Change line 6:
|
||||||
|
|
||||||
|
Old:
|
||||||
|
```json
|
||||||
|
"@shared/*": ["../shared/*"]
|
||||||
|
```
|
||||||
|
|
||||||
|
New:
|
||||||
|
```json
|
||||||
|
"@shared/*": ["../../shop-server/shared/*"]
|
||||||
|
```
|
||||||
|
|
||||||
|
Full file after:
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"baseUrl": ".",
|
||||||
|
"paths": {
|
||||||
|
"@/*": ["src/*"],
|
||||||
|
"@shared/*": ["../../shop-server/shared/*"]
|
||||||
|
},
|
||||||
|
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
|
||||||
|
"target": "es2023",
|
||||||
|
"lib": ["ES2023", "DOM"],
|
||||||
|
"module": "esnext",
|
||||||
|
"types": ["vite/client"],
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"strict": true,
|
||||||
|
|
||||||
|
/* Bundler mode */
|
||||||
|
"moduleResolution": "bundler",
|
||||||
|
"allowImportingTsExtensions": true,
|
||||||
|
"verbatimModuleSyntax": true,
|
||||||
|
"moduleDetection": "force",
|
||||||
|
"noEmit": true,
|
||||||
|
"jsx": "react-jsx",
|
||||||
|
|
||||||
|
"ignoreDeprecations": "6.0",
|
||||||
|
|
||||||
|
/* Linting */
|
||||||
|
"noUnusedLocals": true,
|
||||||
|
"noUnusedParameters": true,
|
||||||
|
"erasableSyntaxOnly": true,
|
||||||
|
"noFallthroughCasesInSwitch": true
|
||||||
|
},
|
||||||
|
"include": ["src"]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 7: shop-client — README.md + AGENTS.md
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Create: `/tmp/shop-repos/shop-client/README.md`
|
||||||
|
- Create: `/tmp/shop-repos/shop-client/AGENTS.md`
|
||||||
|
|
||||||
|
- [ ] **Step 1: Write README.md**
|
||||||
|
|
||||||
|
```
|
||||||
|
# shop-client — витрина и админка магазина ручной работы
|
||||||
|
|
||||||
|
React + Vite + TypeScript + MUI. FSD-архитектура, @tanstack/react-query.
|
||||||
|
|
||||||
|
## Запуск
|
||||||
|
|
||||||
|
npm ci
|
||||||
|
npm run dev
|
||||||
|
|
||||||
|
Откройте http://localhost:5173. API проксируется на http://127.0.0.1:3333.
|
||||||
|
|
||||||
|
Требуется shop-server рядом (../../shop-server/shared/ для @shared/ alias).
|
||||||
|
|
||||||
|
## Команды
|
||||||
|
|
||||||
|
| Command | Description |
|
||||||
|
|---|---|
|
||||||
|
| npm run dev | Vite dev server |
|
||||||
|
| npm run build | tsc + vite build |
|
||||||
|
| npm run lint | ESLint |
|
||||||
|
| npm run lint:fix | ESLint --fix |
|
||||||
|
| npm run format | Prettier |
|
||||||
|
| npm test | vitest run |
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Write AGENTS.md**
|
||||||
|
|
||||||
|
```
|
||||||
|
# AGENTS.md — shop-client
|
||||||
|
|
||||||
|
## Project structure
|
||||||
|
|
||||||
|
FSD architecture: app/pages/widgets/features/entities/shared
|
||||||
|
|
||||||
|
## Developer commands
|
||||||
|
|
||||||
|
| Command | What it does |
|
||||||
|
|---|---|
|
||||||
|
| `npm run dev` | Vite dev server on :5173, proxies /api and /uploads to 127.0.0.1:3333 |
|
||||||
|
| `npm run build` | Runs tsc -b first, then vite build |
|
||||||
|
| `npm run lint` | ESLint (flat config) |
|
||||||
|
| `npm run lint:fix` | ESLint with --fix |
|
||||||
|
| `npm run format` | Prettier write all |
|
||||||
|
| `npm run format:check` | Prettier check only |
|
||||||
|
| `npm test` | vitest run |
|
||||||
|
| `npm run test:watch` | vitest watch mode |
|
||||||
|
|
||||||
|
## Conventions
|
||||||
|
|
||||||
|
- **Language**: Отвечай пользователю на русском.
|
||||||
|
- **Single quotes**, no semicolons, trailing commas, 120 print width (Prettier + ESLint enforce).
|
||||||
|
- **FSD import boundaries** enforced by eslint-plugin-boundaries. Lower layers cannot import upper layers.
|
||||||
|
- **Aliases**: @/ → client/src/, @shared/ → ../../shop-server/shared/
|
||||||
|
- **API requests**: Use apiClient (axios wrapper from shared/api/) with @tanstack/react-query. Invalidate queries after mutations.
|
||||||
|
- **UI**: Prefer MUI components over custom HTML/CSS.
|
||||||
|
- **no-console**: ESLint error; use console.warn/error/info only.
|
||||||
|
|
||||||
|
## Testing
|
||||||
|
|
||||||
|
- vitest + jsdom + @testing-library/react
|
||||||
|
- Setup file: src/testing/setup.ts
|
||||||
|
- Test files live in __tests__/ directories next to the code they test.
|
||||||
|
|
||||||
|
## Important
|
||||||
|
|
||||||
|
- shop-server must be cloned alongside shop-client for @shared/ alias to resolve.
|
||||||
|
- Vite dev server relies on backend running at 127.0.0.1:3333. Start server first.
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 8: shop-client — git init, commit, push
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- None new
|
||||||
|
|
||||||
|
- [ ] **Step 1: Init git repo and commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd /tmp/shop-repos/shop-client
|
||||||
|
git init
|
||||||
|
git add -A
|
||||||
|
git commit -m "initial: client"
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Add remote and push**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd /tmp/shop-repos/shop-client
|
||||||
|
git remote add origin http://192.168.1.110:3000/admin/shop-client.git
|
||||||
|
git push -u origin main
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected: push successful, repo populated on Gitea.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 9: Verify
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- None
|
||||||
|
|
||||||
|
- [ ] **Step 1: Clone fresh and verify structure**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd /tmp
|
||||||
|
git clone http://192.168.1.110:3000/admin/shop-server.git shop-server-test
|
||||||
|
git clone http://192.168.1.110:3000/admin/shop-client.git shop-client-test
|
||||||
|
ls shop-server-test/server/
|
||||||
|
ls shop-server-test/shared/constants/
|
||||||
|
ls shop-client-test/client/src/
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected: all directories present.
|
||||||
|
|
||||||
|
- [ ] **Step 2: Verify client can resolve @shared alias (TypeScript)**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd /tmp/shop-client-test/client
|
||||||
|
npm ci
|
||||||
|
npx tsc --noEmit -p tsconfig.app.json 2>&1 | head -20
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected: no errors related to @shared/ imports. May have unrelated warnings — ignore those.
|
||||||
|
|
||||||
|
- [ ] **Step 3: Cleanup test dirs**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
rm -rf /tmp/shop-repos /tmp/shop-server-test /tmp/shop-client-test
|
||||||
|
```
|
||||||
@@ -0,0 +1,67 @@
|
|||||||
|
# DemoOverlay — индикация демо-режима
|
||||||
|
|
||||||
|
## Контекст
|
||||||
|
|
||||||
|
Демо-режим активируется через `VITE_DEMO_MODE=true` (`client/.env.local`). Сейчас есть только `DemoBanner` (Alert в потоке страницы, не фиксированный). Нужно добавить постоянную визуальную индикацию — оверлей, который не мешает взаимодействию с сайтом.
|
||||||
|
|
||||||
|
## Что делаем
|
||||||
|
|
||||||
|
Новый компонент `DemoOverlay` в `client/src/shared/ui/DemoOverlay.tsx`.
|
||||||
|
|
||||||
|
Два фиксированных слоя, оба `pointer-events: none`:
|
||||||
|
|
||||||
|
1. **Водяной знак** — крупная надпись «ДЕМО», полупрозрачная, повёрнута на ~-30°, по центру экрана.
|
||||||
|
2. **Плашка** — правый нижний угол, скруглённая полупрозрачная тёмная плашка с текстом «ДЕМО-РЕЖИМ».
|
||||||
|
|
||||||
|
Оба рендерятся только при `IS_DEMO_MODE === true`.
|
||||||
|
|
||||||
|
## Размещение
|
||||||
|
|
||||||
|
В `App.tsx` на одном уровне с `<NoiseOverlay />`, вне роутов:
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
<NoiseOverlay />
|
||||||
|
<DemoOverlay />
|
||||||
|
```
|
||||||
|
|
||||||
|
`DemoBanner` (существующий Alert в MainLayout) — не трогаем, остаётся как есть.
|
||||||
|
|
||||||
|
## Водяной знак
|
||||||
|
|
||||||
|
- Текст: `ДЕМО`
|
||||||
|
- Размер шрифта: `10vw` (адаптивный)
|
||||||
|
- Поворот: `rotate(-30deg)`
|
||||||
|
- Цвет: `rgba(0,0,0,0.04)` (тёмная тема: `rgba(255,255,255,0.04)`)
|
||||||
|
- Позиция: `position: fixed`, `inset: 0`, центрирование через flex
|
||||||
|
- z-index: `9990`
|
||||||
|
|
||||||
|
## Плашка
|
||||||
|
|
||||||
|
- Текст: `ДЕМО-РЕЖИМ`
|
||||||
|
- Позиция: `position: fixed`, `bottom: 16px`, `right: 16px`
|
||||||
|
- Фон: `rgba(0,0,0,0.6)` (тёмная тема: `rgba(255,255,255,0.08)`)
|
||||||
|
- Цвет текста: `#fff` (тёмная тема: `rgba(255,255,255,0.6)`)
|
||||||
|
- Паддинги: `6px 16px`
|
||||||
|
- Скругление: `8px`
|
||||||
|
- Размер шрифта: `12px`, `font-weight: 600`
|
||||||
|
- z-index: `9991`
|
||||||
|
|
||||||
|
## Тёмная тема
|
||||||
|
|
||||||
|
Компонент читает тему через `useTheme()` из MUI и применяет соответствующие цвета для watermark и плашки.
|
||||||
|
|
||||||
|
## Тесты
|
||||||
|
|
||||||
|
Проверяем:
|
||||||
|
- Компонент рендерится когда `IS_DEMO_MODE === true` (водяной знак + плашка видны)
|
||||||
|
- Компонент не рендерится когда `IS_DEMO_MODE === false`
|
||||||
|
- Плашка в правом нижнем углу (проверяем CSS-свойства)
|
||||||
|
- `pointer-events: none` на обоих элементах
|
||||||
|
|
||||||
|
## Файлы
|
||||||
|
|
||||||
|
| Действие | Файл |
|
||||||
|
|----------|------|
|
||||||
|
| Создать | `client/src/shared/ui/DemoOverlay.tsx` |
|
||||||
|
| Изменить | `client/src/app/App.tsx` |
|
||||||
|
| Создать | `client/src/shared/ui/__tests__/DemoOverlay.test.tsx` |
|
||||||
@@ -0,0 +1,111 @@
|
|||||||
|
# Split monorepo into shop-server & shop-client
|
||||||
|
|
||||||
|
> Дата: 2026-06-11
|
||||||
|
|
||||||
|
## Цель
|
||||||
|
|
||||||
|
Разделить монорепу `shop_deploy` на два независимых git-репозитория, размещённых на Gitea (192.168.1.110:3000), с автоматическим деплоем через Gitea CI/CD.
|
||||||
|
|
||||||
|
## Репозитории
|
||||||
|
|
||||||
|
### shop-server
|
||||||
|
|
||||||
|
**URL:** `http://192.168.1.110:3000/admin/shop-server.git`
|
||||||
|
|
||||||
|
**Содержимое:**
|
||||||
|
```
|
||||||
|
.gitignore
|
||||||
|
README.md
|
||||||
|
AGENTS.md
|
||||||
|
.gitea/workflows/deploy.yml
|
||||||
|
server/ # Fastify + Prisma backend
|
||||||
|
shared/ # Каноничное место shared/constants/
|
||||||
|
scripts/
|
||||||
|
backup-db.sh
|
||||||
|
craftshop-backup.service
|
||||||
|
craftshop-backup.timer
|
||||||
|
SERVER_SETUP.md
|
||||||
|
```
|
||||||
|
|
||||||
|
### shop-client
|
||||||
|
|
||||||
|
**URL:** `http://192.168.1.110:3000/admin/shop-client.git`
|
||||||
|
|
||||||
|
**Содержимое:**
|
||||||
|
```
|
||||||
|
.gitignore
|
||||||
|
README.md
|
||||||
|
AGENTS.md
|
||||||
|
.gitea/workflows/deploy.yml
|
||||||
|
client/ # React + Vite frontend
|
||||||
|
```
|
||||||
|
|
||||||
|
**Не переносятся** в новые репо: `.cursor/`, `.opencode/`, `.agents/`, `REFACTORING_PLAN.md`, `skills-lock.json`, `node_modules/`, `docs/`, `.superpowers/` — остаются в исторической монорепе.
|
||||||
|
|
||||||
|
## Доступ к shared/
|
||||||
|
|
||||||
|
`shared/` живёт в **shop-server** как каноничный источник.
|
||||||
|
|
||||||
|
### shop-client: alias на shared/
|
||||||
|
|
||||||
|
`client/vite.config.ts`:
|
||||||
|
```ts
|
||||||
|
'@shared': path.resolve(projectRoot, '..', 'shop-server', 'shared'),
|
||||||
|
```
|
||||||
|
|
||||||
|
`client/tsconfig.app.json`:
|
||||||
|
```json
|
||||||
|
"@shared/*": ["../../shop-server/shared/*"]
|
||||||
|
```
|
||||||
|
|
||||||
|
Это работает при условии, что оба репо клонированы рядом:
|
||||||
|
```
|
||||||
|
~/projects/
|
||||||
|
shop-server/
|
||||||
|
server/ shared/
|
||||||
|
shop-client/
|
||||||
|
client/
|
||||||
|
```
|
||||||
|
|
||||||
|
В CI Gitea делает checkout обоих репо в одной workspace.
|
||||||
|
|
||||||
|
### shop-server
|
||||||
|
|
||||||
|
Изменений не требуется — `shared/` уже внутри репо. Сервер импортирует `shared/` через относительные пути (`../../shared/...`).
|
||||||
|
|
||||||
|
## CI/CD (Gitea Actions)
|
||||||
|
|
||||||
|
Runner: self-hosted на машине, где работает приложение (192.168.1.88 или аналог). Триггер: push в `main`.
|
||||||
|
|
||||||
|
### shop-server CI (.gitea/workflows/deploy.yml)
|
||||||
|
|
||||||
|
1. `actions/checkout` shop-server
|
||||||
|
2. `npm ci` → `npx prisma generate` → `npm test`
|
||||||
|
3. Rsync `server/` → `/opt/craftshop/server/` (исключая node_modules, uploads, .env, *.db)
|
||||||
|
4. Rsync `shared/` → `/opt/craftshop/shared/`
|
||||||
|
5. `npx prisma migrate deploy` на сервере
|
||||||
|
6. `systemctl restart craftshop-api`
|
||||||
|
|
||||||
|
### shop-client CI (.gitea/workflows/deploy.yml)
|
||||||
|
|
||||||
|
1. `actions/checkout` shop-client
|
||||||
|
2. `actions/checkout` shop-server в `../shop-server/`
|
||||||
|
3. `npm ci` → `npm test` → `npm run build`
|
||||||
|
4. Rsync `client/dist/` → `/opt/craftshop/www/`
|
||||||
|
|
||||||
|
## Что удаляется
|
||||||
|
|
||||||
|
- `deploy-auto.sh` — заменён Gitea CI
|
||||||
|
- `.deployed-commit` — отслеживание заменено на push-триггер
|
||||||
|
- `scripts/deploy.env` — не нужен (CI конфигурируется в Gitea)
|
||||||
|
- `scripts/craftshop-netbird.conf` — nginx настраивается один раз по `SERVER_SETUP.md`
|
||||||
|
|
||||||
|
## Этапы реализации
|
||||||
|
|
||||||
|
1. Создать пустые репо в Gitea
|
||||||
|
2. Инициализировать shop-server: скопировать server/, shared/, scripts/ в чистый git-репо, сделать init-коммит, запушить
|
||||||
|
3. Инициализировать shop-client: скопировать client/ в чистый git-репо, поправить alias на shared/, сделать init-коммит, запушить
|
||||||
|
4. Настроить self-hosted runner на целевой машине
|
||||||
|
5. Создать `.gitea/workflows/deploy.yml` в каждом репо
|
||||||
|
6. Проверить деплой через push
|
||||||
|
7. Удалить deploy-скрипты из исторической монорепы (опционально)
|
||||||
@@ -2,6 +2,8 @@
|
|||||||
|
|
||||||
Выполнять от **root** на свежем Debian/Ubuntu LXC.
|
Выполнять от **root** на свежем Debian/Ubuntu LXC.
|
||||||
|
|
||||||
|
для теста
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 1. Базовые пакеты и Node.js
|
## 1. Базовые пакеты и Node.js
|
||||||
|
|||||||
@@ -0,0 +1,33 @@
|
|||||||
|
# Nginx для доступа к админке через Netbird
|
||||||
|
# Размещается на сервере в /etc/nginx/sites-available/craftshop-netbird
|
||||||
|
# с симлинком в /etc/nginx/sites-enabled/
|
||||||
|
|
||||||
|
server {
|
||||||
|
listen 100.109.3.6:80;
|
||||||
|
server_name 100.109.3.6;
|
||||||
|
|
||||||
|
root /opt/craftshop/www;
|
||||||
|
index index.html;
|
||||||
|
|
||||||
|
client_max_body_size 100M;
|
||||||
|
|
||||||
|
location /api/ {
|
||||||
|
proxy_pass http://127.0.0.1:3333;
|
||||||
|
proxy_http_version 1.1;
|
||||||
|
proxy_set_header Host $host;
|
||||||
|
proxy_set_header X-Real-IP $remote_addr;
|
||||||
|
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||||
|
proxy_set_header X-Forwarded-Proto $scheme;
|
||||||
|
}
|
||||||
|
|
||||||
|
location /uploads/ {
|
||||||
|
proxy_pass http://127.0.0.1:3333;
|
||||||
|
proxy_set_header Host $host;
|
||||||
|
proxy_set_header X-Real-IP $remote_addr;
|
||||||
|
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||||
|
}
|
||||||
|
|
||||||
|
location / {
|
||||||
|
try_files $uri $uri/ /index.html;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -202,6 +202,24 @@ if [[ "$changed_server" == true ]]; then
|
|||||||
remote_exec bash -lc "$DEPLOY_RESTART_CMD"
|
remote_exec bash -lc "$DEPLOY_RESTART_CMD"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
echo ">>> Настройка Nginx для Netbird-доступа к админке"
|
||||||
|
remote_exec bash -lc "set -e
|
||||||
|
if ! command -v nginx &>/dev/null; then
|
||||||
|
echo 'Установка nginx...'
|
||||||
|
apt-get update -qq && apt-get install -y -qq nginx
|
||||||
|
fi
|
||||||
|
mkdir -p /etc/nginx/sites-available /etc/nginx/sites-enabled
|
||||||
|
rm -f /etc/nginx/sites-enabled/default
|
||||||
|
"
|
||||||
|
|
||||||
|
cat "$ROOT/scripts/craftshop-netbird.conf" | remote_exec tee /etc/nginx/sites-available/craftshop-netbird > /dev/null
|
||||||
|
|
||||||
|
remote_exec bash -lc "set -e
|
||||||
|
ln -sf /etc/nginx/sites-available/craftshop-netbird /etc/nginx/sites-enabled/
|
||||||
|
nginx -t && systemctl reload nginx || systemctl restart nginx
|
||||||
|
systemctl enable nginx
|
||||||
|
"
|
||||||
|
|
||||||
echo ">>> Сервер задеплоен"
|
echo ">>> Сервер задеплоен"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
|||||||
+5
-2
@@ -17,8 +17,11 @@ JWT_SECRET=замените-на-секрет-jwt
|
|||||||
# Разрешённый Origin фронта (через запятую при нескольких)
|
# Разрешённый Origin фронта (через запятую при нескольких)
|
||||||
# CORS_ORIGIN=http://127.0.0.1:5173
|
# CORS_ORIGIN=http://127.0.0.1:5173
|
||||||
|
|
||||||
# Ограничение доступа по IP на время разработки (через запятую). Не задано — защита отключена.
|
# Ограничение доступа по IP на время разработки (через запятую). Поддерживает точные IP и CIDR-диапазоны. Не задано — защита отключена.
|
||||||
# SITE_ACCESS_IPS=1.2.3.4,5.6.7.8
|
# SITE_ACCESS_IPS=1.2.3.4,5.6.7.8,192.168.1.0/24
|
||||||
|
|
||||||
|
# Ограничение доступа к админ-роутам по IP (через запятую). Поддерживает точные IP и CIDR-диапазоны. Не задано — защита отключена.
|
||||||
|
# ADMIN_ACCESS_IPS=1.2.3.4,10.0.0.0/24
|
||||||
|
|
||||||
# Публичные URL для OAuth redirect (локально обычно так):
|
# Публичные URL для OAuth redirect (локально обычно так):
|
||||||
SERVER_PUBLIC_URL=http://127.0.0.1:3333
|
SERVER_PUBLIC_URL=http://127.0.0.1:3333
|
||||||
|
|||||||
Generated
+643
-646
File diff suppressed because it is too large
Load Diff
+2
-1
@@ -25,11 +25,12 @@
|
|||||||
"@fastify/multipart": "^10.0.0",
|
"@fastify/multipart": "^10.0.0",
|
||||||
"@fastify/static": "^9.1.3",
|
"@fastify/static": "^9.1.3",
|
||||||
"@prisma/client": "5.22.0",
|
"@prisma/client": "5.22.0",
|
||||||
|
"@rollup/rollup-linux-x64-gnu": "^4.61.0",
|
||||||
"bcrypt": "^6.0.0",
|
"bcrypt": "^6.0.0",
|
||||||
"dotenv": "^17.4.2",
|
"dotenv": "^17.4.2",
|
||||||
"fastify": "^5.8.5",
|
"fastify": "^5.8.5",
|
||||||
"nodemailer": "^8.0.7",
|
"nodemailer": "^8.0.7",
|
||||||
"sharp": "0.32.6"
|
"sharp": "^0.35.0"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@eslint/js": "^10.0.1",
|
"@eslint/js": "^10.0.1",
|
||||||
|
|||||||
Binary file not shown.
@@ -0,0 +1,296 @@
|
|||||||
|
import jwt from '@fastify/jwt'
|
||||||
|
import Fastify from 'fastify'
|
||||||
|
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
|
||||||
|
import { registerAuth } from '../auth.js'
|
||||||
|
|
||||||
|
const JWT_SECRET = 'test-secret'
|
||||||
|
const ADMIN_EMAIL = 'admin@test.com'
|
||||||
|
|
||||||
|
async function buildApp() {
|
||||||
|
const app = Fastify({ logger: false, trustProxy: true })
|
||||||
|
await app.register(jwt, { secret: JWT_SECRET })
|
||||||
|
registerAuth(app)
|
||||||
|
app.get('/admin/test', { preHandler: [app.verifyAdmin] }, async () => ({ ok: true }))
|
||||||
|
await app.ready()
|
||||||
|
return app
|
||||||
|
}
|
||||||
|
|
||||||
|
async function signToken(app, email) {
|
||||||
|
return app.jwt.sign({ sub: 'test-user-id', email })
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('verifyAdmin — ADMIN_ACCESS_IPS', () => {
|
||||||
|
const originalIps = process.env.ADMIN_ACCESS_IPS
|
||||||
|
const originalEmail = process.env.ADMIN_EMAIL
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
process.env.ADMIN_EMAIL = ADMIN_EMAIL
|
||||||
|
delete process.env.ADMIN_ACCESS_IPS
|
||||||
|
})
|
||||||
|
|
||||||
|
afterEach(async () => {
|
||||||
|
if (originalIps === undefined) {
|
||||||
|
delete process.env.ADMIN_ACCESS_IPS
|
||||||
|
} else {
|
||||||
|
process.env.ADMIN_ACCESS_IPS = originalIps
|
||||||
|
}
|
||||||
|
if (originalEmail === undefined) {
|
||||||
|
delete process.env.ADMIN_EMAIL
|
||||||
|
} else {
|
||||||
|
process.env.ADMIN_EMAIL = originalEmail
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
it('пропускает если ADMIN_ACCESS_IPS не задан (IP не проверяется)', async () => {
|
||||||
|
delete process.env.ADMIN_ACCESS_IPS
|
||||||
|
const app = await buildApp()
|
||||||
|
const token = await signToken(app, ADMIN_EMAIL)
|
||||||
|
try {
|
||||||
|
const res = await app.inject({
|
||||||
|
method: 'GET',
|
||||||
|
url: '/admin/test',
|
||||||
|
headers: { authorization: `Bearer ${token}` },
|
||||||
|
remoteAddress: '9.9.9.9',
|
||||||
|
})
|
||||||
|
expect(res.statusCode).toBe(200)
|
||||||
|
expect(res.json()).toEqual({ ok: true })
|
||||||
|
} finally {
|
||||||
|
await app.close()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
it('пропускает если ADMIN_ACCESS_IPS пустой после трима', async () => {
|
||||||
|
process.env.ADMIN_ACCESS_IPS = ' , , '
|
||||||
|
const app = await buildApp()
|
||||||
|
const token = await signToken(app, ADMIN_EMAIL)
|
||||||
|
try {
|
||||||
|
const res = await app.inject({
|
||||||
|
method: 'GET',
|
||||||
|
url: '/admin/test',
|
||||||
|
headers: { authorization: `Bearer ${token}` },
|
||||||
|
remoteAddress: '9.9.9.9',
|
||||||
|
})
|
||||||
|
expect(res.statusCode).toBe(200)
|
||||||
|
} finally {
|
||||||
|
await app.close()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
it('пропускает с разрешённого IP', async () => {
|
||||||
|
process.env.ADMIN_ACCESS_IPS = '1.2.3.4,5.6.7.8'
|
||||||
|
const app = await buildApp()
|
||||||
|
const token = await signToken(app, ADMIN_EMAIL)
|
||||||
|
try {
|
||||||
|
const res = await app.inject({
|
||||||
|
method: 'GET',
|
||||||
|
url: '/admin/test',
|
||||||
|
headers: { authorization: `Bearer ${token}` },
|
||||||
|
remoteAddress: '1.2.3.4',
|
||||||
|
})
|
||||||
|
// IP passes, JWT and email match → 200
|
||||||
|
expect(res.statusCode).toBe(200)
|
||||||
|
} finally {
|
||||||
|
await app.close()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
it('пропускает с IPv6-mapped разрешённого IP', async () => {
|
||||||
|
process.env.ADMIN_ACCESS_IPS = '1.2.3.4'
|
||||||
|
const app = await buildApp()
|
||||||
|
const token = await signToken(app, ADMIN_EMAIL)
|
||||||
|
try {
|
||||||
|
const res = await app.inject({
|
||||||
|
method: 'GET',
|
||||||
|
url: '/admin/test',
|
||||||
|
headers: { authorization: `Bearer ${token}` },
|
||||||
|
remoteAddress: '::ffff:1.2.3.4',
|
||||||
|
})
|
||||||
|
expect(res.statusCode).toBe(200)
|
||||||
|
} finally {
|
||||||
|
await app.close()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
it('блокирует с неразрешённого IP (403 JSON)', async () => {
|
||||||
|
process.env.ADMIN_ACCESS_IPS = '1.2.3.4'
|
||||||
|
const app = await buildApp()
|
||||||
|
try {
|
||||||
|
const res = await app.inject({
|
||||||
|
method: 'GET',
|
||||||
|
url: '/admin/test',
|
||||||
|
remoteAddress: '9.9.9.9',
|
||||||
|
})
|
||||||
|
// IP not allowed — 403 even before JWT check
|
||||||
|
expect(res.statusCode).toBe(403)
|
||||||
|
const body = res.json()
|
||||||
|
expect(body.error).toBe('Доступ с данного IP запрещён')
|
||||||
|
} finally {
|
||||||
|
await app.close()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
it('тримит пробелы в списке IP', async () => {
|
||||||
|
process.env.ADMIN_ACCESS_IPS = ' 1.2.3.4 , 5.6.7.8 '
|
||||||
|
const app = await buildApp()
|
||||||
|
const token = await signToken(app, ADMIN_EMAIL)
|
||||||
|
try {
|
||||||
|
const res = await app.inject({
|
||||||
|
method: 'GET',
|
||||||
|
url: '/admin/test',
|
||||||
|
headers: { authorization: `Bearer ${token}` },
|
||||||
|
remoteAddress: '5.6.7.8',
|
||||||
|
})
|
||||||
|
expect(res.statusCode).toBe(200)
|
||||||
|
} finally {
|
||||||
|
await app.close()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
it('нормализует IPv6-mapped адреса в whitelist', async () => {
|
||||||
|
process.env.ADMIN_ACCESS_IPS = '::ffff:1.2.3.4'
|
||||||
|
const app = await buildApp()
|
||||||
|
const token = await signToken(app, ADMIN_EMAIL)
|
||||||
|
try {
|
||||||
|
const res = await app.inject({
|
||||||
|
method: 'GET',
|
||||||
|
url: '/admin/test',
|
||||||
|
headers: { authorization: `Bearer ${token}` },
|
||||||
|
remoteAddress: '1.2.3.4',
|
||||||
|
})
|
||||||
|
expect(res.statusCode).toBe(200)
|
||||||
|
} finally {
|
||||||
|
await app.close()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
it('пропускает запрос с IP в CIDR-диапазоне /24', async () => {
|
||||||
|
process.env.ADMIN_ACCESS_IPS = '192.168.1.0/24'
|
||||||
|
const app = await buildApp()
|
||||||
|
const token = await signToken(app, ADMIN_EMAIL)
|
||||||
|
try {
|
||||||
|
const res = await app.inject({
|
||||||
|
method: 'GET',
|
||||||
|
url: '/admin/test',
|
||||||
|
headers: { authorization: `Bearer ${token}` },
|
||||||
|
remoteAddress: '192.168.1.100',
|
||||||
|
})
|
||||||
|
expect(res.statusCode).toBe(200)
|
||||||
|
} finally {
|
||||||
|
await app.close()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
it('блокирует запрос с IP вне CIDR-диапазона', async () => {
|
||||||
|
process.env.ADMIN_ACCESS_IPS = '192.168.1.0/24'
|
||||||
|
const app = await buildApp()
|
||||||
|
try {
|
||||||
|
const res = await app.inject({
|
||||||
|
method: 'GET',
|
||||||
|
url: '/admin/test',
|
||||||
|
remoteAddress: '10.0.0.1',
|
||||||
|
})
|
||||||
|
expect(res.statusCode).toBe(403)
|
||||||
|
} finally {
|
||||||
|
await app.close()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
it('поддерживает микс точных IP и CIDR-диапазонов', async () => {
|
||||||
|
process.env.ADMIN_ACCESS_IPS = '1.2.3.4,10.0.0.0/24'
|
||||||
|
const app = await buildApp()
|
||||||
|
const token = await signToken(app, ADMIN_EMAIL)
|
||||||
|
try {
|
||||||
|
const res1 = await app.inject({
|
||||||
|
method: 'GET',
|
||||||
|
url: '/admin/test',
|
||||||
|
headers: { authorization: `Bearer ${token}` },
|
||||||
|
remoteAddress: '1.2.3.4',
|
||||||
|
})
|
||||||
|
expect(res1.statusCode).toBe(200)
|
||||||
|
|
||||||
|
const res2 = await app.inject({
|
||||||
|
method: 'GET',
|
||||||
|
url: '/admin/test',
|
||||||
|
headers: { authorization: `Bearer ${token}` },
|
||||||
|
remoteAddress: '10.0.0.50',
|
||||||
|
})
|
||||||
|
expect(res2.statusCode).toBe(200)
|
||||||
|
|
||||||
|
const res3 = await app.inject({
|
||||||
|
method: 'GET',
|
||||||
|
url: '/admin/test',
|
||||||
|
remoteAddress: '9.9.9.9',
|
||||||
|
})
|
||||||
|
expect(res3.statusCode).toBe(403)
|
||||||
|
} finally {
|
||||||
|
await app.close()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
it('IPv6-mapped адрес в CIDR-диапазоне пропускается', async () => {
|
||||||
|
process.env.ADMIN_ACCESS_IPS = '192.168.1.0/24'
|
||||||
|
const app = await buildApp()
|
||||||
|
const token = await signToken(app, ADMIN_EMAIL)
|
||||||
|
try {
|
||||||
|
const res = await app.inject({
|
||||||
|
method: 'GET',
|
||||||
|
url: '/admin/test',
|
||||||
|
headers: { authorization: `Bearer ${token}` },
|
||||||
|
remoteAddress: '::ffff:192.168.1.50',
|
||||||
|
})
|
||||||
|
expect(res.statusCode).toBe(200)
|
||||||
|
} finally {
|
||||||
|
await app.close()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
it('IP-проверка происходит до JWT (неразрешённый IP → 403, а не 401)', async () => {
|
||||||
|
process.env.ADMIN_ACCESS_IPS = '1.2.3.4'
|
||||||
|
const app = await buildApp()
|
||||||
|
try {
|
||||||
|
const res = await app.inject({
|
||||||
|
method: 'GET',
|
||||||
|
url: '/admin/test',
|
||||||
|
remoteAddress: '9.9.9.9',
|
||||||
|
})
|
||||||
|
// Should be 403 from IP check, NOT 401 from missing JWT
|
||||||
|
expect(res.statusCode).toBe(403)
|
||||||
|
expect(res.json().error).toBe('Доступ с данного IP запрещён')
|
||||||
|
} finally {
|
||||||
|
await app.close()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
it('после прохождения IP-проверки всё ещё нужен JWT (разрешённый IP, нет токена → 401)', async () => {
|
||||||
|
process.env.ADMIN_ACCESS_IPS = '1.2.3.4'
|
||||||
|
const app = await buildApp()
|
||||||
|
try {
|
||||||
|
const res = await app.inject({
|
||||||
|
method: 'GET',
|
||||||
|
url: '/admin/test',
|
||||||
|
remoteAddress: '1.2.3.4',
|
||||||
|
})
|
||||||
|
// IP passes, but no JWT → 401
|
||||||
|
expect(res.statusCode).toBe(401)
|
||||||
|
} finally {
|
||||||
|
await app.close()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
it('ADMIN_EMAIL не задан → 503, IP не проверяется', async () => {
|
||||||
|
delete process.env.ADMIN_EMAIL
|
||||||
|
process.env.ADMIN_ACCESS_IPS = '1.2.3.4'
|
||||||
|
const app = await buildApp()
|
||||||
|
try {
|
||||||
|
const res = await app.inject({
|
||||||
|
method: 'GET',
|
||||||
|
url: '/admin/test',
|
||||||
|
remoteAddress: '1.2.3.4',
|
||||||
|
})
|
||||||
|
expect(res.statusCode).toBe(503)
|
||||||
|
expect(res.json().error).toBe('ADMIN_EMAIL не задан в .env')
|
||||||
|
} finally {
|
||||||
|
await app.close()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -172,4 +172,88 @@ describe('registerIpGate', () => {
|
|||||||
})
|
})
|
||||||
expect(res.statusCode).toBe(200)
|
expect(res.statusCode).toBe(200)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('пропускает запрос с IP в CIDR-диапазоне /24', async () => {
|
||||||
|
process.env.SITE_ACCESS_IPS = '192.168.1.0/24'
|
||||||
|
const res = await app.inject({
|
||||||
|
method: 'GET',
|
||||||
|
url: '/test',
|
||||||
|
remoteAddress: '192.168.1.100',
|
||||||
|
})
|
||||||
|
expect(res.statusCode).toBe(200)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('блокирует запрос с IP вне CIDR-диапазона', async () => {
|
||||||
|
process.env.SITE_ACCESS_IPS = '192.168.1.0/24'
|
||||||
|
const res = await app.inject({
|
||||||
|
method: 'GET',
|
||||||
|
url: '/test',
|
||||||
|
remoteAddress: '10.0.0.1',
|
||||||
|
})
|
||||||
|
expect(res.statusCode).toBe(403)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('пропускает IP в CIDR /32 (эквивалент одного IP)', async () => {
|
||||||
|
process.env.SITE_ACCESS_IPS = '10.0.0.5/32'
|
||||||
|
const res = await app.inject({
|
||||||
|
method: 'GET',
|
||||||
|
url: '/test',
|
||||||
|
remoteAddress: '10.0.0.5',
|
||||||
|
})
|
||||||
|
expect(res.statusCode).toBe(200)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('блокирует IP рядом с CIDR /32', async () => {
|
||||||
|
process.env.SITE_ACCESS_IPS = '10.0.0.5/32'
|
||||||
|
const res = await app.inject({
|
||||||
|
method: 'GET',
|
||||||
|
url: '/test',
|
||||||
|
remoteAddress: '10.0.0.6',
|
||||||
|
})
|
||||||
|
expect(res.statusCode).toBe(403)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('пропускает любой IP в CIDR /0', async () => {
|
||||||
|
process.env.SITE_ACCESS_IPS = '0.0.0.0/0'
|
||||||
|
const res = await app.inject({
|
||||||
|
method: 'GET',
|
||||||
|
url: '/test',
|
||||||
|
remoteAddress: '1.2.3.4',
|
||||||
|
})
|
||||||
|
expect(res.statusCode).toBe(200)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('поддерживает микс точных IP и CIDR-диапазонов', async () => {
|
||||||
|
process.env.SITE_ACCESS_IPS = '1.2.3.4,10.0.0.0/24'
|
||||||
|
const res1 = await app.inject({
|
||||||
|
method: 'GET',
|
||||||
|
url: '/test',
|
||||||
|
remoteAddress: '1.2.3.4',
|
||||||
|
})
|
||||||
|
expect(res1.statusCode).toBe(200)
|
||||||
|
|
||||||
|
const res2 = await app.inject({
|
||||||
|
method: 'GET',
|
||||||
|
url: '/test',
|
||||||
|
remoteAddress: '10.0.0.50',
|
||||||
|
})
|
||||||
|
expect(res2.statusCode).toBe(200)
|
||||||
|
|
||||||
|
const res3 = await app.inject({
|
||||||
|
method: 'GET',
|
||||||
|
url: '/test',
|
||||||
|
remoteAddress: '9.9.9.9',
|
||||||
|
})
|
||||||
|
expect(res3.statusCode).toBe(403)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('IPv6-mapped адрес в CIDR-диапазоне', async () => {
|
||||||
|
process.env.SITE_ACCESS_IPS = '192.168.1.0/24'
|
||||||
|
const res = await app.inject({
|
||||||
|
method: 'GET',
|
||||||
|
url: '/test',
|
||||||
|
remoteAddress: '::ffff:192.168.1.50',
|
||||||
|
})
|
||||||
|
expect(res.statusCode).toBe(200)
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
import { normalizeIp, cidrMatch } from './ip-gate.js'
|
||||||
|
|
||||||
export function registerAuth(fastify) {
|
export function registerAuth(fastify) {
|
||||||
function normalizeEmail(email) {
|
function normalizeEmail(email) {
|
||||||
return String(email || '')
|
return String(email || '')
|
||||||
@@ -11,6 +13,22 @@ export function registerAuth(fastify) {
|
|||||||
return reply.code(503).send({ error: 'ADMIN_EMAIL не задан в .env' })
|
return reply.code(503).send({ error: 'ADMIN_EMAIL не задан в .env' })
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const adminIps = process.env.ADMIN_ACCESS_IPS
|
||||||
|
if (adminIps) {
|
||||||
|
const allowedList = adminIps
|
||||||
|
.split(',')
|
||||||
|
.map((s) => normalizeIp(s.trim()))
|
||||||
|
.filter(Boolean)
|
||||||
|
|
||||||
|
if (allowedList.length > 0) {
|
||||||
|
const reqIp = normalizeIp(request.ip)
|
||||||
|
const isAllowed = allowedList.includes(reqIp) || allowedList.some((entry) => cidrMatch(reqIp, entry))
|
||||||
|
if (!isAllowed) {
|
||||||
|
return reply.code(403).send({ error: 'Доступ с данного IP запрещён' })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await request.jwtVerify()
|
await request.jwtVerify()
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
|
|||||||
@@ -5,13 +5,39 @@ const EXCLUDED_PATHS = [
|
|||||||
'/api/admin/notifications/telegram/webhook',
|
'/api/admin/notifications/telegram/webhook',
|
||||||
]
|
]
|
||||||
|
|
||||||
function normalizeIp(ip) {
|
export function normalizeIp(ip) {
|
||||||
if (ip && ip.startsWith('::ffff:')) {
|
if (ip && ip.startsWith('::ffff:')) {
|
||||||
return ip.slice(7)
|
return ip.slice(7)
|
||||||
}
|
}
|
||||||
return ip
|
return ip
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function ipToInt(ip) {
|
||||||
|
const parts = ip.split('.')
|
||||||
|
if (parts.length !== 4) return null
|
||||||
|
return parts.reduce((acc, octet) => {
|
||||||
|
const num = parseInt(octet, 10)
|
||||||
|
if (isNaN(num) || num < 0 || num > 255) return null
|
||||||
|
return acc !== null ? (acc << 8) + num : null
|
||||||
|
}, 0)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function cidrMatch(ip, cidr) {
|
||||||
|
const slashIdx = cidr.indexOf('/')
|
||||||
|
if (slashIdx === -1) return false
|
||||||
|
|
||||||
|
const baseIp = cidr.slice(0, slashIdx)
|
||||||
|
const prefix = parseInt(cidr.slice(slashIdx + 1), 10)
|
||||||
|
if (isNaN(prefix) || prefix < 0 || prefix > 32) return false
|
||||||
|
|
||||||
|
const ipInt = ipToInt(normalizeIp(ip))
|
||||||
|
const baseInt = ipToInt(normalizeIp(baseIp))
|
||||||
|
if (ipInt === null || baseInt === null) return false
|
||||||
|
|
||||||
|
const mask = prefix === 0 ? 0 : ~(2 ** (32 - prefix) - 1) >>> 0
|
||||||
|
return (ipInt & mask) === (baseInt & mask)
|
||||||
|
}
|
||||||
|
|
||||||
export function build403Html(ip) {
|
export function build403Html(ip) {
|
||||||
const safeIp = ip || 'не определён'
|
const safeIp = ip || 'не определён'
|
||||||
return `<!DOCTYPE html>
|
return `<!DOCTYPE html>
|
||||||
@@ -95,7 +121,11 @@ export async function registerIpGate(fastify) {
|
|||||||
|
|
||||||
if (EXCLUDED_PATHS.includes(urlPath)) return
|
if (EXCLUDED_PATHS.includes(urlPath)) return
|
||||||
|
|
||||||
if (allowedIps.includes(normalizeIp(request.ip))) return
|
const normalizedIp = normalizeIp(request.ip)
|
||||||
|
if (allowedIps.includes(normalizedIp)) return
|
||||||
|
|
||||||
|
const isInCidr = allowedIps.some((entry) => cidrMatch(normalizedIp, entry))
|
||||||
|
if (isInCidr) return
|
||||||
|
|
||||||
return reply.code(403).type('text/html').send(build403Html(request.ip))
|
return reply.code(403).type('text/html').send(build403Html(request.ip))
|
||||||
})
|
})
|
||||||
|
|||||||
Reference in New Issue
Block a user