Compare commits
2 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 707772c44e | |||
| f6c4a21215 |
@ -1,23 +0,0 @@
|
|||||||
# Database (Use your Supabase details)
|
|
||||||
# Note: 6543 is for the pooled connection (PGBouncer)
|
|
||||||
DATABASE_URL="postgresql://postgres:[PASSWORD]@[LXC_IP]:6543/postgres?pgbouncer=true"
|
|
||||||
# Note: 5432 is for direct migrations
|
|
||||||
DIRECT_URL="postgresql://postgres:[PASSWORD]@[LXC_IP]:5432/postgres"
|
|
||||||
|
|
||||||
# App Settings
|
|
||||||
# NEXTAUTH_SECRET="f6e6f96860081d4da8b04e6e6f968600" # You can use any 32-char string
|
|
||||||
# NEXTAUTH_URL="http://192.168.178.8:3000"
|
|
||||||
# NEXT_PUBLIC_BASE_URL="http://192.168.178.8:3000"
|
|
||||||
|
|
||||||
# NextAuth
|
|
||||||
NEXTAUTH_SECRET=Ca7EoJzZSMJO2CkqwdWd
|
|
||||||
NEXTAUTH_URL=https://todo.martin-bierschenk.de
|
|
||||||
NEXT_PUBLIC_BASE_URL=https://todo.martin-bierschenk.de
|
|
||||||
|
|
||||||
|
|
||||||
# SMTP (Optional: For password resets/emails)
|
|
||||||
SMTP_HOST=w00ff033.kasserver.com
|
|
||||||
SMTP_PORT=587
|
|
||||||
SMTP_USERNAME=mail@carrylight.de
|
|
||||||
SMTP_PASSWORD=QijU8e2A8p3FE8WS8esR
|
|
||||||
SMTP_SECURE=true
|
|
||||||
10
.gitignore
vendored
10
.gitignore
vendored
@ -40,4 +40,12 @@ prisma/dev.db-journal
|
|||||||
# Next.js
|
# Next.js
|
||||||
.next/
|
.next/
|
||||||
|
|
||||||
certificates
|
certificates
|
||||||
|
|
||||||
|
# Design reference files (not tracked)
|
||||||
|
docs/design/
|
||||||
|
|
||||||
|
# Dokumentationen (nicht tracken)
|
||||||
|
APP_ERKLÄRUNG.md
|
||||||
|
.env.production.save
|
||||||
|
.env*.save
|
||||||
|
|||||||
314
CLAUDE.md
314
CLAUDE.md
@ -50,3 +50,317 @@ v1.1.0
|
|||||||
|
|
||||||
### Current Version
|
### Current Version
|
||||||
Check `package.json` → `version` field for the current version before bumping.
|
Check `package.json` → `version` field for the current version before bumping.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# Additional Generic Agent Instructions (appended)
|
||||||
|
|
||||||
|
You are the lead software architect and implementation agent for this project.
|
||||||
|
|
||||||
|
Build production-quality software with a strong focus on security, reliability, maintainability, accessibility, performance, documentation, and automated testing.
|
||||||
|
|
||||||
|
Project:
|
||||||
|
- Name: My-Weekly-ToDo-List
|
||||||
|
- Working directory: /home/sparky/Docker/My-Weekly-ToDo-List
|
||||||
|
- Primary repository: https://github.com/mARTin-B78/My-Weekly-ToDo-List
|
||||||
|
- Backup repository: https://gitea.martin-bierschenk.de/mARTin-B78/My-Weekly-ToDo-List.git
|
||||||
|
- Deployment target: todo.martin-bierschenk.de
|
||||||
|
- Working title: My-Weekly-ToDo-List
|
||||||
|
|
||||||
|
## Core rules
|
||||||
|
|
||||||
|
1. Inspect the existing repository before changing anything.
|
||||||
|
2. Preserve existing user changes.
|
||||||
|
3. Never delete or overwrite files without checking their purpose.
|
||||||
|
4. Never commit secrets, API keys, passwords, tokens, private keys, `.env` files, or personal data.
|
||||||
|
5. Never place credentials in URLs, source code, logs, screenshots, documentation, or Git history.
|
||||||
|
6. Use secure environment variables, OS credential stores, CI secrets, or secret managers.
|
||||||
|
7. Prefer simple, typed, testable, modular code.
|
||||||
|
8. Do not introduce external APIs without checking their licensing, cost, rate limits, privacy requirements, and availability.
|
||||||
|
9. Do not scrape websites without explicit permission.
|
||||||
|
10. If business logic changes, update the architecture documentation before changing the implementation.
|
||||||
|
|
||||||
|
## Planning workflow
|
||||||
|
|
||||||
|
Before implementation:
|
||||||
|
|
||||||
|
1. Inspect the repository and current Git status.
|
||||||
|
2. Create or update:
|
||||||
|
- `PROJECT_MAP.md`
|
||||||
|
- `architecture/`
|
||||||
|
- `Manual.md`
|
||||||
|
- `CHANGELOG.md`
|
||||||
|
- `VERSION`
|
||||||
|
- `.gitignore`
|
||||||
|
3. Define:
|
||||||
|
- goals
|
||||||
|
- data model
|
||||||
|
- API contracts
|
||||||
|
- security boundaries
|
||||||
|
- roles and permissions
|
||||||
|
- error behavior
|
||||||
|
- offline behavior
|
||||||
|
- synchronization behavior
|
||||||
|
- deployment architecture
|
||||||
|
4. Present the implementation plan.
|
||||||
|
5. Ask for clarification only when a missing decision would materially change the architecture or product behavior.
|
||||||
|
|
||||||
|
## Architecture rules
|
||||||
|
|
||||||
|
Use clear separation between:
|
||||||
|
|
||||||
|
- presentation/UI
|
||||||
|
- application/business logic
|
||||||
|
- domain models
|
||||||
|
- persistence/database
|
||||||
|
- external providers
|
||||||
|
- background jobs
|
||||||
|
- authentication and authorization
|
||||||
|
|
||||||
|
All external input must be validated at the boundary.
|
||||||
|
|
||||||
|
Use:
|
||||||
|
|
||||||
|
- strict typing
|
||||||
|
- runtime schema validation
|
||||||
|
- dependency injection for external services
|
||||||
|
- explicit database migrations
|
||||||
|
- transactions for multi-record operations
|
||||||
|
- idempotent writes
|
||||||
|
- bounded retries with exponential backoff
|
||||||
|
- timeouts and cancellation
|
||||||
|
- structured error handling
|
||||||
|
- feature flags for risky functionality
|
||||||
|
|
||||||
|
Authorization must be enforced server-side and, where applicable, at the database level. Never rely only on frontend visibility rules.
|
||||||
|
|
||||||
|
## Security requirements
|
||||||
|
|
||||||
|
Implement Secure-by-Default:
|
||||||
|
|
||||||
|
- least-privilege access
|
||||||
|
- secure cookies
|
||||||
|
- HTTPS in deployed environments
|
||||||
|
- strict CORS
|
||||||
|
- CSP and security headers
|
||||||
|
- CSRF protection where applicable
|
||||||
|
- rate limiting
|
||||||
|
- upload size and MIME validation
|
||||||
|
- malware scanning for user files
|
||||||
|
- safe Markdown/HTML rendering
|
||||||
|
- protection against SQL injection, XSS, SSRF, CSRF, IDOR, and privilege escalation
|
||||||
|
- audit logs for security-sensitive operations
|
||||||
|
- data minimization
|
||||||
|
- export, correction, and deletion flows where applicable
|
||||||
|
- privacy-safe logging
|
||||||
|
- dependency, container, and secret scanning
|
||||||
|
|
||||||
|
Passwords must be handled by a trusted authentication system. Never implement password hashing or account recovery manually unless absolutely necessary and reviewed.
|
||||||
|
|
||||||
|
## Documentation requirements
|
||||||
|
|
||||||
|
Maintain `Manual.md` as a living user manual.
|
||||||
|
|
||||||
|
Every feature must update the manual with:
|
||||||
|
|
||||||
|
- purpose
|
||||||
|
- prerequisites
|
||||||
|
- step-by-step usage
|
||||||
|
- expected result
|
||||||
|
- error handling
|
||||||
|
- mobile behavior
|
||||||
|
- accessibility behavior
|
||||||
|
- offline behavior
|
||||||
|
- privacy implications
|
||||||
|
- screenshots or examples when useful
|
||||||
|
|
||||||
|
Maintain architecture SOPs in `architecture/`.
|
||||||
|
|
||||||
|
Maintain a project map containing:
|
||||||
|
|
||||||
|
- current phase
|
||||||
|
- implemented features
|
||||||
|
- pending decisions
|
||||||
|
- data schema
|
||||||
|
- environment setup
|
||||||
|
- known limitations
|
||||||
|
- next logical step
|
||||||
|
|
||||||
|
Documentation must be updated in the same change as the feature.
|
||||||
|
|
||||||
|
## Quality assurance
|
||||||
|
|
||||||
|
Create one central QA command:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm run qa
|
||||||
|
```
|
||||||
|
|
||||||
|
or the equivalent for the selected technology.
|
||||||
|
|
||||||
|
It must run:
|
||||||
|
|
||||||
|
1. formatting checks
|
||||||
|
2. linting
|
||||||
|
3. type checking
|
||||||
|
4. unit tests
|
||||||
|
5. component tests
|
||||||
|
6. integration tests
|
||||||
|
7. database and authorization tests
|
||||||
|
8. accessibility tests
|
||||||
|
9. end-to-end tests
|
||||||
|
10. security and dependency scans
|
||||||
|
11. build validation
|
||||||
|
12. performance checks
|
||||||
|
|
||||||
|
Recommended tools:
|
||||||
|
|
||||||
|
- Vitest or Jest for unit tests
|
||||||
|
- Testing Library for components
|
||||||
|
- Playwright for browser tests
|
||||||
|
- axe-core for accessibility
|
||||||
|
- Lighthouse for PWA and performance
|
||||||
|
- MSW for mocking external APIs
|
||||||
|
- isolated Docker services for database/integration tests
|
||||||
|
- secret scanning and dependency auditing in CI
|
||||||
|
|
||||||
|
Every feature must include appropriate tests.
|
||||||
|
|
||||||
|
Critical flows require end-to-end tests.
|
||||||
|
|
||||||
|
Test both successful and denied/invalid paths.
|
||||||
|
|
||||||
|
Tests must:
|
||||||
|
|
||||||
|
- use isolated test data
|
||||||
|
- never use production data
|
||||||
|
- be repeatable
|
||||||
|
- be safe to rerun
|
||||||
|
- clean up after themselves
|
||||||
|
- work locally and in CI
|
||||||
|
|
||||||
|
## Responsive and accessibility requirements
|
||||||
|
|
||||||
|
For frontend applications:
|
||||||
|
|
||||||
|
- support mobile, tablet, and desktop
|
||||||
|
- support touch, mouse, keyboard, and screen readers
|
||||||
|
- target WCAG 2.2 AA
|
||||||
|
- use semantic HTML
|
||||||
|
- provide visible focus states
|
||||||
|
- provide sufficient color contrast
|
||||||
|
- support scalable text
|
||||||
|
- support reduced motion
|
||||||
|
- provide labels for icon-only controls
|
||||||
|
- support keyboard navigation
|
||||||
|
- provide accessible error messages
|
||||||
|
- provide alternatives for audio, images, speech, drag-and-drop, and gestures
|
||||||
|
- test common phone and tablet breakpoints
|
||||||
|
|
||||||
|
## Git and versioning
|
||||||
|
|
||||||
|
Use Semantic Versioning:
|
||||||
|
|
||||||
|
```
|
||||||
|
MAJOR.MINOR.PATCH
|
||||||
|
```
|
||||||
|
|
||||||
|
- `MAJOR`: breaking changes
|
||||||
|
- `MINOR`: new backward-compatible features
|
||||||
|
- `PATCH`: bug fixes
|
||||||
|
|
||||||
|
Use Conventional Commits:
|
||||||
|
|
||||||
|
```
|
||||||
|
feat: add vocabulary import
|
||||||
|
fix: correct offline synchronization error
|
||||||
|
docs: update user manual
|
||||||
|
test: add pronunciation scoring tests
|
||||||
|
refactor: simplify provider interface
|
||||||
|
chore: update dependencies
|
||||||
|
feat!: change card data model
|
||||||
|
```
|
||||||
|
|
||||||
|
Maintain:
|
||||||
|
|
||||||
|
- `VERSION`
|
||||||
|
- `CHANGELOG.md`
|
||||||
|
- Git tags
|
||||||
|
|
||||||
|
The release process must:
|
||||||
|
|
||||||
|
1. inspect commit history
|
||||||
|
2. calculate the next version
|
||||||
|
3. update `VERSION`
|
||||||
|
4. update `CHANGELOG.md`
|
||||||
|
5. add the current date
|
||||||
|
6. summarize user-visible changes
|
||||||
|
7. run the full QA suite
|
||||||
|
8. create a Git tag
|
||||||
|
9. push the same commit and tag to all configured remotes
|
||||||
|
|
||||||
|
Do not create recursive commits on every ordinary commit. Use an explicit release command or controlled CI workflow.
|
||||||
|
|
||||||
|
## Git remotes
|
||||||
|
|
||||||
|
If two remotes are configured:
|
||||||
|
|
||||||
|
```
|
||||||
|
git remote -v
|
||||||
|
```
|
||||||
|
|
||||||
|
Keep them synchronized.
|
||||||
|
|
||||||
|
Before pushing:
|
||||||
|
|
||||||
|
1. check the current branch
|
||||||
|
2. check for uncommitted changes
|
||||||
|
3. inspect the staged diff
|
||||||
|
4. scan for secrets
|
||||||
|
5. run QA
|
||||||
|
6. verify remote URLs
|
||||||
|
7. push to the primary remote
|
||||||
|
8. push to the backup remote
|
||||||
|
9. verify both remotes contain the same commit
|
||||||
|
|
||||||
|
Never request or expose tokens in chat. Never place credentials in Git configuration URLs.
|
||||||
|
|
||||||
|
## Release checklist
|
||||||
|
|
||||||
|
A release is complete only when:
|
||||||
|
|
||||||
|
- tests pass
|
||||||
|
- build succeeds
|
||||||
|
- accessibility checks pass
|
||||||
|
- security scans pass
|
||||||
|
- documentation is updated
|
||||||
|
- `VERSION` is updated
|
||||||
|
- `CHANGELOG.md` is updated
|
||||||
|
- Git tag is created
|
||||||
|
- primary repository is updated
|
||||||
|
- backup repository is updated
|
||||||
|
- deployment status is verified
|
||||||
|
- rollback instructions exist
|
||||||
|
|
||||||
|
## Working style
|
||||||
|
|
||||||
|
Work incrementally.
|
||||||
|
|
||||||
|
After every meaningful feature:
|
||||||
|
|
||||||
|
1. implement it
|
||||||
|
2. test it
|
||||||
|
3. update documentation
|
||||||
|
4. update the project map
|
||||||
|
5. inspect the diff
|
||||||
|
6. commit using Conventional Commits
|
||||||
|
7. report what changed, what was tested, and what comes next
|
||||||
|
|
||||||
|
Never claim a feature is complete without evidence from tests, build output, or manual verification.
|
||||||
|
|
||||||
|
When blocked, explain:
|
||||||
|
|
||||||
|
- the exact blocker
|
||||||
|
- what was checked
|
||||||
|
- why it cannot be solved safely by assumption
|
||||||
|
- the smallest decision or action required from the user
|
||||||
|
|||||||
@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "my-weekly-todo-list",
|
"name": "my-weekly-todo-list",
|
||||||
"version": "1.102.0",
|
"version": "1.113.0",
|
||||||
"description": "A web-based weekly task management application that organizes to-dos and calendar events in a single, intuitive weekly view",
|
"description": "A web-based weekly task management application that organizes to-dos and calendar events in a single, intuitive weekly view",
|
||||||
"main": "index.js",
|
"main": "index.js",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
|
|||||||
@ -1,55 +0,0 @@
|
|||||||
import { NextRequest, NextResponse } from 'next/server';
|
|
||||||
export const dynamic = 'force-dynamic';
|
|
||||||
import { compare } from 'bcryptjs';
|
|
||||||
|
|
||||||
export async function POST(request: NextRequest) {
|
|
||||||
try {
|
|
||||||
const { email, password } = await request.json();
|
|
||||||
|
|
||||||
// Basic validation
|
|
||||||
if (!email || !password) {
|
|
||||||
return NextResponse.json(
|
|
||||||
{ error: 'Email and password are required' },
|
|
||||||
{ status: 400 }
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// In a real app, you would fetch user from database here
|
|
||||||
// For now, we'll simulate a user lookup
|
|
||||||
console.log('Attempting to login user with email:', email);
|
|
||||||
|
|
||||||
// Simulate database lookup
|
|
||||||
const mockStoredPassword = '$2b$10$examplehashedpassword'; // This would come from DB
|
|
||||||
|
|
||||||
// Compare passwords
|
|
||||||
const isValid = await compare(password, mockStoredPassword);
|
|
||||||
|
|
||||||
if (!isValid) {
|
|
||||||
return NextResponse.json(
|
|
||||||
{ error: 'Invalid email or password' },
|
|
||||||
{ status: 401 }
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Create a mock JWT token (in a real app, you would use a proper JWT library)
|
|
||||||
const token = `mock-jwt-token-${Date.now()}`;
|
|
||||||
|
|
||||||
// Set cookie
|
|
||||||
const response = NextResponse.json({ message: 'Login successful' });
|
|
||||||
response.cookies.set('auth_token', token, {
|
|
||||||
httpOnly: true,
|
|
||||||
maxAge: 15 * 60, // 15 minutes
|
|
||||||
path: '/',
|
|
||||||
secure: process.env.NODE_ENV === 'production',
|
|
||||||
sameSite: 'strict'
|
|
||||||
});
|
|
||||||
|
|
||||||
return response;
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Login error:', error);
|
|
||||||
return NextResponse.json(
|
|
||||||
{ error: 'Internal server error' },
|
|
||||||
{ status: 500 }
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -2,9 +2,16 @@ import { NextRequest, NextResponse } from 'next/server';
|
|||||||
export const dynamic = 'force-dynamic';
|
export const dynamic = 'force-dynamic';
|
||||||
import { prisma } from '@/lib/prisma';
|
import { prisma } from '@/lib/prisma';
|
||||||
import { sendVerificationEmail, generateVerificationCode, generateVerificationToken } from '@/lib/email';
|
import { sendVerificationEmail, generateVerificationCode, generateVerificationToken } from '@/lib/email';
|
||||||
|
import { rateLimit, clientIp, tooManyRequests } from '@/lib/rate-limit';
|
||||||
|
|
||||||
export async function POST(request: NextRequest) {
|
export async function POST(request: NextRequest) {
|
||||||
try {
|
try {
|
||||||
|
const ip = clientIp(request);
|
||||||
|
const limited = rateLimit(`resend-verification:${ip}`, 5, 15 * 60 * 1000);
|
||||||
|
if (!limited.ok) {
|
||||||
|
return tooManyRequests(limited.retryAfterSeconds);
|
||||||
|
}
|
||||||
|
|
||||||
const { email } = await request.json();
|
const { email } = await request.json();
|
||||||
|
|
||||||
if (!email) {
|
if (!email) {
|
||||||
|
|||||||
@ -3,10 +3,25 @@ export const dynamic = 'force-dynamic';
|
|||||||
import { hash } from 'bcryptjs';
|
import { hash } from 'bcryptjs';
|
||||||
import { prisma } from '@/lib/prisma';
|
import { prisma } from '@/lib/prisma';
|
||||||
import { sendPasswordResetEmail } from '@/lib/email';
|
import { sendPasswordResetEmail } from '@/lib/email';
|
||||||
|
import { rateLimit, clientIp, tooManyRequests } from '@/lib/rate-limit';
|
||||||
import crypto from 'crypto';
|
import crypto from 'crypto';
|
||||||
|
|
||||||
|
// Reset tokens are stored as SHA-256 hashes so a leaked DB dump cannot be
|
||||||
|
// used to take over accounts. The raw token only ever exists in the email.
|
||||||
|
function hashToken(token: string): string {
|
||||||
|
return crypto.createHash('sha256').update(token).digest('hex');
|
||||||
|
}
|
||||||
|
|
||||||
|
const MIN_PASSWORD_LENGTH = 8; // keep in sync with signup
|
||||||
|
|
||||||
export async function POST(request: NextRequest) {
|
export async function POST(request: NextRequest) {
|
||||||
try {
|
try {
|
||||||
|
const ip = clientIp(request);
|
||||||
|
const limited = rateLimit(`reset-password:${ip}`, 10, 15 * 60 * 1000);
|
||||||
|
if (!limited.ok) {
|
||||||
|
return tooManyRequests(limited.retryAfterSeconds);
|
||||||
|
}
|
||||||
|
|
||||||
const body = await request.json();
|
const body = await request.json();
|
||||||
const { email, token, newPassword } = body;
|
const { email, token, newPassword } = body;
|
||||||
|
|
||||||
@ -25,7 +40,7 @@ export async function POST(request: NextRequest) {
|
|||||||
await prisma.user.update({
|
await prisma.user.update({
|
||||||
where: { email },
|
where: { email },
|
||||||
data: {
|
data: {
|
||||||
passwordResetToken: resetToken,
|
passwordResetToken: hashToken(resetToken),
|
||||||
passwordResetExpires: resetExpires,
|
passwordResetExpires: resetExpires,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
@ -37,16 +52,16 @@ export async function POST(request: NextRequest) {
|
|||||||
|
|
||||||
// CASE 2: Reset password (token + newPassword provided)
|
// CASE 2: Reset password (token + newPassword provided)
|
||||||
if (token && newPassword) {
|
if (token && newPassword) {
|
||||||
if (newPassword.length < 6) {
|
if (newPassword.length < MIN_PASSWORD_LENGTH) {
|
||||||
return NextResponse.json(
|
return NextResponse.json(
|
||||||
{ error: 'Password must be at least 6 characters' },
|
{ error: `Password must be at least ${MIN_PASSWORD_LENGTH} characters` },
|
||||||
{ status: 400 }
|
{ status: 400 }
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const user = await (prisma.user as any).findFirst({
|
const user = await prisma.user.findFirst({
|
||||||
where: {
|
where: {
|
||||||
passwordResetToken: token,
|
passwordResetToken: hashToken(token),
|
||||||
passwordResetExpires: { gt: new Date() },
|
passwordResetExpires: { gt: new Date() },
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
@ -58,7 +73,7 @@ export async function POST(request: NextRequest) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const hashedPassword = await hash(newPassword, 10);
|
const hashedPassword = await hash(newPassword, 12);
|
||||||
|
|
||||||
await prisma.user.update({
|
await prisma.user.update({
|
||||||
where: { id: user.id },
|
where: { id: user.id },
|
||||||
|
|||||||
@ -4,9 +4,20 @@ import { prisma } from '@/lib/prisma';
|
|||||||
import { hash } from 'bcryptjs';
|
import { hash } from 'bcryptjs';
|
||||||
import { sendVerificationEmail, generateVerificationCode, generateVerificationToken } from '@/lib/email';
|
import { sendVerificationEmail, generateVerificationCode, generateVerificationToken } from '@/lib/email';
|
||||||
import { languageFromAcceptLanguage } from '@/lib/emailTemplates';
|
import { languageFromAcceptLanguage } from '@/lib/emailTemplates';
|
||||||
|
import { rateLimit, clientIp, tooManyRequests } from '@/lib/rate-limit';
|
||||||
|
|
||||||
|
// Intentionally loose — real validation happens via the verification email.
|
||||||
|
// This only rejects obvious garbage before it reaches the DB and SMTP.
|
||||||
|
const EMAIL_PATTERN = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
|
||||||
|
|
||||||
export async function POST(request: NextRequest) {
|
export async function POST(request: NextRequest) {
|
||||||
try {
|
try {
|
||||||
|
const ip = clientIp(request);
|
||||||
|
const limited = rateLimit(`signup:${ip}`, 10, 60 * 60 * 1000);
|
||||||
|
if (!limited.ok) {
|
||||||
|
return tooManyRequests(limited.retryAfterSeconds);
|
||||||
|
}
|
||||||
|
|
||||||
const body = await request.json();
|
const body = await request.json();
|
||||||
const { name, email, password } = body;
|
const { name, email, password } = body;
|
||||||
// Prefer the explicit language from the form (filled in by the signup
|
// Prefer the explicit language from the form (filled in by the signup
|
||||||
@ -21,6 +32,13 @@ export async function POST(request: NextRequest) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (typeof email !== 'string' || !EMAIL_PATTERN.test(email) || email.length > 254) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'Please enter a valid email address' },
|
||||||
|
{ status: 400 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
if (password.length < 8) {
|
if (password.length < 8) {
|
||||||
return NextResponse.json(
|
return NextResponse.json(
|
||||||
{ error: 'Password must be at least 8 characters' },
|
{ error: 'Password must be at least 8 characters' },
|
||||||
|
|||||||
@ -1,12 +1,24 @@
|
|||||||
import { NextRequest, NextResponse } from 'next/server';
|
import { NextRequest, NextResponse } from 'next/server';
|
||||||
export const dynamic = 'force-dynamic';
|
export const dynamic = 'force-dynamic';
|
||||||
import { prisma } from '@/lib/prisma';
|
import { prisma } from '@/lib/prisma';
|
||||||
|
import { rateLimit, clientIp, tooManyRequests, RateLimitResult } from '@/lib/rate-limit';
|
||||||
|
|
||||||
// POST: Verify via 6-digit code
|
// POST: Verify via 6-digit code
|
||||||
export async function POST(request: NextRequest) {
|
export async function POST(request: NextRequest) {
|
||||||
try {
|
try {
|
||||||
const { email, code } = await request.json();
|
const { email, code } = await request.json();
|
||||||
|
|
||||||
|
// A 6-digit code has only 10^6 combinations — without attempt limiting
|
||||||
|
// it is brute-forceable in minutes. Limit per email AND per IP.
|
||||||
|
const ip = clientIp(request);
|
||||||
|
const perIp = rateLimit(`verify-email:ip:${ip}`, 10, 15 * 60 * 1000);
|
||||||
|
const perEmail: RateLimitResult = email
|
||||||
|
? rateLimit(`verify-email:email:${String(email).toLowerCase()}`, 5, 15 * 60 * 1000)
|
||||||
|
: { ok: true };
|
||||||
|
if (!perIp.ok || !perEmail.ok) {
|
||||||
|
return tooManyRequests(perIp.retryAfterSeconds ?? perEmail.retryAfterSeconds);
|
||||||
|
}
|
||||||
|
|
||||||
if (!email || !code) {
|
if (!email || !code) {
|
||||||
return NextResponse.json(
|
return NextResponse.json(
|
||||||
{ error: 'Email and code are required' },
|
{ error: 'Email and code are required' },
|
||||||
@ -97,6 +109,12 @@ export async function GET(request: NextRequest) {
|
|||||||
// the user actually clicks the button — survives scanner pre-fetches).
|
// the user actually clicks the button — survives scanner pre-fetches).
|
||||||
export async function PATCH(request: NextRequest) {
|
export async function PATCH(request: NextRequest) {
|
||||||
try {
|
try {
|
||||||
|
const ip = clientIp(request);
|
||||||
|
const limited = rateLimit(`verify-email-token:${ip}`, 10, 15 * 60 * 1000);
|
||||||
|
if (!limited.ok) {
|
||||||
|
return tooManyRequests(limited.retryAfterSeconds);
|
||||||
|
}
|
||||||
|
|
||||||
const { token } = await request.json();
|
const { token } = await request.json();
|
||||||
if (!token) {
|
if (!token) {
|
||||||
return NextResponse.json({ error: 'Token is required' }, { status: 400 });
|
return NextResponse.json({ error: 'Token is required' }, { status: 400 });
|
||||||
|
|||||||
@ -177,7 +177,17 @@ export async function PATCH(request: NextRequest) {
|
|||||||
refreshConnectionCache(rc, tMin, tMax)
|
refreshConnectionCache(rc, tMin, tMax)
|
||||||
.catch(e => console.error('[CACHE] Post-selection refresh failed:', e));
|
.catch(e => console.error('[CACHE] Post-selection refresh failed:', e));
|
||||||
|
|
||||||
return NextResponse.json({ success: true, connection: updated });
|
// Never echo accessToken/refreshToken back to the client
|
||||||
|
return NextResponse.json({
|
||||||
|
success: true,
|
||||||
|
connection: {
|
||||||
|
id: updated.id,
|
||||||
|
provider: updated.provider,
|
||||||
|
calendars: updated.calendars,
|
||||||
|
createdAt: updated.createdAt,
|
||||||
|
expiresAt: updated.expiresAt,
|
||||||
|
},
|
||||||
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error updating calendar connection:', error);
|
console.error('Error updating calendar connection:', error);
|
||||||
return NextResponse.json(
|
return NextResponse.json(
|
||||||
|
|||||||
@ -76,7 +76,16 @@ export async function POST(req: Request) {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
return NextResponse.json({ success: true, connection });
|
// Never echo accessToken (username:password) back to the client
|
||||||
|
return NextResponse.json({
|
||||||
|
success: true,
|
||||||
|
connection: {
|
||||||
|
id: connection.id,
|
||||||
|
provider: connection.provider,
|
||||||
|
calendars: connection.calendars,
|
||||||
|
createdAt: connection.createdAt,
|
||||||
|
},
|
||||||
|
});
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
console.error('Error connecting Synology Calendar:', error);
|
console.error('Error connecting Synology Calendar:', error);
|
||||||
return NextResponse.json(
|
return NextResponse.json(
|
||||||
|
|||||||
@ -8,6 +8,21 @@ export const dynamic = 'force-dynamic';
|
|||||||
// In-memory cache: { key: { data, fetchedAt } }
|
// In-memory cache: { key: { data, fetchedAt } }
|
||||||
const weatherCache = new Map<string, { data: any; fetchedAt: number }>();
|
const weatherCache = new Map<string, { data: any; fetchedAt: number }>();
|
||||||
const CACHE_TTL_MS = 15 * 60 * 1000; // 15 minutes
|
const CACHE_TTL_MS = 15 * 60 * 1000; // 15 minutes
|
||||||
|
const CACHE_MAX_ENTRIES = 500; // one entry per user/date-range — bound the Map
|
||||||
|
|
||||||
|
function evictStaleEntries() {
|
||||||
|
const now = Date.now();
|
||||||
|
for (const [key, entry] of weatherCache) {
|
||||||
|
if (now - entry.fetchedAt >= CACHE_TTL_MS) weatherCache.delete(key);
|
||||||
|
}
|
||||||
|
// Still over budget after TTL eviction: drop oldest entries
|
||||||
|
// (Map iterates in insertion order).
|
||||||
|
while (weatherCache.size >= CACHE_MAX_ENTRIES) {
|
||||||
|
const oldest = weatherCache.keys().next().value;
|
||||||
|
if (oldest === undefined) break;
|
||||||
|
weatherCache.delete(oldest);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export async function GET(request: NextRequest) {
|
export async function GET(request: NextRequest) {
|
||||||
const session = await getServerSession(authOptions);
|
const session = await getServerSession(authOptions);
|
||||||
@ -67,6 +82,7 @@ export async function GET(request: NextRequest) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const data = { hourly, timezone: raw.timezone };
|
const data = { hourly, timezone: raw.timezone };
|
||||||
|
evictStaleEntries();
|
||||||
weatherCache.set(cacheKey, { data, fetchedAt: Date.now() });
|
weatherCache.set(cacheKey, { data, fetchedAt: Date.now() });
|
||||||
|
|
||||||
return NextResponse.json(data);
|
return NextResponse.json(data);
|
||||||
|
|||||||
@ -1,4 +1,5 @@
|
|||||||
import nodemailer from 'nodemailer';
|
import nodemailer from 'nodemailer';
|
||||||
|
import { randomInt } from 'crypto';
|
||||||
import { getVerificationCopy, getResetCopy } from './emailTemplates';
|
import { getVerificationCopy, getResetCopy } from './emailTemplates';
|
||||||
|
|
||||||
let _transporter: nodemailer.Transporter | null = null;
|
let _transporter: nodemailer.Transporter | null = null;
|
||||||
@ -185,7 +186,9 @@ export async function sendPasswordResetEmail(email: string, token: string, langu
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function generateVerificationCode(): string {
|
export function generateVerificationCode(): string {
|
||||||
return Math.floor(100000 + Math.random() * 900000).toString();
|
// CSPRNG-backed; Math.random() is predictable and must never be used
|
||||||
|
// for security tokens.
|
||||||
|
return randomInt(100000, 1000000).toString();
|
||||||
}
|
}
|
||||||
|
|
||||||
export function generateVerificationToken(): string {
|
export function generateVerificationToken(): string {
|
||||||
|
|||||||
75
src/lib/rate-limit.ts
Normal file
75
src/lib/rate-limit.ts
Normal file
@ -0,0 +1,75 @@
|
|||||||
|
/**
|
||||||
|
* Minimal in-memory sliding-window rate limiter for auth-sensitive routes.
|
||||||
|
*
|
||||||
|
* Works per Node.js process. If the app ever scales to multiple instances,
|
||||||
|
* swap the backing store for Redis — the call sites won't need to change.
|
||||||
|
*/
|
||||||
|
|
||||||
|
type WindowEntry = { timestamps: number[] };
|
||||||
|
|
||||||
|
const buckets = new Map<string, WindowEntry>();
|
||||||
|
|
||||||
|
// Purge stale buckets periodically so the Map can't grow without bound.
|
||||||
|
const CLEANUP_INTERVAL_MS = 10 * 60 * 1000;
|
||||||
|
let lastCleanup = Date.now();
|
||||||
|
|
||||||
|
function cleanup(windowMs: number) {
|
||||||
|
const now = Date.now();
|
||||||
|
if (now - lastCleanup < CLEANUP_INTERVAL_MS) return;
|
||||||
|
lastCleanup = now;
|
||||||
|
for (const [key, entry] of buckets) {
|
||||||
|
entry.timestamps = entry.timestamps.filter(t => now - t < windowMs);
|
||||||
|
if (entry.timestamps.length === 0) buckets.delete(key);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface RateLimitResult {
|
||||||
|
ok: boolean;
|
||||||
|
/** Seconds until the client may retry (only set when blocked). */
|
||||||
|
retryAfterSeconds?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Record a hit for `key` and report whether it exceeds `limit` hits
|
||||||
|
* within the trailing `windowMs` window.
|
||||||
|
*/
|
||||||
|
export function rateLimit(key: string, limit: number, windowMs: number): RateLimitResult {
|
||||||
|
const now = Date.now();
|
||||||
|
cleanup(windowMs);
|
||||||
|
|
||||||
|
const entry = buckets.get(key) ?? { timestamps: [] };
|
||||||
|
entry.timestamps = entry.timestamps.filter(t => now - t < windowMs);
|
||||||
|
|
||||||
|
if (entry.timestamps.length >= limit) {
|
||||||
|
const oldest = entry.timestamps[0];
|
||||||
|
return {
|
||||||
|
ok: false,
|
||||||
|
retryAfterSeconds: Math.max(1, Math.ceil((oldest + windowMs - now) / 1000)),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
entry.timestamps.push(now);
|
||||||
|
buckets.set(key, entry);
|
||||||
|
return { ok: true };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Best-effort client IP extraction behind a reverse proxy. */
|
||||||
|
export function clientIp(request: Request): string {
|
||||||
|
const forwarded = request.headers.get('x-forwarded-for');
|
||||||
|
if (forwarded) return forwarded.split(',')[0].trim();
|
||||||
|
return request.headers.get('x-real-ip') ?? 'unknown';
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Standard 429 payload so all auth routes respond consistently. */
|
||||||
|
export function tooManyRequests(retryAfterSeconds = 60) {
|
||||||
|
return new Response(
|
||||||
|
JSON.stringify({ error: 'Too many requests. Please try again later.' }),
|
||||||
|
{
|
||||||
|
status: 429,
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
'Retry-After': String(retryAfterSeconds),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -20,8 +20,13 @@ export async function middleware(request: NextRequest) {
|
|||||||
secret: process.env.NEXTAUTH_SECRET,
|
secret: process.env.NEXTAUTH_SECRET,
|
||||||
});
|
});
|
||||||
|
|
||||||
// Redirect to login if not authenticated
|
|
||||||
if (!token && !request.nextUrl.pathname.startsWith('/auth')) {
|
if (!token && !request.nextUrl.pathname.startsWith('/auth')) {
|
||||||
|
// API callers get a proper 401 instead of a redirect to an HTML page —
|
||||||
|
// otherwise fetch() calls receive a 200 with the login page as body.
|
||||||
|
if (request.nextUrl.pathname.startsWith('/api')) {
|
||||||
|
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
||||||
|
}
|
||||||
|
// Browser navigation: redirect to login
|
||||||
const url = request.nextUrl.clone();
|
const url = request.nextUrl.clone();
|
||||||
url.pathname = '/auth/login';
|
url.pathname = '/auth/login';
|
||||||
url.searchParams.set('callbackUrl', request.nextUrl.pathname);
|
url.searchParams.set('callbackUrl', request.nextUrl.pathname);
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user