feat: implement Phase 1 server-side federation groundwork
Add multi-instance support foundation: shared federation types (ReplicatedInstance, InstanceInfoResponse, VerifyPasswordRequest), database schema changes (home_instance, replicated_instances on users, instance_name on settings), public instance info endpoint, auth registration with homeInstance and username@domain collision fallback, password verification endpoint, and replicatedInstances sync on user profile. Extract duplicated sanitizeUser into shared utility across 8 server files.
This commit is contained in:
@@ -23,6 +23,8 @@ function createTables(db: Database.Database): void {
|
|||||||
avatar TEXT,
|
avatar TEXT,
|
||||||
status TEXT DEFAULT 'offline',
|
status TEXT DEFAULT 'offline',
|
||||||
custom_status TEXT,
|
custom_status TEXT,
|
||||||
|
home_instance TEXT,
|
||||||
|
replicated_instances TEXT DEFAULT '[]',
|
||||||
created_at INTEGER NOT NULL
|
created_at INTEGER NOT NULL
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -177,6 +179,7 @@ function createTables(db: Database.Database): void {
|
|||||||
|
|
||||||
CREATE TABLE IF NOT EXISTS instance_settings (
|
CREATE TABLE IF NOT EXISTS instance_settings (
|
||||||
id INTEGER PRIMARY KEY DEFAULT 1 CHECK (id = 1),
|
id INTEGER PRIMARY KEY DEFAULT 1 CHECK (id = 1),
|
||||||
|
instance_name TEXT DEFAULT 'Backspace',
|
||||||
max_bitrate_kbps INTEGER NOT NULL DEFAULT 20000,
|
max_bitrate_kbps INTEGER NOT NULL DEFAULT 20000,
|
||||||
min_bitrate_kbps INTEGER NOT NULL DEFAULT 500,
|
min_bitrate_kbps INTEGER NOT NULL DEFAULT 500,
|
||||||
bitrate_step_kbps INTEGER NOT NULL DEFAULT 500,
|
bitrate_step_kbps INTEGER NOT NULL DEFAULT 500,
|
||||||
|
|||||||
@@ -54,6 +54,19 @@ export function runMigrations(db: Database.Database): void {
|
|||||||
columns: [
|
columns: [
|
||||||
{ name: 'is_admin', type: 'INTEGER DEFAULT 0' }
|
{ name: 'is_admin', type: 'INTEGER DEFAULT 0' }
|
||||||
]
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'users',
|
||||||
|
columns: [
|
||||||
|
{ name: 'home_instance', type: 'TEXT' },
|
||||||
|
{ name: 'replicated_instances', type: "TEXT DEFAULT '[]'" }
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'instance_settings',
|
||||||
|
columns: [
|
||||||
|
{ name: 'instance_name', type: "TEXT DEFAULT 'Backspace'" }
|
||||||
|
]
|
||||||
}
|
}
|
||||||
];
|
];
|
||||||
|
|
||||||
|
|||||||
@@ -9,6 +9,8 @@ export const users = sqliteTable('users', {
|
|||||||
status: text('status').default('offline'),
|
status: text('status').default('offline'),
|
||||||
customStatus: text('custom_status'),
|
customStatus: text('custom_status'),
|
||||||
isAdmin: integer('is_admin').default(0),
|
isAdmin: integer('is_admin').default(0),
|
||||||
|
homeInstance: text('home_instance'),
|
||||||
|
replicatedInstances: text('replicated_instances').default('[]'),
|
||||||
createdAt: integer('created_at').notNull(),
|
createdAt: integer('created_at').notNull(),
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -177,6 +179,7 @@ export const serverFolderMembers = sqliteTable('server_folder_members', {
|
|||||||
|
|
||||||
export const instanceSettings = sqliteTable('instance_settings', {
|
export const instanceSettings = sqliteTable('instance_settings', {
|
||||||
id: integer('id').primaryKey().default(1),
|
id: integer('id').primaryKey().default(1),
|
||||||
|
instanceName: text('instance_name').default('Backspace'),
|
||||||
maxBitrateKbps: integer('max_bitrate_kbps').notNull().default(20000),
|
maxBitrateKbps: integer('max_bitrate_kbps').notNull().default(20000),
|
||||||
minBitrateKbps: integer('min_bitrate_kbps').notNull().default(500),
|
minBitrateKbps: integer('min_bitrate_kbps').notNull().default(500),
|
||||||
bitrateStepKbps: integer('bitrate_step_kbps').notNull().default(500),
|
bitrateStepKbps: integer('bitrate_step_kbps').notNull().default(500),
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ import { livekitRoutes } from './routes/livekit.js';
|
|||||||
import { socialRoutes } from './routes/social.js';
|
import { socialRoutes } from './routes/social.js';
|
||||||
import { settingsRoutes } from './routes/settings.js';
|
import { settingsRoutes } from './routes/settings.js';
|
||||||
import { utilRoutes } from './routes/utils.js';
|
import { utilRoutes } from './routes/utils.js';
|
||||||
|
import { instanceRoutes } from './routes/instance.js';
|
||||||
import { registerWebSocket } from './ws/handler.js';
|
import { registerWebSocket } from './ws/handler.js';
|
||||||
import path from 'path';
|
import path from 'path';
|
||||||
import fs from 'fs';
|
import fs from 'fs';
|
||||||
@@ -75,6 +76,7 @@ async function main(): Promise<void> {
|
|||||||
await app.register(socialRoutes);
|
await app.register(socialRoutes);
|
||||||
await app.register(settingsRoutes);
|
await app.register(settingsRoutes);
|
||||||
await app.register(utilRoutes);
|
await app.register(utilRoutes);
|
||||||
|
await app.register(instanceRoutes);
|
||||||
await app.register(registerWebSocket);
|
await app.register(registerWebSocket);
|
||||||
|
|
||||||
app.get('/api/health', async () => {
|
app.get('/api/health', async () => {
|
||||||
|
|||||||
@@ -4,20 +4,8 @@ import { getDb, schema } from '../db/index.js';
|
|||||||
import { hashPassword, verifyPassword, signJwt } from '../utils/auth.js';
|
import { hashPassword, verifyPassword, signJwt } from '../utils/auth.js';
|
||||||
import { generateSnowflake } from '../utils/snowflake.js';
|
import { generateSnowflake } from '../utils/snowflake.js';
|
||||||
import { config } from '../config.js';
|
import { config } from '../config.js';
|
||||||
import type { RegisterRequest, LoginRequest, AuthResponse, User } from '@backspace/shared';
|
import type { RegisterRequest, LoginRequest, AuthResponse } from '@backspace/shared';
|
||||||
|
import { sanitizeUser } from '../utils/sanitize.js';
|
||||||
function sanitizeUser(row: typeof schema.users.$inferSelect): User {
|
|
||||||
return {
|
|
||||||
id: row.id,
|
|
||||||
username: row.username,
|
|
||||||
displayName: row.displayName,
|
|
||||||
avatar: row.avatar,
|
|
||||||
status: (row.status ?? 'offline') as User['status'],
|
|
||||||
customStatus: row.customStatus,
|
|
||||||
isAdmin: row.isAdmin === 1,
|
|
||||||
createdAt: row.createdAt,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function authRoutes(app: FastifyInstance): Promise<void> {
|
export async function authRoutes(app: FastifyInstance): Promise<void> {
|
||||||
app.post<{ Body: RegisterRequest }>('/api/auth/register', {
|
app.post<{ Body: RegisterRequest }>('/api/auth/register', {
|
||||||
@@ -29,7 +17,7 @@ export async function authRoutes(app: FastifyInstance): Promise<void> {
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
}, async (request, reply) => {
|
}, async (request, reply) => {
|
||||||
const { username, password, displayName } = request.body;
|
const { username, password, displayName, homeInstance } = request.body;
|
||||||
|
|
||||||
if (!username || typeof username !== 'string') {
|
if (!username || typeof username !== 'string') {
|
||||||
return reply.code(400).send({ error: 'Username is required', statusCode: 400 });
|
return reply.code(400).send({ error: 'Username is required', statusCode: 400 });
|
||||||
@@ -41,13 +29,47 @@ export async function authRoutes(app: FastifyInstance): Promise<void> {
|
|||||||
|
|
||||||
const trimmedUsername = username.trim();
|
const trimmedUsername = username.trim();
|
||||||
|
|
||||||
|
// Replicated registrations (homeInstance provided) may use username@domain format
|
||||||
|
// for collision fallback. Local registrations use strict alphanumeric+underscore.
|
||||||
|
if (homeInstance) {
|
||||||
|
// Validate homeInstance is a reasonable domain string
|
||||||
|
if (typeof homeInstance !== 'string' || homeInstance.length > 253 || !/^[a-zA-Z0-9._-]+$/.test(homeInstance)) {
|
||||||
|
return reply.code(400).send({ error: 'Invalid homeInstance domain', statusCode: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (trimmedUsername.includes('@')) {
|
||||||
|
// username@domain format: validate local part + domain part
|
||||||
|
const atIndex = trimmedUsername.indexOf('@');
|
||||||
|
const localPart = trimmedUsername.slice(0, atIndex);
|
||||||
|
const domainPart = trimmedUsername.slice(atIndex + 1);
|
||||||
|
|
||||||
|
if (localPart.length < 3 || localPart.length > 32 || !/^[a-zA-Z0-9_]+$/.test(localPart)) {
|
||||||
|
return reply.code(400).send({ error: 'Username local part must be 3-32 alphanumeric/underscore characters', statusCode: 400 });
|
||||||
|
}
|
||||||
|
if (domainPart.length === 0 || domainPart.length > 253 || !/^[a-zA-Z0-9._-]+$/.test(domainPart)) {
|
||||||
|
return reply.code(400).send({ error: 'Username domain part is invalid', statusCode: 400 });
|
||||||
|
}
|
||||||
|
if (trimmedUsername.length > 100) {
|
||||||
|
return reply.code(400).send({ error: 'Username must be 100 characters or less', statusCode: 400 });
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Plain username from a replicated registration — same rules as local
|
||||||
if (trimmedUsername.length < 3 || trimmedUsername.length > 32) {
|
if (trimmedUsername.length < 3 || trimmedUsername.length > 32) {
|
||||||
return reply.code(400).send({ error: 'Username must be between 3 and 32 characters', statusCode: 400 });
|
return reply.code(400).send({ error: 'Username must be between 3 and 32 characters', statusCode: 400 });
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!/^[a-zA-Z0-9_]+$/.test(trimmedUsername)) {
|
if (!/^[a-zA-Z0-9_]+$/.test(trimmedUsername)) {
|
||||||
return reply.code(400).send({ error: 'Username can only contain letters, numbers, and underscores', statusCode: 400 });
|
return reply.code(400).send({ error: 'Username can only contain letters, numbers, and underscores', statusCode: 400 });
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Local registration — strict validation
|
||||||
|
if (trimmedUsername.length < 3 || trimmedUsername.length > 32) {
|
||||||
|
return reply.code(400).send({ error: 'Username must be between 3 and 32 characters', statusCode: 400 });
|
||||||
|
}
|
||||||
|
if (!/^[a-zA-Z0-9_]+$/.test(trimmedUsername)) {
|
||||||
|
return reply.code(400).send({ error: 'Username can only contain letters, numbers, and underscores', statusCode: 400 });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if (password.length < 6) {
|
if (password.length < 6) {
|
||||||
return reply.code(400).send({ error: 'Password must be at least 6 characters', statusCode: 400 });
|
return reply.code(400).send({ error: 'Password must be at least 6 characters', statusCode: 400 });
|
||||||
@@ -68,9 +90,9 @@ export async function authRoutes(app: FastifyInstance): Promise<void> {
|
|||||||
const userId = generateSnowflake();
|
const userId = generateSnowflake();
|
||||||
const now = Date.now();
|
const now = Date.now();
|
||||||
|
|
||||||
// First registered user becomes instance admin
|
// First registered user becomes instance admin (replicated users are never admins)
|
||||||
const userCount = db.select().from(schema.users).all().length;
|
const userCount = db.select().from(schema.users).all().length;
|
||||||
const isFirstUser = userCount === 0;
|
const isFirstUser = userCount === 0 && !homeInstance;
|
||||||
|
|
||||||
db.insert(schema.users).values({
|
db.insert(schema.users).values({
|
||||||
id: userId,
|
id: userId,
|
||||||
@@ -79,6 +101,7 @@ export async function authRoutes(app: FastifyInstance): Promise<void> {
|
|||||||
passwordHash,
|
passwordHash,
|
||||||
status: 'online',
|
status: 'online',
|
||||||
isAdmin: isFirstUser ? 1 : 0,
|
isAdmin: isFirstUser ? 1 : 0,
|
||||||
|
homeInstance: homeInstance || null,
|
||||||
createdAt: now,
|
createdAt: now,
|
||||||
}).run();
|
}).run();
|
||||||
|
|
||||||
|
|||||||
@@ -6,7 +6,6 @@ import { generateSnowflake } from '../utils/snowflake.js';
|
|||||||
import { isDmMember } from '../utils/permissions.js';
|
import { isDmMember } from '../utils/permissions.js';
|
||||||
import { connectionManager } from '../ws/handler.js';
|
import { connectionManager } from '../ws/handler.js';
|
||||||
import type {
|
import type {
|
||||||
User,
|
|
||||||
DmChannel,
|
DmChannel,
|
||||||
DmMessage,
|
DmMessage,
|
||||||
DmMessageWithUser,
|
DmMessageWithUser,
|
||||||
@@ -17,19 +16,7 @@ import type {
|
|||||||
Attachment,
|
Attachment,
|
||||||
Reaction,
|
Reaction,
|
||||||
} from '@backspace/shared';
|
} from '@backspace/shared';
|
||||||
|
import { sanitizeUser } from '../utils/sanitize.js';
|
||||||
function sanitizeUser(row: typeof schema.users.$inferSelect): User {
|
|
||||||
return {
|
|
||||||
id: row.id,
|
|
||||||
username: row.username,
|
|
||||||
displayName: row.displayName,
|
|
||||||
avatar: row.avatar,
|
|
||||||
status: (row.status ?? 'offline') as User['status'],
|
|
||||||
customStatus: row.customStatus,
|
|
||||||
isAdmin: row.isAdmin === 1,
|
|
||||||
createdAt: row.createdAt,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Batch-fetch reactions for a set of DM message IDs.
|
* Batch-fetch reactions for a set of DM message IDs.
|
||||||
|
|||||||
@@ -0,0 +1,24 @@
|
|||||||
|
import type { FastifyInstance } from 'fastify';
|
||||||
|
import { eq } from 'drizzle-orm';
|
||||||
|
import { getDb, schema } from '../db/index.js';
|
||||||
|
import { config } from '../config.js';
|
||||||
|
import type { InstanceInfoResponse } from '@backspace/shared';
|
||||||
|
|
||||||
|
const BACKSPACE_VERSION = '1.0.0';
|
||||||
|
|
||||||
|
export async function instanceRoutes(app: FastifyInstance): Promise<void> {
|
||||||
|
app.get('/api/instance/info', async (_request, reply) => {
|
||||||
|
const db = getDb();
|
||||||
|
|
||||||
|
const settings = db.select().from(schema.instanceSettings).where(eq(schema.instanceSettings.id, 1)).get();
|
||||||
|
const instanceName = settings?.instanceName ?? 'Backspace';
|
||||||
|
|
||||||
|
const response: InstanceInfoResponse = {
|
||||||
|
name: instanceName,
|
||||||
|
version: BACKSPACE_VERSION,
|
||||||
|
registrationOpen: config.registrationOpen,
|
||||||
|
};
|
||||||
|
|
||||||
|
return reply.code(200).send(response);
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -9,23 +9,10 @@ import type {
|
|||||||
CreateMessageRequest,
|
CreateMessageRequest,
|
||||||
UpdateMessageRequest,
|
UpdateMessageRequest,
|
||||||
PaginatedQuery,
|
PaginatedQuery,
|
||||||
User,
|
|
||||||
MessageWithUser,
|
MessageWithUser,
|
||||||
Reaction,
|
Reaction,
|
||||||
} from '@backspace/shared';
|
} from '@backspace/shared';
|
||||||
|
import { sanitizeUser } from '../utils/sanitize.js';
|
||||||
function sanitizeUser(row: typeof schema.users.$inferSelect): User {
|
|
||||||
return {
|
|
||||||
id: row.id,
|
|
||||||
username: row.username,
|
|
||||||
displayName: row.displayName,
|
|
||||||
avatar: row.avatar,
|
|
||||||
status: (row.status ?? 'offline') as User['status'],
|
|
||||||
customStatus: row.customStatus,
|
|
||||||
isAdmin: row.isAdmin === 1,
|
|
||||||
createdAt: row.createdAt,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Fetch reactions for a set of message IDs.
|
* Fetch reactions for a set of message IDs.
|
||||||
|
|||||||
@@ -12,26 +12,13 @@ import type {
|
|||||||
UpdateServerRequest,
|
UpdateServerRequest,
|
||||||
JoinServerRequest,
|
JoinServerRequest,
|
||||||
UpdateMemberRequest,
|
UpdateMemberRequest,
|
||||||
User,
|
|
||||||
Server,
|
Server,
|
||||||
Channel,
|
Channel,
|
||||||
MemberWithUser,
|
MemberWithUser,
|
||||||
ServerWithChannelsAndMembers,
|
ServerWithChannelsAndMembers,
|
||||||
Role,
|
Role,
|
||||||
} from '@backspace/shared';
|
} from '@backspace/shared';
|
||||||
|
import { sanitizeUser } from '../utils/sanitize.js';
|
||||||
function sanitizeUser(row: typeof schema.users.$inferSelect): User {
|
|
||||||
return {
|
|
||||||
id: row.id,
|
|
||||||
username: row.username,
|
|
||||||
displayName: row.displayName,
|
|
||||||
avatar: row.avatar,
|
|
||||||
status: (row.status ?? 'offline') as User['status'],
|
|
||||||
customStatus: row.customStatus,
|
|
||||||
isAdmin: row.isAdmin === 1,
|
|
||||||
createdAt: row.createdAt,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
function rowToServer(row: typeof schema.servers.$inferSelect): Server {
|
function rowToServer(row: typeof schema.servers.$inferSelect): Server {
|
||||||
return {
|
return {
|
||||||
|
|||||||
@@ -5,25 +5,12 @@ import { authenticate } from '../utils/auth.js';
|
|||||||
import { generateSnowflake } from '../utils/snowflake.js';
|
import { generateSnowflake } from '../utils/snowflake.js';
|
||||||
import { connectionManager } from '../ws/handler.js';
|
import { connectionManager } from '../ws/handler.js';
|
||||||
import type {
|
import type {
|
||||||
User,
|
|
||||||
Friend,
|
Friend,
|
||||||
FriendRequest,
|
FriendRequest,
|
||||||
SendFriendRequest,
|
SendFriendRequest,
|
||||||
UpdateFriendRequest,
|
UpdateFriendRequest,
|
||||||
} from '@backspace/shared';
|
} from '@backspace/shared';
|
||||||
|
import { sanitizeUser } from '../utils/sanitize.js';
|
||||||
function sanitizeUser(row: typeof schema.users.$inferSelect): User {
|
|
||||||
return {
|
|
||||||
id: row.id,
|
|
||||||
username: row.username,
|
|
||||||
displayName: row.displayName,
|
|
||||||
avatar: row.avatar,
|
|
||||||
status: (row.status ?? 'offline') as User['status'],
|
|
||||||
customStatus: row.customStatus,
|
|
||||||
isAdmin: row.isAdmin === 1,
|
|
||||||
createdAt: row.createdAt,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function socialRoutes(app: FastifyInstance): Promise<void> {
|
export async function socialRoutes(app: FastifyInstance): Promise<void> {
|
||||||
// GET /api/social/friends - List all friends
|
// GET /api/social/friends - List all friends
|
||||||
|
|||||||
@@ -1,22 +1,10 @@
|
|||||||
import type { FastifyInstance } from 'fastify';
|
import type { FastifyInstance } from 'fastify';
|
||||||
import { eq } from 'drizzle-orm';
|
import { eq } from 'drizzle-orm';
|
||||||
import { getDb, schema } from '../db/index.js';
|
import { getDb, schema } from '../db/index.js';
|
||||||
import { authenticate } from '../utils/auth.js';
|
import { authenticate, verifyPassword } from '../utils/auth.js';
|
||||||
import { connectionManager } from '../ws/handler.js';
|
import { connectionManager } from '../ws/handler.js';
|
||||||
import type { User, UpdateUserRequest } from '@backspace/shared';
|
import type { UpdateUserRequest, VerifyPasswordRequest, VerifyPasswordResponse, ReplicatedInstance } from '@backspace/shared';
|
||||||
|
import { sanitizeUser } from '../utils/sanitize.js';
|
||||||
function sanitizeUser(row: typeof schema.users.$inferSelect): User {
|
|
||||||
return {
|
|
||||||
id: row.id,
|
|
||||||
username: row.username,
|
|
||||||
displayName: row.displayName,
|
|
||||||
avatar: row.avatar,
|
|
||||||
status: (row.status ?? 'offline') as User['status'],
|
|
||||||
customStatus: row.customStatus,
|
|
||||||
isAdmin: row.isAdmin === 1,
|
|
||||||
createdAt: row.createdAt,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function userRoutes(app: FastifyInstance): Promise<void> {
|
export async function userRoutes(app: FastifyInstance): Promise<void> {
|
||||||
app.get('/api/users/@me', { preHandler: authenticate }, async (request, reply) => {
|
app.get('/api/users/@me', { preHandler: authenticate }, async (request, reply) => {
|
||||||
@@ -30,8 +18,27 @@ export async function userRoutes(app: FastifyInstance): Promise<void> {
|
|||||||
return reply.code(200).send(sanitizeUser(user));
|
return reply.code(200).send(sanitizeUser(user));
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// POST /api/users/@me/verify-password — verify password matches current account
|
||||||
|
app.post<{ Body: VerifyPasswordRequest }>('/api/users/@me/verify-password', { preHandler: authenticate }, async (request, reply) => {
|
||||||
|
const { password } = request.body;
|
||||||
|
|
||||||
|
if (!password || typeof password !== 'string') {
|
||||||
|
return reply.code(400).send({ error: 'Password is required', statusCode: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const db = getDb();
|
||||||
|
const user = db.select().from(schema.users).where(eq(schema.users.id, request.userId)).get();
|
||||||
|
if (!user) {
|
||||||
|
return reply.code(404).send({ error: 'User not found', statusCode: 404 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const valid = await verifyPassword(password, user.passwordHash);
|
||||||
|
const response: VerifyPasswordResponse = { valid };
|
||||||
|
return reply.code(200).send(response);
|
||||||
|
});
|
||||||
|
|
||||||
app.patch<{ Body: UpdateUserRequest }>('/api/users/@me', { preHandler: authenticate }, async (request, reply) => {
|
app.patch<{ Body: UpdateUserRequest }>('/api/users/@me', { preHandler: authenticate }, async (request, reply) => {
|
||||||
const { displayName, avatar, customStatus, status } = request.body;
|
const { displayName, avatar, customStatus, status, replicatedInstances } = request.body;
|
||||||
const db = getDb();
|
const db = getDb();
|
||||||
|
|
||||||
const updateData: Record<string, string | null | undefined> = {};
|
const updateData: Record<string, string | null | undefined> = {};
|
||||||
@@ -71,6 +78,22 @@ export async function userRoutes(app: FastifyInstance): Promise<void> {
|
|||||||
updateData.status = status;
|
updateData.status = status;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (replicatedInstances !== undefined) {
|
||||||
|
if (!Array.isArray(replicatedInstances)) {
|
||||||
|
return reply.code(400).send({ error: 'replicatedInstances must be an array', statusCode: 400 });
|
||||||
|
}
|
||||||
|
// Validate each entry has domain and username strings
|
||||||
|
for (const inst of replicatedInstances) {
|
||||||
|
if (!inst || typeof inst.domain !== 'string' || typeof inst.username !== 'string') {
|
||||||
|
return reply.code(400).send({ error: 'Each replicated instance must have domain and username strings', statusCode: 400 });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (replicatedInstances.length > 50) {
|
||||||
|
return reply.code(400).send({ error: 'Maximum 50 replicated instances', statusCode: 400 });
|
||||||
|
}
|
||||||
|
updateData.replicatedInstances = JSON.stringify(replicatedInstances);
|
||||||
|
}
|
||||||
|
|
||||||
if (Object.keys(updateData).length === 0) {
|
if (Object.keys(updateData).length === 0) {
|
||||||
return reply.code(400).send({ error: 'No fields to update', statusCode: 400 });
|
return reply.code(400).send({ error: 'No fields to update', statusCode: 400 });
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,26 @@
|
|||||||
|
import type { User, ReplicatedInstance } from '@backspace/shared';
|
||||||
|
import { schema } from '../db/index.js';
|
||||||
|
|
||||||
|
export function sanitizeUser(row: typeof schema.users.$inferSelect): User {
|
||||||
|
let replicatedInstances: ReplicatedInstance[] = [];
|
||||||
|
if (row.replicatedInstances) {
|
||||||
|
try {
|
||||||
|
replicatedInstances = JSON.parse(row.replicatedInstances);
|
||||||
|
} catch {
|
||||||
|
replicatedInstances = [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: row.id,
|
||||||
|
username: row.username,
|
||||||
|
displayName: row.displayName,
|
||||||
|
avatar: row.avatar,
|
||||||
|
status: (row.status ?? 'offline') as User['status'],
|
||||||
|
customStatus: row.customStatus,
|
||||||
|
isAdmin: row.isAdmin === 1,
|
||||||
|
createdAt: row.createdAt,
|
||||||
|
homeInstance: row.homeInstance ?? null,
|
||||||
|
replicatedInstances,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -5,20 +5,8 @@ import { connectionManager } from './handler.js';
|
|||||||
import type { VoiceRoom, DmRoomMeta, ServerRoomMeta } from './handler.js';
|
import type { VoiceRoom, DmRoomMeta, ServerRoomMeta } from './handler.js';
|
||||||
import { isMember, getChannelServerId, isDmMember, hasPermission, PermissionBits } from '../utils/permissions.js';
|
import { isMember, getChannelServerId, isDmMember, hasPermission, PermissionBits } from '../utils/permissions.js';
|
||||||
import { broadcastDmMessage, getDmMessageWithUser } from '../routes/dm.js';
|
import { broadcastDmMessage, getDmMessageWithUser } from '../routes/dm.js';
|
||||||
import type { User, MessageWithUser, Attachment, DmMessageWithUser } from '@backspace/shared';
|
import type { MessageWithUser, Attachment, DmMessageWithUser } from '@backspace/shared';
|
||||||
|
import { sanitizeUser } from '../utils/sanitize.js';
|
||||||
function sanitizeUser(row: typeof schema.users.$inferSelect): User {
|
|
||||||
return {
|
|
||||||
id: row.id,
|
|
||||||
username: row.username,
|
|
||||||
displayName: row.displayName,
|
|
||||||
avatar: row.avatar,
|
|
||||||
status: (row.status ?? 'offline') as User['status'],
|
|
||||||
customStatus: row.customStatus,
|
|
||||||
isAdmin: row.isAdmin === 1,
|
|
||||||
createdAt: row.createdAt,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
function getMessageWithUser(messageId: string): MessageWithUser | null {
|
function getMessageWithUser(messageId: string): MessageWithUser | null {
|
||||||
const db = getDb();
|
const db = getDb();
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ import type {
|
|||||||
ReadState,
|
ReadState,
|
||||||
ActiveCallInfo,
|
ActiveCallInfo,
|
||||||
} from '@backspace/shared';
|
} from '@backspace/shared';
|
||||||
|
import { sanitizeUser } from '../utils/sanitize.js';
|
||||||
|
|
||||||
// SQLite's SQLITE_MAX_VARIABLE_NUMBER default is 999.
|
// SQLite's SQLITE_MAX_VARIABLE_NUMBER default is 999.
|
||||||
// Chunk inArray() calls to stay safely under this limit.
|
// Chunk inArray() calls to stay safely under this limit.
|
||||||
@@ -30,19 +31,6 @@ function batchInArray<TId, TResult>(ids: TId[], queryFn: (chunk: TId[]) => TResu
|
|||||||
return results;
|
return results;
|
||||||
}
|
}
|
||||||
|
|
||||||
function sanitizeUser(row: typeof schema.users.$inferSelect): User {
|
|
||||||
return {
|
|
||||||
id: row.id,
|
|
||||||
username: row.username,
|
|
||||||
displayName: row.displayName,
|
|
||||||
avatar: row.avatar,
|
|
||||||
status: (row.status ?? 'offline') as User['status'],
|
|
||||||
customStatus: row.customStatus,
|
|
||||||
isAdmin: row.isAdmin === 1,
|
|
||||||
createdAt: row.createdAt,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface AuthenticatedSocket {
|
export interface AuthenticatedSocket {
|
||||||
ws: WebSocket;
|
ws: WebSocket;
|
||||||
userId: string;
|
userId: string;
|
||||||
|
|||||||
@@ -9,6 +9,13 @@ export interface User {
|
|||||||
customStatus: string | null;
|
customStatus: string | null;
|
||||||
isAdmin: boolean;
|
isAdmin: boolean;
|
||||||
createdAt: number;
|
createdAt: number;
|
||||||
|
homeInstance: string | null;
|
||||||
|
replicatedInstances: ReplicatedInstance[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ReplicatedInstance {
|
||||||
|
domain: string;
|
||||||
|
username: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export type UserStatus = 'online' | 'idle' | 'dnd' | 'offline';
|
export type UserStatus = 'online' | 'idle' | 'dnd' | 'offline';
|
||||||
@@ -244,6 +251,7 @@ export interface RegisterRequest {
|
|||||||
username: string;
|
username: string;
|
||||||
password: string;
|
password: string;
|
||||||
displayName?: string;
|
displayName?: string;
|
||||||
|
homeInstance?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface LoginRequest {
|
export interface LoginRequest {
|
||||||
@@ -283,6 +291,7 @@ export interface UpdateUserRequest {
|
|||||||
avatar?: string;
|
avatar?: string;
|
||||||
customStatus?: string;
|
customStatus?: string;
|
||||||
status?: UserStatus;
|
status?: UserStatus;
|
||||||
|
replicatedInstances?: ReplicatedInstance[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface UpdateMemberRequest {
|
export interface UpdateMemberRequest {
|
||||||
@@ -379,3 +388,19 @@ export interface InstanceStreamingLimits {
|
|||||||
maxResolution: number;
|
maxResolution: number;
|
||||||
maxFramerate: number;
|
maxFramerate: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ─── Federation Types ──────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export interface InstanceInfoResponse {
|
||||||
|
name: string;
|
||||||
|
version: string;
|
||||||
|
registrationOpen: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface VerifyPasswordRequest {
|
||||||
|
password: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface VerifyPasswordResponse {
|
||||||
|
valid: boolean;
|
||||||
|
}
|
||||||
|
|||||||
@@ -61,6 +61,8 @@ const makeRequest = (overrides: Partial<FriendRequest> = {}): FriendRequest => (
|
|||||||
customStatus: null,
|
customStatus: null,
|
||||||
isAdmin: false,
|
isAdmin: false,
|
||||||
createdAt: Date.now(),
|
createdAt: Date.now(),
|
||||||
|
homeInstance: null,
|
||||||
|
replicatedInstances: [],
|
||||||
},
|
},
|
||||||
...overrides,
|
...overrides,
|
||||||
});
|
});
|
||||||
@@ -215,6 +217,8 @@ describe('FriendsPage', () => {
|
|||||||
customStatus: null,
|
customStatus: null,
|
||||||
isAdmin: false,
|
isAdmin: false,
|
||||||
createdAt: Date.now(),
|
createdAt: Date.now(),
|
||||||
|
homeInstance: null,
|
||||||
|
replicatedInstances: [],
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -260,6 +264,8 @@ describe('FriendsPage', () => {
|
|||||||
customStatus: null,
|
customStatus: null,
|
||||||
isAdmin: false,
|
isAdmin: false,
|
||||||
createdAt: Date.now(),
|
createdAt: Date.now(),
|
||||||
|
homeInstance: null,
|
||||||
|
replicatedInstances: [],
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -300,6 +306,8 @@ describe('FriendsPage', () => {
|
|||||||
customStatus: null,
|
customStatus: null,
|
||||||
isAdmin: false,
|
isAdmin: false,
|
||||||
createdAt: Date.now(),
|
createdAt: Date.now(),
|
||||||
|
homeInstance: null,
|
||||||
|
replicatedInstances: [],
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user