Compare commits
4 Commits
feat/mobil
...
6a4a308fd9
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6a4a308fd9 | ||
|
|
b058997889 | ||
|
|
4baa86eed0 | ||
|
|
4f4d2bfae5 |
1
.gitignore
vendored
1
.gitignore
vendored
@@ -1,3 +1,4 @@
|
||||
node_modules
|
||||
dist
|
||||
.vite
|
||||
.env
|
||||
|
||||
12
Dockerfile
12
Dockerfile
@@ -1,13 +1,19 @@
|
||||
FROM node:20-alpine AS build
|
||||
WORKDIR /app
|
||||
COPY package.json package-lock.json* ./
|
||||
COPY packages/client/package.json packages/client/
|
||||
COPY packages/server/package.json packages/server/
|
||||
RUN npm install
|
||||
COPY . .
|
||||
COPY packages/client packages/client
|
||||
RUN npm run build
|
||||
|
||||
FROM node:20-alpine
|
||||
WORKDIR /app
|
||||
COPY package.json package-lock.json* ./
|
||||
COPY packages/server/package.json packages/server/
|
||||
RUN npm install -w packages/server --omit=dev
|
||||
COPY --from=build /app/dist ./dist
|
||||
COPY server.js .
|
||||
COPY packages/server packages/server
|
||||
ENV NODE_ENV=production
|
||||
EXPOSE 80
|
||||
CMD ["node", "server.js"]
|
||||
CMD ["node", "packages/server/src/index.js"]
|
||||
|
||||
24
docker-compose.yml
Normal file
24
docker-compose.yml
Normal file
@@ -0,0 +1,24 @@
|
||||
services:
|
||||
app:
|
||||
build: .
|
||||
ports:
|
||||
- "80:80"
|
||||
depends_on:
|
||||
db:
|
||||
condition: service_healthy
|
||||
|
||||
db:
|
||||
image: postgres:16-alpine
|
||||
environment:
|
||||
POSTGRES_USER: reaktor
|
||||
POSTGRES_PASSWORD: reaktor_dev
|
||||
POSTGRES_DB: reaktor
|
||||
volumes:
|
||||
- pgdata:/var/lib/postgresql/data
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "pg_isready -U reaktor"]
|
||||
interval: 5s
|
||||
retries: 5
|
||||
|
||||
volumes:
|
||||
pgdata:
|
||||
3000
package-lock.json
generated
3000
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
23
package.json
23
package.json
@@ -1,21 +1,12 @@
|
||||
{
|
||||
"name": "reaktor-montlab",
|
||||
"version": "1.0.0",
|
||||
"name": "reaktor",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"workspaces": ["packages/*"],
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "vite build",
|
||||
"preview": "vite preview",
|
||||
"start": "node server.js"
|
||||
},
|
||||
"dependencies": {
|
||||
"react": "^18.2.0",
|
||||
"react-dom": "^18.2.0",
|
||||
"tone": "^14.8.49"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@vitejs/plugin-react": "^4.2.0",
|
||||
"vite": "^5.4.0"
|
||||
"dev": "npm run dev -w packages/client",
|
||||
"dev:server": "npm run dev -w packages/server",
|
||||
"build": "npm run build -w packages/client",
|
||||
"start": "node packages/server/src/index.js",
|
||||
"db:push": "npm run db:push -w packages/server"
|
||||
}
|
||||
}
|
||||
|
||||
20
packages/client/package.json
Normal file
20
packages/client/package.json
Normal file
@@ -0,0 +1,20 @@
|
||||
{
|
||||
"name": "@reaktor/client",
|
||||
"version": "1.0.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "vite build",
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"react": "^18.2.0",
|
||||
"react-dom": "^18.2.0",
|
||||
"tone": "^14.8.49"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@vitejs/plugin-react": "^4.2.0",
|
||||
"vite": "^5.4.0"
|
||||
}
|
||||
}
|
||||
|
Before Width: | Height: | Size: 496 B After Width: | Height: | Size: 496 B |
|
Before Width: | Height: | Size: 3.5 KiB After Width: | Height: | Size: 3.5 KiB |
|
Before Width: | Height: | Size: 11 KiB After Width: | Height: | Size: 11 KiB |
13
packages/client/vite.config.js
Normal file
13
packages/client/vite.config.js
Normal file
@@ -0,0 +1,13 @@
|
||||
import { defineConfig } from 'vite';
|
||||
import react from '@vitejs/plugin-react';
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [react()],
|
||||
server: {
|
||||
port: 3000,
|
||||
proxy: {
|
||||
'/api': 'http://localhost:3001',
|
||||
},
|
||||
},
|
||||
build: { outDir: '../../dist', emptyOutDir: true }
|
||||
});
|
||||
5
packages/server/.env.example
Normal file
5
packages/server/.env.example
Normal file
@@ -0,0 +1,5 @@
|
||||
DATABASE_URL=postgres://reaktor:reaktor_dev@localhost:5432/reaktor
|
||||
JWT_SECRET=change-this-to-a-random-secret
|
||||
PORT=3001
|
||||
CORS_ORIGIN=http://localhost:3000
|
||||
NODE_ENV=development
|
||||
8
packages/server/drizzle.config.js
Normal file
8
packages/server/drizzle.config.js
Normal file
@@ -0,0 +1,8 @@
|
||||
export default {
|
||||
schema: './src/db/schema.js',
|
||||
out: './src/db/migrations',
|
||||
dialect: 'postgresql',
|
||||
dbCredentials: {
|
||||
url: process.env.DATABASE_URL || 'postgres://reaktor:reaktor_dev@localhost:5432/reaktor',
|
||||
},
|
||||
};
|
||||
29
packages/server/package.json
Normal file
29
packages/server/package.json
Normal file
@@ -0,0 +1,29 @@
|
||||
{
|
||||
"name": "@reaktor/server",
|
||||
"version": "1.0.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"start": "node src/index.js",
|
||||
"dev": "node --watch src/index.js",
|
||||
"db:generate": "drizzle-kit generate",
|
||||
"db:push": "drizzle-kit push"
|
||||
},
|
||||
"dependencies": {
|
||||
"@fastify/cookie": "^11.0.2",
|
||||
"@fastify/cors": "^11.2.0",
|
||||
"@fastify/jwt": "^10.0.0",
|
||||
"@fastify/multipart": "^9.4.0",
|
||||
"@fastify/rate-limit": "^10.3.0",
|
||||
"@fastify/static": "^9.0.0",
|
||||
"argon2": "^0.44.0",
|
||||
"dotenv": "^17.3.1",
|
||||
"drizzle-orm": "^0.45.1",
|
||||
"fastify": "^5.8.2",
|
||||
"postgres": "^3.4.8",
|
||||
"uuid": "^13.0.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"drizzle-kit": "^0.31.10"
|
||||
}
|
||||
}
|
||||
@@ -4,7 +4,7 @@ const fs = require('fs');
|
||||
const path = require('path');
|
||||
|
||||
const PORT = process.env.PORT || 80;
|
||||
const STATIC_DIR = path.join(__dirname, 'dist');
|
||||
const STATIC_DIR = path.join(__dirname, '..', '..', 'dist');
|
||||
|
||||
const MIME = {
|
||||
'.html': 'text/html', '.css': 'text/css', '.js': 'application/javascript',
|
||||
9
packages/server/src/db/index.js
Normal file
9
packages/server/src/db/index.js
Normal file
@@ -0,0 +1,9 @@
|
||||
import { drizzle } from 'drizzle-orm/postgres-js';
|
||||
import postgres from 'postgres';
|
||||
import * as schema from './schema.js';
|
||||
|
||||
const connectionString = process.env.DATABASE_URL || 'postgres://reaktor:reaktor_dev@localhost:5432/reaktor';
|
||||
|
||||
const client = postgres(connectionString);
|
||||
export const db = drizzle(client, { schema });
|
||||
export { schema };
|
||||
63
packages/server/src/db/schema.js
Normal file
63
packages/server/src/db/schema.js
Normal file
@@ -0,0 +1,63 @@
|
||||
import { pgTable, uuid, varchar, text, boolean, integer, timestamp, jsonb, primaryKey } from 'drizzle-orm/pg-core';
|
||||
|
||||
export const users = pgTable('users', {
|
||||
id: uuid('id').defaultRandom().primaryKey(),
|
||||
email: varchar('email', { length: 255 }).unique().notNull(),
|
||||
username: varchar('username', { length: 50 }).unique().notNull(),
|
||||
passwordHash: varchar('password_hash', { length: 255 }).notNull(),
|
||||
avatarUrl: varchar('avatar_url', { length: 500 }),
|
||||
bio: text('bio'),
|
||||
role: varchar('role', { length: 20 }).default('user').notNull(), // user | premium | admin
|
||||
authProvider: varchar('auth_provider', { length: 20 }).default('local'),
|
||||
providerId: varchar('provider_id', { length: 255 }),
|
||||
stripeCustomerId: varchar('stripe_customer_id', { length: 255 }),
|
||||
subscriptionStatus: varchar('subscription_status', { length: 20 }),
|
||||
createdAt: timestamp('created_at', { withTimezone: true }).defaultNow().notNull(),
|
||||
updatedAt: timestamp('updated_at', { withTimezone: true }).defaultNow().notNull(),
|
||||
});
|
||||
|
||||
export const presets = pgTable('presets', {
|
||||
id: uuid('id').defaultRandom().primaryKey(),
|
||||
userId: uuid('user_id').references(() => users.id, { onDelete: 'cascade' }).notNull(),
|
||||
name: varchar('name', { length: 200 }).notNull(),
|
||||
data: jsonb('data').notNull(),
|
||||
isAutosave: boolean('is_autosave').default(false),
|
||||
createdAt: timestamp('created_at', { withTimezone: true }).defaultNow().notNull(),
|
||||
updatedAt: timestamp('updated_at', { withTimezone: true }).defaultNow().notNull(),
|
||||
});
|
||||
|
||||
export const gameProgress = pgTable('game_progress', {
|
||||
userId: uuid('user_id').references(() => users.id, { onDelete: 'cascade' }).primaryKey(),
|
||||
data: jsonb('data').notNull(),
|
||||
updatedAt: timestamp('updated_at', { withTimezone: true }).defaultNow().notNull(),
|
||||
});
|
||||
|
||||
export const sharedPatches = pgTable('shared_patches', {
|
||||
id: uuid('id').defaultRandom().primaryKey(),
|
||||
userId: uuid('user_id').references(() => users.id, { onDelete: 'set null' }),
|
||||
title: varchar('title', { length: 200 }).notNull(),
|
||||
description: text('description'),
|
||||
tags: text('tags').array(),
|
||||
data: jsonb('data').notNull(),
|
||||
previewUrl: varchar('preview_url', { length: 500 }),
|
||||
likesCount: integer('likes_count').default(0).notNull(),
|
||||
isFlagged: boolean('is_flagged').default(false).notNull(),
|
||||
isDeleted: boolean('is_deleted').default(false).notNull(),
|
||||
createdAt: timestamp('created_at', { withTimezone: true }).defaultNow().notNull(),
|
||||
});
|
||||
|
||||
export const likes = pgTable('likes', {
|
||||
userId: uuid('user_id').references(() => users.id, { onDelete: 'cascade' }).notNull(),
|
||||
patchId: uuid('patch_id').references(() => sharedPatches.id, { onDelete: 'cascade' }).notNull(),
|
||||
createdAt: timestamp('created_at', { withTimezone: true }).defaultNow().notNull(),
|
||||
}, (table) => ({
|
||||
pk: primaryKey({ columns: [table.userId, table.patchId] }),
|
||||
}));
|
||||
|
||||
export const refreshTokens = pgTable('refresh_tokens', {
|
||||
id: uuid('id').defaultRandom().primaryKey(),
|
||||
userId: uuid('user_id').references(() => users.id, { onDelete: 'cascade' }).notNull(),
|
||||
tokenHash: varchar('token_hash', { length: 255 }).notNull(),
|
||||
expiresAt: timestamp('expires_at', { withTimezone: true }).notNull(),
|
||||
createdAt: timestamp('created_at', { withTimezone: true }).defaultNow().notNull(),
|
||||
});
|
||||
74
packages/server/src/index.js
Normal file
74
packages/server/src/index.js
Normal file
@@ -0,0 +1,74 @@
|
||||
import 'dotenv/config';
|
||||
import Fastify from 'fastify';
|
||||
import cors from '@fastify/cors';
|
||||
import cookie from '@fastify/cookie';
|
||||
import jwt from '@fastify/jwt';
|
||||
import rateLimit from '@fastify/rate-limit';
|
||||
import fastifyStatic from '@fastify/static';
|
||||
import path from 'path';
|
||||
import { fileURLToPath } from 'url';
|
||||
|
||||
import authRoutes from './routes/auth.js';
|
||||
import userRoutes from './routes/users.js';
|
||||
import adminRoutes from './routes/admin.js';
|
||||
|
||||
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||
const PORT = process.env.PORT || 3001;
|
||||
const JWT_SECRET = process.env.JWT_SECRET || 'dev-secret-change-in-production';
|
||||
|
||||
const fastify = Fastify({ logger: true });
|
||||
|
||||
// Plugins
|
||||
await fastify.register(cors, {
|
||||
origin: process.env.CORS_ORIGIN || 'http://localhost:3000',
|
||||
credentials: true,
|
||||
});
|
||||
|
||||
await fastify.register(cookie);
|
||||
|
||||
await fastify.register(jwt, {
|
||||
secret: JWT_SECRET,
|
||||
});
|
||||
|
||||
await fastify.register(rateLimit, {
|
||||
max: 100,
|
||||
timeWindow: '1 minute',
|
||||
});
|
||||
|
||||
// API routes
|
||||
await fastify.register(authRoutes, { prefix: '/api/v1/auth' });
|
||||
await fastify.register(userRoutes, { prefix: '/api/v1/users' });
|
||||
await fastify.register(adminRoutes, { prefix: '/api/v1/admin' });
|
||||
|
||||
// Rate limit auth endpoints more aggressively
|
||||
fastify.addHook('onRoute', (routeOptions) => {
|
||||
if (routeOptions.url?.startsWith('/api/v1/auth')) {
|
||||
routeOptions.config = { ...routeOptions.config, rateLimit: { max: 10, timeWindow: '1 minute' } };
|
||||
}
|
||||
});
|
||||
|
||||
// Health check
|
||||
fastify.get('/api/health', async () => ({ status: 'ok', timestamp: new Date().toISOString() }));
|
||||
|
||||
// In production, serve static files (SPA)
|
||||
if (process.env.NODE_ENV === 'production') {
|
||||
const distDir = path.join(__dirname, '..', '..', '..', 'dist');
|
||||
await fastify.register(fastifyStatic, { root: distDir });
|
||||
|
||||
// SPA fallback
|
||||
fastify.setNotFoundHandler((request, reply) => {
|
||||
if (request.url.startsWith('/api/')) {
|
||||
reply.code(404).send({ error: 'Not found' });
|
||||
} else {
|
||||
reply.sendFile('index.html');
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Start
|
||||
try {
|
||||
await fastify.listen({ port: PORT, host: '0.0.0.0' });
|
||||
} catch (err) {
|
||||
fastify.log.error(err);
|
||||
process.exit(1);
|
||||
}
|
||||
18
packages/server/src/middleware/auth.js
Normal file
18
packages/server/src/middleware/auth.js
Normal file
@@ -0,0 +1,18 @@
|
||||
export async function authenticate(request, reply) {
|
||||
try {
|
||||
await request.jwtVerify();
|
||||
} catch (err) {
|
||||
reply.code(401).send({ error: 'Unauthorized' });
|
||||
}
|
||||
}
|
||||
|
||||
export async function requireAdmin(request, reply) {
|
||||
try {
|
||||
await request.jwtVerify();
|
||||
if (request.user.role !== 'admin') {
|
||||
reply.code(403).send({ error: 'Forbidden' });
|
||||
}
|
||||
} catch (err) {
|
||||
reply.code(401).send({ error: 'Unauthorized' });
|
||||
}
|
||||
}
|
||||
103
packages/server/src/routes/admin.js
Normal file
103
packages/server/src/routes/admin.js
Normal file
@@ -0,0 +1,103 @@
|
||||
import { eq, sql, desc, ilike } from 'drizzle-orm';
|
||||
import { db, schema } from '../db/index.js';
|
||||
import { requireAdmin } from '../middleware/auth.js';
|
||||
|
||||
export default async function adminRoutes(fastify) {
|
||||
|
||||
// Dashboard stats
|
||||
fastify.get('/stats', { preHandler: [requireAdmin] }, async () => {
|
||||
const [userCount] = await db.select({ count: sql`count(*)::int` }).from(schema.users);
|
||||
const [patchCount] = await db.select({ count: sql`count(*)::int` }).from(schema.sharedPatches).where(eq(schema.sharedPatches.isDeleted, false));
|
||||
const [premiumCount] = await db.select({ count: sql`count(*)::int` }).from(schema.users).where(eq(schema.users.role, 'premium'));
|
||||
const [flaggedCount] = await db.select({ count: sql`count(*)::int` }).from(schema.sharedPatches).where(eq(schema.sharedPatches.isFlagged, true));
|
||||
|
||||
return {
|
||||
users: userCount.count,
|
||||
patches: patchCount.count,
|
||||
premium: premiumCount.count,
|
||||
flagged: flaggedCount.count,
|
||||
};
|
||||
});
|
||||
|
||||
// List users
|
||||
fastify.get('/users', { preHandler: [requireAdmin] }, async (request) => {
|
||||
const { q, page = 1, role } = request.query;
|
||||
const limit = 20;
|
||||
const offset = (page - 1) * limit;
|
||||
|
||||
let query = db.select({
|
||||
id: schema.users.id,
|
||||
email: schema.users.email,
|
||||
username: schema.users.username,
|
||||
role: schema.users.role,
|
||||
avatarUrl: schema.users.avatarUrl,
|
||||
createdAt: schema.users.createdAt,
|
||||
}).from(schema.users);
|
||||
|
||||
if (q) {
|
||||
query = query.where(
|
||||
sql`${schema.users.username} ILIKE ${'%' + q + '%'} OR ${schema.users.email} ILIKE ${'%' + q + '%'}`
|
||||
);
|
||||
}
|
||||
if (role) {
|
||||
query = query.where(eq(schema.users.role, role));
|
||||
}
|
||||
|
||||
const users = await query.orderBy(desc(schema.users.createdAt)).limit(limit).offset(offset);
|
||||
return { users, page, limit };
|
||||
});
|
||||
|
||||
// Update user role / ban
|
||||
fastify.patch('/users/:id', { preHandler: [requireAdmin] }, async (request, reply) => {
|
||||
const { role } = request.body || {};
|
||||
if (!role || !['user', 'premium', 'admin', 'banned'].includes(role)) {
|
||||
return reply.code(400).send({ error: 'Invalid role' });
|
||||
}
|
||||
|
||||
const [user] = await db.update(schema.users)
|
||||
.set({ role, updatedAt: new Date() })
|
||||
.where(eq(schema.users.id, request.params.id))
|
||||
.returning({ id: schema.users.id, username: schema.users.username, role: schema.users.role });
|
||||
|
||||
if (!user) return reply.code(404).send({ error: 'User not found' });
|
||||
return user;
|
||||
});
|
||||
|
||||
// List shared patches (moderation)
|
||||
fastify.get('/patches', { preHandler: [requireAdmin] }, async (request) => {
|
||||
const { flagged, deleted, page = 1 } = request.query;
|
||||
const limit = 20;
|
||||
const offset = (page - 1) * limit;
|
||||
|
||||
let query = db.select().from(schema.sharedPatches);
|
||||
|
||||
if (flagged === 'true') {
|
||||
query = query.where(eq(schema.sharedPatches.isFlagged, true));
|
||||
}
|
||||
if (deleted === 'true') {
|
||||
query = query.where(eq(schema.sharedPatches.isDeleted, true));
|
||||
}
|
||||
|
||||
const patches = await query.orderBy(desc(schema.sharedPatches.createdAt)).limit(limit).offset(offset);
|
||||
return { patches, page, limit };
|
||||
});
|
||||
|
||||
// Moderate patch (delete, unflag, restore)
|
||||
fastify.patch('/patches/:id', { preHandler: [requireAdmin] }, async (request, reply) => {
|
||||
const updates = {};
|
||||
const { action } = request.body || {};
|
||||
|
||||
if (action === 'delete') updates.isDeleted = true;
|
||||
else if (action === 'restore') updates.isDeleted = false;
|
||||
else if (action === 'unflag') updates.isFlagged = false;
|
||||
else return reply.code(400).send({ error: 'Invalid action' });
|
||||
|
||||
const [patch] = await db.update(schema.sharedPatches)
|
||||
.set(updates)
|
||||
.where(eq(schema.sharedPatches.id, request.params.id))
|
||||
.returning();
|
||||
|
||||
if (!patch) return reply.code(404).send({ error: 'Patch not found' });
|
||||
return patch;
|
||||
});
|
||||
}
|
||||
206
packages/server/src/routes/auth.js
Normal file
206
packages/server/src/routes/auth.js
Normal file
@@ -0,0 +1,206 @@
|
||||
import argon2 from 'argon2';
|
||||
import { v4 as uuidv4 } from 'uuid';
|
||||
import { eq } from 'drizzle-orm';
|
||||
import { db, schema } from '../db/index.js';
|
||||
|
||||
const REFRESH_EXPIRY_DAYS = 30;
|
||||
|
||||
async function hashRefreshToken(token) {
|
||||
return argon2.hash(token, { type: argon2.argon2id });
|
||||
}
|
||||
|
||||
export default async function authRoutes(fastify) {
|
||||
|
||||
// Register
|
||||
fastify.post('/register', {
|
||||
schema: {
|
||||
body: {
|
||||
type: 'object',
|
||||
required: ['email', 'username', 'password'],
|
||||
properties: {
|
||||
email: { type: 'string', format: 'email' },
|
||||
username: { type: 'string', minLength: 3, maxLength: 50 },
|
||||
password: { type: 'string', minLength: 6 },
|
||||
},
|
||||
},
|
||||
},
|
||||
}, async (request, reply) => {
|
||||
const { email, username, password } = request.body;
|
||||
|
||||
// Check existing
|
||||
const existing = await db.select().from(schema.users)
|
||||
.where(eq(schema.users.email, email)).limit(1);
|
||||
if (existing.length > 0) {
|
||||
return reply.code(409).send({ error: 'Email already registered' });
|
||||
}
|
||||
|
||||
const existingUsername = await db.select().from(schema.users)
|
||||
.where(eq(schema.users.username, username)).limit(1);
|
||||
if (existingUsername.length > 0) {
|
||||
return reply.code(409).send({ error: 'Username already taken' });
|
||||
}
|
||||
|
||||
const passwordHash = await argon2.hash(password, { type: argon2.argon2id });
|
||||
|
||||
const [user] = await db.insert(schema.users).values({
|
||||
email,
|
||||
username,
|
||||
passwordHash,
|
||||
}).returning({ id: schema.users.id, email: schema.users.email, username: schema.users.username, role: schema.users.role });
|
||||
|
||||
// Generate tokens
|
||||
const accessToken = fastify.jwt.sign(
|
||||
{ id: user.id, email: user.email, username: user.username, role: user.role },
|
||||
{ expiresIn: '15m' }
|
||||
);
|
||||
|
||||
const refreshToken = uuidv4();
|
||||
const refreshHash = await hashRefreshToken(refreshToken);
|
||||
const expiresAt = new Date(Date.now() + REFRESH_EXPIRY_DAYS * 24 * 60 * 60 * 1000);
|
||||
|
||||
await db.insert(schema.refreshTokens).values({
|
||||
userId: user.id,
|
||||
tokenHash: refreshHash,
|
||||
expiresAt,
|
||||
});
|
||||
|
||||
reply.setCookie('refreshToken', refreshToken, {
|
||||
httpOnly: true,
|
||||
secure: process.env.NODE_ENV === 'production',
|
||||
sameSite: 'lax',
|
||||
path: '/api/v1/auth',
|
||||
maxAge: REFRESH_EXPIRY_DAYS * 24 * 60 * 60,
|
||||
});
|
||||
|
||||
return { user, accessToken };
|
||||
});
|
||||
|
||||
// Login
|
||||
fastify.post('/login', {
|
||||
schema: {
|
||||
body: {
|
||||
type: 'object',
|
||||
required: ['email', 'password'],
|
||||
properties: {
|
||||
email: { type: 'string' },
|
||||
password: { type: 'string' },
|
||||
},
|
||||
},
|
||||
},
|
||||
}, async (request, reply) => {
|
||||
const { email, password } = request.body;
|
||||
|
||||
const [user] = await db.select().from(schema.users)
|
||||
.where(eq(schema.users.email, email)).limit(1);
|
||||
|
||||
if (!user) {
|
||||
return reply.code(401).send({ error: 'Invalid credentials' });
|
||||
}
|
||||
|
||||
const valid = await argon2.verify(user.passwordHash, password);
|
||||
if (!valid) {
|
||||
return reply.code(401).send({ error: 'Invalid credentials' });
|
||||
}
|
||||
|
||||
const accessToken = fastify.jwt.sign(
|
||||
{ id: user.id, email: user.email, username: user.username, role: user.role },
|
||||
{ expiresIn: '15m' }
|
||||
);
|
||||
|
||||
const refreshToken = uuidv4();
|
||||
const refreshHash = await hashRefreshToken(refreshToken);
|
||||
const expiresAt = new Date(Date.now() + REFRESH_EXPIRY_DAYS * 24 * 60 * 60 * 1000);
|
||||
|
||||
await db.insert(schema.refreshTokens).values({
|
||||
userId: user.id,
|
||||
tokenHash: refreshHash,
|
||||
expiresAt,
|
||||
});
|
||||
|
||||
reply.setCookie('refreshToken', refreshToken, {
|
||||
httpOnly: true,
|
||||
secure: process.env.NODE_ENV === 'production',
|
||||
sameSite: 'lax',
|
||||
path: '/api/v1/auth',
|
||||
maxAge: REFRESH_EXPIRY_DAYS * 24 * 60 * 60,
|
||||
});
|
||||
|
||||
return {
|
||||
user: { id: user.id, email: user.email, username: user.username, role: user.role, avatarUrl: user.avatarUrl },
|
||||
accessToken,
|
||||
};
|
||||
});
|
||||
|
||||
// Refresh token
|
||||
fastify.post('/refresh', async (request, reply) => {
|
||||
const token = request.cookies.refreshToken;
|
||||
if (!token) {
|
||||
return reply.code(401).send({ error: 'No refresh token' });
|
||||
}
|
||||
|
||||
// Find all non-expired tokens and verify
|
||||
const candidates = await db.select().from(schema.refreshTokens)
|
||||
.where(eq(schema.refreshTokens.expiresAt, new Date())); // will fix below
|
||||
|
||||
// Actually, find all tokens and check hash
|
||||
const allTokens = await db.select().from(schema.refreshTokens);
|
||||
let matchedToken = null;
|
||||
|
||||
for (const t of allTokens) {
|
||||
if (t.expiresAt < new Date()) continue;
|
||||
try {
|
||||
if (await argon2.verify(t.tokenHash, token)) {
|
||||
matchedToken = t;
|
||||
break;
|
||||
}
|
||||
} catch {}
|
||||
}
|
||||
|
||||
if (!matchedToken) {
|
||||
return reply.code(401).send({ error: 'Invalid refresh token' });
|
||||
}
|
||||
|
||||
// Delete old token (rotation)
|
||||
await db.delete(schema.refreshTokens).where(eq(schema.refreshTokens.id, matchedToken.id));
|
||||
|
||||
// Get user
|
||||
const [user] = await db.select().from(schema.users)
|
||||
.where(eq(schema.users.id, matchedToken.userId)).limit(1);
|
||||
|
||||
if (!user) {
|
||||
return reply.code(401).send({ error: 'User not found' });
|
||||
}
|
||||
|
||||
// Issue new tokens
|
||||
const accessToken = fastify.jwt.sign(
|
||||
{ id: user.id, email: user.email, username: user.username, role: user.role },
|
||||
{ expiresIn: '15m' }
|
||||
);
|
||||
|
||||
const newRefreshToken = uuidv4();
|
||||
const refreshHash = await hashRefreshToken(newRefreshToken);
|
||||
const expiresAt = new Date(Date.now() + REFRESH_EXPIRY_DAYS * 24 * 60 * 60 * 1000);
|
||||
|
||||
await db.insert(schema.refreshTokens).values({
|
||||
userId: user.id,
|
||||
tokenHash: refreshHash,
|
||||
expiresAt,
|
||||
});
|
||||
|
||||
reply.setCookie('refreshToken', newRefreshToken, {
|
||||
httpOnly: true,
|
||||
secure: process.env.NODE_ENV === 'production',
|
||||
sameSite: 'lax',
|
||||
path: '/api/v1/auth',
|
||||
maxAge: REFRESH_EXPIRY_DAYS * 24 * 60 * 60,
|
||||
});
|
||||
|
||||
return { accessToken };
|
||||
});
|
||||
|
||||
// Logout
|
||||
fastify.post('/logout', async (request, reply) => {
|
||||
reply.clearCookie('refreshToken', { path: '/api/v1/auth' });
|
||||
return { ok: true };
|
||||
});
|
||||
}
|
||||
56
packages/server/src/routes/users.js
Normal file
56
packages/server/src/routes/users.js
Normal file
@@ -0,0 +1,56 @@
|
||||
import { eq } from 'drizzle-orm';
|
||||
import { db, schema } from '../db/index.js';
|
||||
import { authenticate } from '../middleware/auth.js';
|
||||
|
||||
export default async function userRoutes(fastify) {
|
||||
|
||||
// Get my profile
|
||||
fastify.get('/me', { preHandler: [authenticate] }, async (request) => {
|
||||
const [user] = await db.select({
|
||||
id: schema.users.id,
|
||||
email: schema.users.email,
|
||||
username: schema.users.username,
|
||||
avatarUrl: schema.users.avatarUrl,
|
||||
bio: schema.users.bio,
|
||||
role: schema.users.role,
|
||||
createdAt: schema.users.createdAt,
|
||||
}).from(schema.users).where(eq(schema.users.id, request.user.id)).limit(1);
|
||||
|
||||
if (!user) return { error: 'User not found' };
|
||||
return user;
|
||||
});
|
||||
|
||||
// Update my profile
|
||||
fastify.patch('/me', {
|
||||
preHandler: [authenticate],
|
||||
schema: {
|
||||
body: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
username: { type: 'string', minLength: 3, maxLength: 50 },
|
||||
bio: { type: 'string', maxLength: 500 },
|
||||
},
|
||||
},
|
||||
},
|
||||
}, async (request, reply) => {
|
||||
const updates = {};
|
||||
if (request.body.username) updates.username = request.body.username;
|
||||
if (request.body.bio !== undefined) updates.bio = request.body.bio;
|
||||
updates.updatedAt = new Date();
|
||||
|
||||
if (Object.keys(updates).length === 1) {
|
||||
return reply.code(400).send({ error: 'Nothing to update' });
|
||||
}
|
||||
|
||||
const [user] = await db.update(schema.users)
|
||||
.set(updates)
|
||||
.where(eq(schema.users.id, request.user.id))
|
||||
.returning({
|
||||
id: schema.users.id,
|
||||
username: schema.users.username,
|
||||
bio: schema.users.bio,
|
||||
});
|
||||
|
||||
return user;
|
||||
});
|
||||
}
|
||||
149
producto.md
Normal file
149
producto.md
Normal file
@@ -0,0 +1,149 @@
|
||||
# Reaktor — Product Document
|
||||
|
||||
## Vision
|
||||
|
||||
Reaktor es una plataforma web de sintesis modular que combina un **sandbox creativo** con un **sistema de aprendizaje gamificado (SynthQuest)**. La plataforma permitira a los usuarios crear, aprender, compartir y descubrir sonidos sintetizados.
|
||||
|
||||
---
|
||||
|
||||
## Producto Actual (v1 — Live)
|
||||
|
||||
### Sandbox Mode
|
||||
- Sintetizador modular completo en el navegador (Tone.js)
|
||||
- 15+ tipos de modulos: Oscillator, Filter, Envelope, VCA, LFO, Mixer, Sequencer, Piano Roll, Keyboard, Drum Pad, CV→Gate, Delay, Reverb, Distortion, Scope, Output, Noise
|
||||
- Conexion visual de modulos con cables de audio/control/trigger
|
||||
- Canvas con zoom, pan, grid
|
||||
- Guardado/carga de presets (localStorage)
|
||||
- Export/import de patches como JSON
|
||||
- Demo Chiptune incluido
|
||||
|
||||
### SynthQuest (Game Mode)
|
||||
- 12 mundos tematicos, 96 niveles progresivos
|
||||
- Sistema de 3 estrellas por nivel
|
||||
- Hints con penalizacion (max 2 estrellas)
|
||||
- Boss levels por mundo
|
||||
- Progreso persistente (localStorage)
|
||||
|
||||
### Mobile
|
||||
- UI responsiva completa (bottom sheet, tab bar, touch panning, pinch zoom)
|
||||
- Keyboard y Drum Pad a pantalla completa
|
||||
- PWA instalable
|
||||
|
||||
---
|
||||
|
||||
## Roadmap
|
||||
|
||||
### Phase 0 — Estructura (monorepo)
|
||||
**Objetivo:** Preparar la base tecnica para el backend sin romper nada.
|
||||
|
||||
- [ ] Reestructurar a monorepo (`packages/client` + `packages/server`)
|
||||
- [ ] Actualizar Dockerfile (multi-stage: client build + server)
|
||||
- [ ] Docker Compose con PostgreSQL
|
||||
- [ ] Verificar que deploy funciona igual que antes
|
||||
|
||||
### Phase 1 — Usuarios y Auth
|
||||
**Objetivo:** Sistema de cuentas de usuario.
|
||||
|
||||
- [ ] Backend API (Fastify + PostgreSQL + Drizzle ORM)
|
||||
- [ ] Registro por email + password (argon2)
|
||||
- [ ] Login con JWT (access token 15min + refresh cookie 30d)
|
||||
- [ ] Perfil de usuario (username, avatar, bio)
|
||||
- [ ] Roles: `user`, `premium`, `admin`
|
||||
- [ ] UI: modal de login/registro en el frontend
|
||||
- [ ] Auth context en React (user, isLoggedIn, role)
|
||||
- [ ] OAuth con Google/GitHub (opcional, puede ir en Phase 1.5)
|
||||
|
||||
### Phase 2 — Sincronizacion de Datos
|
||||
**Objetivo:** Los datos del usuario viajan con su cuenta, no con el dispositivo.
|
||||
|
||||
- [ ] Sync de presets a la nube (offline-first, localStorage primary)
|
||||
- [ ] Sync de progreso de SynthQuest
|
||||
- [ ] Merge inteligente: last-write-wins por timestamp
|
||||
- [ ] Cola de sincronizacion offline (flush al reconectar)
|
||||
- [ ] Multi-dispositivo: login en otro dispositivo y tener todo
|
||||
|
||||
### Phase 3 — Workshop / Comunidad
|
||||
**Objetivo:** Compartir creaciones y descubrir sonidos de otros usuarios.
|
||||
|
||||
- [ ] Publicar patches con titulo, descripcion, tags
|
||||
- [ ] Preview de audio (generado client-side con Tone.js Recorder)
|
||||
- [ ] Galeria publica: buscar, filtrar por tags, ordenar (popular/reciente)
|
||||
- [ ] Sistema de likes/favoritos
|
||||
- [ ] Cargar patch compartido directamente en el Sandbox
|
||||
- [ ] Perfil publico de usuario con sus patches compartidos
|
||||
- [ ] Comentarios en patches (v2, opcional)
|
||||
|
||||
### Phase 4 — Panel de Administracion
|
||||
**Objetivo:** Control total sobre la plataforma.
|
||||
|
||||
- [ ] Dashboard con KPIs:
|
||||
- Usuarios totales, DAU, MAU
|
||||
- Patches compartidos (total, por dia)
|
||||
- Usuarios premium vs free
|
||||
- Niveles completados (metricas del juego)
|
||||
- [ ] Gestion de usuarios:
|
||||
- Lista, busqueda, filtros
|
||||
- Ver detalle de usuario (patches, progreso, rol)
|
||||
- Cambiar rol (user → premium → admin)
|
||||
- Banear/desbanear
|
||||
- [ ] Moderacion del Workshop:
|
||||
- Ver patches reportados/flagged
|
||||
- Eliminar contenido (soft delete)
|
||||
- Editar titulo/descripcion
|
||||
- Ver historial de moderacion
|
||||
|
||||
### Phase 5 — Monetizacion (futuro)
|
||||
**Objetivo:** Cursos premium y sostenibilidad.
|
||||
|
||||
- [ ] Definir proveedor de pagos (Stripe, LemonSqueezy, Paddle)
|
||||
- [ ] Plan Premium: acceso a cursos avanzados de sintesis
|
||||
- [ ] Checkout flow
|
||||
- [ ] Gestion de suscripciones (portal del usuario)
|
||||
- [ ] Metricas de revenue en admin dashboard
|
||||
- [ ] Sandbox permanece gratuito
|
||||
|
||||
### Phase 6 — Cursos (futuro)
|
||||
**Objetivo:** Contenido educativo estructurado de pago.
|
||||
|
||||
- [ ] Sistema de cursos con lecciones
|
||||
- [ ] Lecciones interactivas (como SynthQuest pero mas profundo)
|
||||
- [ ] Certificados de completado
|
||||
- [ ] Tracks tematicos: "Sound Design", "Beat Making", "Ambient Textures"
|
||||
|
||||
---
|
||||
|
||||
## Stack Tecnico
|
||||
|
||||
| Capa | Tecnologia |
|
||||
|------|-----------|
|
||||
| Frontend | React 18, Vite, Tone.js |
|
||||
| Backend | Fastify v5 (Node.js) |
|
||||
| Base de datos | PostgreSQL 16 + Drizzle ORM |
|
||||
| Auth | JWT + httpOnly refresh cookies + argon2 |
|
||||
| Storage | Filesystem (Docker volume) |
|
||||
| Deploy | Docker + Docker Compose |
|
||||
| Hosting | montlab.dev (self-hosted) |
|
||||
| Git | Gitea (git.montlab.dev) |
|
||||
|
||||
---
|
||||
|
||||
## Principios de Diseno
|
||||
|
||||
1. **Offline-first** — La app funciona sin internet. El backend es un extra, no una dependencia.
|
||||
2. **Opt-in** — Todo funciona sin cuenta. Login desbloquea sync + comunidad.
|
||||
3. **Mobile-first** — Cada feature se disena primero para movil.
|
||||
4. **Progresivo** — Cada phase se puede deployar independientemente.
|
||||
5. **Simple** — Preferir soluciones simples sobre arquitecturas complejas.
|
||||
|
||||
---
|
||||
|
||||
## Metricas de Exito
|
||||
|
||||
- **Phase 1:** 100 usuarios registrados en el primer mes
|
||||
- **Phase 3:** 50 patches compartidos en el Workshop
|
||||
- **Phase 5:** 10 suscriptores premium
|
||||
- **Long-term:** Reaktor como referencia en educacion de sintesis modular web
|
||||
|
||||
---
|
||||
|
||||
*Documento vivo — actualizar conforme avanza el desarrollo.*
|
||||
@@ -1,8 +0,0 @@
|
||||
import { defineConfig } from 'vite';
|
||||
import react from '@vitejs/plugin-react';
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [react()],
|
||||
server: { port: 3000 },
|
||||
build: { outDir: 'dist' }
|
||||
});
|
||||
Reference in New Issue
Block a user