Compare commits
No commits in common. "707772c44effec27f3e7f2852f0058bda990d64b" and "8c247e9115cf6acdc38d4cc53880ab0b5f1c9cce" have entirely different histories.
707772c44e
...
8c247e9115
23
.env.production.save
Normal file
23
.env.production.save
Normal file
@ -0,0 +1,23 @@
|
|||||||
|
# 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
|
||||||
8
.gitignore
vendored
8
.gitignore
vendored
@ -41,11 +41,3 @@ prisma/dev.db-journal
|
|||||||
.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,317 +50,3 @@ 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.113.0",
|
"version": "1.102.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": {
|
||||||
|
|||||||
55
src/app/api/auth/login/route.ts
Normal file
55
src/app/api/auth/login/route.ts
Normal file
@ -0,0 +1,55 @@
|
|||||||
|
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,16 +2,9 @@ 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,25 +3,10 @@ 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;
|
||||||
|
|
||||||
@ -40,7 +25,7 @@ export async function POST(request: NextRequest) {
|
|||||||
await prisma.user.update({
|
await prisma.user.update({
|
||||||
where: { email },
|
where: { email },
|
||||||
data: {
|
data: {
|
||||||
passwordResetToken: hashToken(resetToken),
|
passwordResetToken: resetToken,
|
||||||
passwordResetExpires: resetExpires,
|
passwordResetExpires: resetExpires,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
@ -52,16 +37,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 < MIN_PASSWORD_LENGTH) {
|
if (newPassword.length < 6) {
|
||||||
return NextResponse.json(
|
return NextResponse.json(
|
||||||
{ error: `Password must be at least ${MIN_PASSWORD_LENGTH} characters` },
|
{ error: 'Password must be at least 6 characters' },
|
||||||
{ status: 400 }
|
{ status: 400 }
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const user = await prisma.user.findFirst({
|
const user = await (prisma.user as any).findFirst({
|
||||||
where: {
|
where: {
|
||||||
passwordResetToken: hashToken(token),
|
passwordResetToken: token,
|
||||||
passwordResetExpires: { gt: new Date() },
|
passwordResetExpires: { gt: new Date() },
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
@ -73,7 +58,7 @@ export async function POST(request: NextRequest) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const hashedPassword = await hash(newPassword, 12);
|
const hashedPassword = await hash(newPassword, 10);
|
||||||
|
|
||||||
await prisma.user.update({
|
await prisma.user.update({
|
||||||
where: { id: user.id },
|
where: { id: user.id },
|
||||||
|
|||||||
@ -4,20 +4,9 @@ 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
|
||||||
@ -32,13 +21,6 @@ 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,24 +1,12 @@
|
|||||||
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' },
|
||||||
@ -109,12 +97,6 @@ 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,17 +177,7 @@ 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));
|
||||||
|
|
||||||
// Never echo accessToken/refreshToken back to the client
|
return NextResponse.json({ success: true, connection: updated });
|
||||||
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,16 +76,7 @@ export async function POST(req: Request) {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// Never echo accessToken (username:password) back to the client
|
return NextResponse.json({ success: true, connection });
|
||||||
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,21 +8,6 @@ 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);
|
||||||
@ -82,7 +67,6 @@ 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,5 +1,4 @@
|
|||||||
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;
|
||||||
@ -186,9 +185,7 @@ export async function sendPasswordResetEmail(email: string, token: string, langu
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function generateVerificationCode(): string {
|
export function generateVerificationCode(): string {
|
||||||
// CSPRNG-backed; Math.random() is predictable and must never be used
|
return Math.floor(100000 + Math.random() * 900000).toString();
|
||||||
// for security tokens.
|
|
||||||
return randomInt(100000, 1000000).toString();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function generateVerificationToken(): string {
|
export function generateVerificationToken(): string {
|
||||||
|
|||||||
@ -1,75 +0,0 @@
|
|||||||
/**
|
|
||||||
* 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,13 +20,8 @@ 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