Imrpove chat with gifs, videos, music player, redesigns and improved filesharing errors
This commit is contained in:
43
server/dist/db.d.ts
vendored
43
server/dist/db.d.ts
vendored
@@ -1,43 +0,0 @@
|
||||
export declare function initDB(): Promise<void>;
|
||||
export interface AuthUser {
|
||||
id: string;
|
||||
username: string;
|
||||
passwordHash: string;
|
||||
displayName: string;
|
||||
createdAt: number;
|
||||
}
|
||||
export declare function getUserByUsername(username: string): Promise<AuthUser | null>;
|
||||
export declare function getUserById(id: string): Promise<AuthUser | null>;
|
||||
export declare function createUser(user: AuthUser): Promise<void>;
|
||||
export interface ServerInfo {
|
||||
id: string;
|
||||
name: string;
|
||||
description?: string;
|
||||
ownerId: string;
|
||||
ownerPublicKey: string;
|
||||
isPrivate: boolean;
|
||||
maxUsers: number;
|
||||
currentUsers: number;
|
||||
tags: string[];
|
||||
createdAt: number;
|
||||
lastSeen: number;
|
||||
}
|
||||
export declare function getAllPublicServers(): Promise<ServerInfo[]>;
|
||||
export declare function getServerById(id: string): Promise<ServerInfo | null>;
|
||||
export declare function upsertServer(server: ServerInfo): Promise<void>;
|
||||
export declare function deleteServer(id: string): Promise<void>;
|
||||
export interface JoinRequest {
|
||||
id: string;
|
||||
serverId: string;
|
||||
userId: string;
|
||||
userPublicKey: string;
|
||||
displayName: string;
|
||||
status: 'pending' | 'approved' | 'rejected';
|
||||
createdAt: number;
|
||||
}
|
||||
export declare function createJoinRequest(req: JoinRequest): Promise<void>;
|
||||
export declare function getJoinRequestById(id: string): Promise<JoinRequest | null>;
|
||||
export declare function getPendingRequestsForServer(serverId: string): Promise<JoinRequest[]>;
|
||||
export declare function updateJoinRequestStatus(id: string, status: JoinRequest['status']): Promise<void>;
|
||||
export declare function deleteStaleJoinRequests(maxAgeMs: number): Promise<void>;
|
||||
// # sourceMappingURL=db.d.ts.map
|
||||
1
server/dist/db.d.ts.map
vendored
1
server/dist/db.d.ts.map
vendored
@@ -1 +0,0 @@
|
||||
{"version":3,"file":"db.d.ts","sourceRoot":"","sources":["../src/db.ts"],"names":[],"mappings":"AAeA,wBAAsB,MAAM,IAAI,OAAO,CAAC,IAAI,CAAC,CAoD5C;AAaD,MAAM,WAAW,QAAQ;IACvB,EAAE,EAAE,MAAM,CAAC;IACX,QAAQ,EAAE,MAAM,CAAC;IACjB,YAAY,EAAE,MAAM,CAAC;IACrB,WAAW,EAAE,MAAM,CAAC;IACpB,SAAS,EAAE,MAAM,CAAC;CACnB;AAED,wBAAsB,iBAAiB,CAAC,QAAQ,EAAE,MAAM,GAAG,OAAO,CAAC,QAAQ,GAAG,IAAI,CAAC,CAiBlF;AAED,wBAAsB,WAAW,CAAC,EAAE,EAAE,MAAM,GAAG,OAAO,CAAC,QAAQ,GAAG,IAAI,CAAC,CAiBtE;AAED,wBAAsB,UAAU,CAAC,IAAI,EAAE,QAAQ,GAAG,OAAO,CAAC,IAAI,CAAC,CAO9D;AAMD,MAAM,WAAW,UAAU;IACzB,EAAE,EAAE,MAAM,CAAC;IACX,IAAI,EAAE,MAAM,CAAC;IACb,WAAW,CAAC,EAAE,MAAM,CAAC;IACrB,OAAO,EAAE,MAAM,CAAC;IAChB,cAAc,EAAE,MAAM,CAAC;IACvB,SAAS,EAAE,OAAO,CAAC;IACnB,QAAQ,EAAE,MAAM,CAAC;IACjB,YAAY,EAAE,MAAM,CAAC;IACrB,IAAI,EAAE,MAAM,EAAE,CAAC;IACf,SAAS,EAAE,MAAM,CAAC;IAClB,QAAQ,EAAE,MAAM,CAAC;CAClB;AAkBD,wBAAsB,mBAAmB,IAAI,OAAO,CAAC,UAAU,EAAE,CAAC,CASjE;AAED,wBAAsB,aAAa,CAAC,EAAE,EAAE,MAAM,GAAG,OAAO,CAAC,UAAU,GAAG,IAAI,CAAC,CAU1E;AAED,wBAAsB,YAAY,CAAC,MAAM,EAAE,UAAU,GAAG,OAAO,CAAC,IAAI,CAAC,CAsBpE;AAED,wBAAsB,YAAY,CAAC,EAAE,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC,CAY5D;AAMD,MAAM,WAAW,WAAW;IAC1B,EAAE,EAAE,MAAM,CAAC;IACX,QAAQ,EAAE,MAAM,CAAC;IACjB,MAAM,EAAE,MAAM,CAAC;IACf,aAAa,EAAE,MAAM,CAAC;IACtB,WAAW,EAAE,MAAM,CAAC;IACpB,MAAM,EAAE,SAAS,GAAG,UAAU,GAAG,UAAU,CAAC;IAC5C,SAAS,EAAE,MAAM,CAAC;CACnB;AAcD,wBAAsB,iBAAiB,CAAC,GAAG,EAAE,WAAW,GAAG,OAAO,CAAC,IAAI,CAAC,CAUvE;AAED,wBAAsB,kBAAkB,CAAC,EAAE,EAAE,MAAM,GAAG,OAAO,CAAC,WAAW,GAAG,IAAI,CAAC,CAUhF;AAED,wBAAsB,2BAA2B,CAAC,QAAQ,EAAE,MAAM,GAAG,OAAO,CAAC,WAAW,EAAE,CAAC,CAU1F;AAED,wBAAsB,uBAAuB,CAAC,EAAE,EAAE,MAAM,EAAE,MAAM,EAAE,WAAW,CAAC,QAAQ,CAAC,GAAG,OAAO,CAAC,IAAI,CAAC,CAOtG;AAED,wBAAsB,uBAAuB,CAAC,QAAQ,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC,CAQ7E"}
|
||||
277
server/dist/db.js
vendored
277
server/dist/db.js
vendored
@@ -1,277 +0,0 @@
|
||||
"use strict";
|
||||
var __importDefault = (this && this.__importDefault) || function (mod) {
|
||||
return (mod && mod.__esModule) ? mod : { "default": mod };
|
||||
};
|
||||
Object.defineProperty(exports, "__esModule", { value: true });
|
||||
exports.initDB = initDB;
|
||||
exports.getUserByUsername = getUserByUsername;
|
||||
exports.getUserById = getUserById;
|
||||
exports.createUser = createUser;
|
||||
exports.getAllPublicServers = getAllPublicServers;
|
||||
exports.getServerById = getServerById;
|
||||
exports.upsertServer = upsertServer;
|
||||
exports.deleteServer = deleteServer;
|
||||
exports.createJoinRequest = createJoinRequest;
|
||||
exports.getJoinRequestById = getJoinRequestById;
|
||||
exports.getPendingRequestsForServer = getPendingRequestsForServer;
|
||||
exports.updateJoinRequestStatus = updateJoinRequestStatus;
|
||||
exports.deleteStaleJoinRequests = deleteStaleJoinRequests;
|
||||
const fs_1 = __importDefault(require("fs"));
|
||||
const path_1 = __importDefault(require("path"));
|
||||
const sql_js_1 = __importDefault(require("sql.js"));
|
||||
// Simple SQLite via sql.js persisted to a single file
|
||||
const DATA_DIR = path_1.default.join(process.cwd(), 'data');
|
||||
const DB_FILE = path_1.default.join(DATA_DIR, 'metoyou.sqlite');
|
||||
function ensureDataDir() {
|
||||
if (!fs_1.default.existsSync(DATA_DIR))
|
||||
fs_1.default.mkdirSync(DATA_DIR, { recursive: true });
|
||||
}
|
||||
let SQL = null;
|
||||
let db = null;
|
||||
async function initDB() {
|
||||
if (db)
|
||||
return;
|
||||
SQL = await (0, sql_js_1.default)({ locateFile: (file) => require.resolve('sql.js/dist/sql-wasm.wasm') });
|
||||
ensureDataDir();
|
||||
if (fs_1.default.existsSync(DB_FILE)) {
|
||||
const fileBuffer = fs_1.default.readFileSync(DB_FILE);
|
||||
db = new SQL.Database(new Uint8Array(fileBuffer));
|
||||
}
|
||||
else {
|
||||
db = new SQL.Database();
|
||||
}
|
||||
// Initialize schema
|
||||
db.run(`
|
||||
CREATE TABLE IF NOT EXISTS users (
|
||||
id TEXT PRIMARY KEY,
|
||||
username TEXT UNIQUE NOT NULL,
|
||||
passwordHash TEXT NOT NULL,
|
||||
displayName TEXT NOT NULL,
|
||||
createdAt INTEGER NOT NULL
|
||||
);
|
||||
`);
|
||||
db.run(`
|
||||
CREATE TABLE IF NOT EXISTS servers (
|
||||
id TEXT PRIMARY KEY,
|
||||
name TEXT NOT NULL,
|
||||
description TEXT,
|
||||
ownerId TEXT NOT NULL,
|
||||
ownerPublicKey TEXT NOT NULL,
|
||||
isPrivate INTEGER NOT NULL DEFAULT 0,
|
||||
maxUsers INTEGER NOT NULL DEFAULT 0,
|
||||
currentUsers INTEGER NOT NULL DEFAULT 0,
|
||||
tags TEXT NOT NULL DEFAULT '[]',
|
||||
createdAt INTEGER NOT NULL,
|
||||
lastSeen INTEGER NOT NULL
|
||||
);
|
||||
`);
|
||||
db.run(`
|
||||
CREATE TABLE IF NOT EXISTS join_requests (
|
||||
id TEXT PRIMARY KEY,
|
||||
serverId TEXT NOT NULL,
|
||||
userId TEXT NOT NULL,
|
||||
userPublicKey TEXT NOT NULL,
|
||||
displayName TEXT NOT NULL,
|
||||
status TEXT NOT NULL DEFAULT 'pending',
|
||||
createdAt INTEGER NOT NULL
|
||||
);
|
||||
`);
|
||||
persist();
|
||||
}
|
||||
function persist() {
|
||||
if (!db)
|
||||
return;
|
||||
const data = db.export();
|
||||
const buffer = Buffer.from(data);
|
||||
fs_1.default.writeFileSync(DB_FILE, buffer);
|
||||
}
|
||||
async function getUserByUsername(username) {
|
||||
if (!db)
|
||||
await initDB();
|
||||
const stmt = db.prepare('SELECT id, username, passwordHash, displayName, createdAt FROM users WHERE username = ? LIMIT 1');
|
||||
stmt.bind([username]);
|
||||
let row = null;
|
||||
if (stmt.step()) {
|
||||
const r = stmt.getAsObject();
|
||||
row = {
|
||||
id: String(r.id),
|
||||
username: String(r.username),
|
||||
passwordHash: String(r.passwordHash),
|
||||
displayName: String(r.displayName),
|
||||
createdAt: Number(r.createdAt),
|
||||
};
|
||||
}
|
||||
stmt.free();
|
||||
return row;
|
||||
}
|
||||
async function getUserById(id) {
|
||||
if (!db)
|
||||
await initDB();
|
||||
const stmt = db.prepare('SELECT id, username, passwordHash, displayName, createdAt FROM users WHERE id = ? LIMIT 1');
|
||||
stmt.bind([id]);
|
||||
let row = null;
|
||||
if (stmt.step()) {
|
||||
const r = stmt.getAsObject();
|
||||
row = {
|
||||
id: String(r.id),
|
||||
username: String(r.username),
|
||||
passwordHash: String(r.passwordHash),
|
||||
displayName: String(r.displayName),
|
||||
createdAt: Number(r.createdAt),
|
||||
};
|
||||
}
|
||||
stmt.free();
|
||||
return row;
|
||||
}
|
||||
async function createUser(user) {
|
||||
if (!db)
|
||||
await initDB();
|
||||
const stmt = db.prepare('INSERT INTO users (id, username, passwordHash, displayName, createdAt) VALUES (?, ?, ?, ?, ?)');
|
||||
stmt.bind([user.id, user.username, user.passwordHash, user.displayName, user.createdAt]);
|
||||
stmt.step();
|
||||
stmt.free();
|
||||
persist();
|
||||
}
|
||||
function rowToServer(r) {
|
||||
return {
|
||||
id: String(r.id),
|
||||
name: String(r.name),
|
||||
description: r.description ? String(r.description) : undefined,
|
||||
ownerId: String(r.ownerId),
|
||||
ownerPublicKey: String(r.ownerPublicKey),
|
||||
isPrivate: !!r.isPrivate,
|
||||
maxUsers: Number(r.maxUsers),
|
||||
currentUsers: Number(r.currentUsers),
|
||||
tags: JSON.parse(String(r.tags || '[]')),
|
||||
createdAt: Number(r.createdAt),
|
||||
lastSeen: Number(r.lastSeen),
|
||||
};
|
||||
}
|
||||
async function getAllPublicServers() {
|
||||
if (!db)
|
||||
await initDB();
|
||||
const stmt = db.prepare('SELECT * FROM servers WHERE isPrivate = 0');
|
||||
const results = [];
|
||||
while (stmt.step()) {
|
||||
results.push(rowToServer(stmt.getAsObject()));
|
||||
}
|
||||
stmt.free();
|
||||
return results;
|
||||
}
|
||||
async function getServerById(id) {
|
||||
if (!db)
|
||||
await initDB();
|
||||
const stmt = db.prepare('SELECT * FROM servers WHERE id = ? LIMIT 1');
|
||||
stmt.bind([id]);
|
||||
let row = null;
|
||||
if (stmt.step()) {
|
||||
row = rowToServer(stmt.getAsObject());
|
||||
}
|
||||
stmt.free();
|
||||
return row;
|
||||
}
|
||||
async function upsertServer(server) {
|
||||
if (!db)
|
||||
await initDB();
|
||||
const stmt = db.prepare(`
|
||||
INSERT OR REPLACE INTO servers (id, name, description, ownerId, ownerPublicKey, isPrivate, maxUsers, currentUsers, tags, createdAt, lastSeen)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
`);
|
||||
stmt.bind([
|
||||
server.id,
|
||||
server.name,
|
||||
server.description ?? null,
|
||||
server.ownerId,
|
||||
server.ownerPublicKey,
|
||||
server.isPrivate ? 1 : 0,
|
||||
server.maxUsers,
|
||||
server.currentUsers,
|
||||
JSON.stringify(server.tags),
|
||||
server.createdAt,
|
||||
server.lastSeen,
|
||||
]);
|
||||
stmt.step();
|
||||
stmt.free();
|
||||
persist();
|
||||
}
|
||||
async function deleteServer(id) {
|
||||
if (!db)
|
||||
await initDB();
|
||||
const stmt = db.prepare('DELETE FROM servers WHERE id = ?');
|
||||
stmt.bind([id]);
|
||||
stmt.step();
|
||||
stmt.free();
|
||||
// Also clean up related join requests
|
||||
const jStmt = db.prepare('DELETE FROM join_requests WHERE serverId = ?');
|
||||
jStmt.bind([id]);
|
||||
jStmt.step();
|
||||
jStmt.free();
|
||||
persist();
|
||||
}
|
||||
function rowToJoinRequest(r) {
|
||||
return {
|
||||
id: String(r.id),
|
||||
serverId: String(r.serverId),
|
||||
userId: String(r.userId),
|
||||
userPublicKey: String(r.userPublicKey),
|
||||
displayName: String(r.displayName),
|
||||
status: String(r.status),
|
||||
createdAt: Number(r.createdAt),
|
||||
};
|
||||
}
|
||||
async function createJoinRequest(req) {
|
||||
if (!db)
|
||||
await initDB();
|
||||
const stmt = db.prepare(`
|
||||
INSERT INTO join_requests (id, serverId, userId, userPublicKey, displayName, status, createdAt)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?)
|
||||
`);
|
||||
stmt.bind([req.id, req.serverId, req.userId, req.userPublicKey, req.displayName, req.status, req.createdAt]);
|
||||
stmt.step();
|
||||
stmt.free();
|
||||
persist();
|
||||
}
|
||||
async function getJoinRequestById(id) {
|
||||
if (!db)
|
||||
await initDB();
|
||||
const stmt = db.prepare('SELECT * FROM join_requests WHERE id = ? LIMIT 1');
|
||||
stmt.bind([id]);
|
||||
let row = null;
|
||||
if (stmt.step()) {
|
||||
row = rowToJoinRequest(stmt.getAsObject());
|
||||
}
|
||||
stmt.free();
|
||||
return row;
|
||||
}
|
||||
async function getPendingRequestsForServer(serverId) {
|
||||
if (!db)
|
||||
await initDB();
|
||||
const stmt = db.prepare('SELECT * FROM join_requests WHERE serverId = ? AND status = ?');
|
||||
stmt.bind([serverId, 'pending']);
|
||||
const results = [];
|
||||
while (stmt.step()) {
|
||||
results.push(rowToJoinRequest(stmt.getAsObject()));
|
||||
}
|
||||
stmt.free();
|
||||
return results;
|
||||
}
|
||||
async function updateJoinRequestStatus(id, status) {
|
||||
if (!db)
|
||||
await initDB();
|
||||
const stmt = db.prepare('UPDATE join_requests SET status = ? WHERE id = ?');
|
||||
stmt.bind([status, id]);
|
||||
stmt.step();
|
||||
stmt.free();
|
||||
persist();
|
||||
}
|
||||
async function deleteStaleJoinRequests(maxAgeMs) {
|
||||
if (!db)
|
||||
await initDB();
|
||||
const cutoff = Date.now() - maxAgeMs;
|
||||
const stmt = db.prepare('DELETE FROM join_requests WHERE createdAt < ?');
|
||||
stmt.bind([cutoff]);
|
||||
stmt.step();
|
||||
stmt.free();
|
||||
persist();
|
||||
}
|
||||
//# sourceMappingURL=db.js.map
|
||||
1
server/dist/db.js.map
vendored
1
server/dist/db.js.map
vendored
File diff suppressed because one or more lines are too long
2
server/dist/index.d.ts
vendored
2
server/dist/index.d.ts
vendored
@@ -1,2 +0,0 @@
|
||||
export {};
|
||||
// # sourceMappingURL=index.d.ts.map
|
||||
1
server/dist/index.d.ts.map
vendored
1
server/dist/index.d.ts.map
vendored
@@ -1 +0,0 @@
|
||||
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":""}
|
||||
438
server/dist/index.js
vendored
438
server/dist/index.js
vendored
@@ -1,438 +0,0 @@
|
||||
"use strict";
|
||||
var __importDefault = (this && this.__importDefault) || function (mod) {
|
||||
return (mod && mod.__esModule) ? mod : { "default": mod };
|
||||
};
|
||||
Object.defineProperty(exports, "__esModule", { value: true });
|
||||
const express_1 = __importDefault(require("express"));
|
||||
const cors_1 = __importDefault(require("cors"));
|
||||
const http_1 = require("http");
|
||||
const ws_1 = require("ws");
|
||||
const uuid_1 = require("uuid");
|
||||
const app = (0, express_1.default)();
|
||||
const PORT = process.env.PORT || 3001;
|
||||
app.use((0, cors_1.default)());
|
||||
app.use(express_1.default.json());
|
||||
const connectedUsers = new Map();
|
||||
// Database
|
||||
const crypto_1 = __importDefault(require("crypto"));
|
||||
const db_1 = require("./db");
|
||||
function hashPassword(pw) { return crypto_1.default.createHash('sha256').update(pw).digest('hex'); }
|
||||
// REST API Routes
|
||||
// Health check endpoint
|
||||
app.get('/api/health', async (req, res) => {
|
||||
const allServers = await (0, db_1.getAllPublicServers)();
|
||||
res.json({
|
||||
status: 'ok',
|
||||
timestamp: Date.now(),
|
||||
serverCount: allServers.length,
|
||||
connectedUsers: connectedUsers.size,
|
||||
});
|
||||
});
|
||||
// Time endpoint for clock synchronization
|
||||
app.get('/api/time', (req, res) => {
|
||||
res.json({ now: Date.now() });
|
||||
});
|
||||
// Image proxy to allow rendering external images within CSP (img-src 'self' data: blob:)
|
||||
app.get('/api/image-proxy', async (req, res) => {
|
||||
try {
|
||||
const url = String(req.query.url || '');
|
||||
if (!/^https?:\/\//i.test(url)) {
|
||||
return res.status(400).json({ error: 'Invalid URL' });
|
||||
}
|
||||
const controller = new AbortController();
|
||||
const timeout = setTimeout(() => controller.abort(), 8000);
|
||||
const response = await fetch(url, { redirect: 'follow', signal: controller.signal });
|
||||
clearTimeout(timeout);
|
||||
if (!response.ok) {
|
||||
return res.status(response.status).end();
|
||||
}
|
||||
const contentType = response.headers.get('content-type') || '';
|
||||
if (!contentType.toLowerCase().startsWith('image/')) {
|
||||
return res.status(415).json({ error: 'Unsupported content type' });
|
||||
}
|
||||
const arrayBuffer = await response.arrayBuffer();
|
||||
const MAX_BYTES = 8 * 1024 * 1024; // 8MB limit
|
||||
if (arrayBuffer.byteLength > MAX_BYTES) {
|
||||
return res.status(413).json({ error: 'Image too large' });
|
||||
}
|
||||
res.setHeader('Content-Type', contentType);
|
||||
res.setHeader('Cache-Control', 'public, max-age=3600');
|
||||
res.send(Buffer.from(arrayBuffer));
|
||||
}
|
||||
catch (err) {
|
||||
if (err?.name === 'AbortError') {
|
||||
return res.status(504).json({ error: 'Timeout fetching image' });
|
||||
}
|
||||
console.error('Image proxy error:', err);
|
||||
res.status(502).json({ error: 'Failed to fetch image' });
|
||||
}
|
||||
});
|
||||
// Auth
|
||||
app.post('/api/users/register', async (req, res) => {
|
||||
const { username, password, displayName } = req.body;
|
||||
if (!username || !password)
|
||||
return res.status(400).json({ error: 'Missing username/password' });
|
||||
const exists = await (0, db_1.getUserByUsername)(username);
|
||||
if (exists)
|
||||
return res.status(409).json({ error: 'Username taken' });
|
||||
const user = { id: (0, uuid_1.v4)(), username, passwordHash: hashPassword(password), displayName: displayName || username, createdAt: Date.now() };
|
||||
await (0, db_1.createUser)(user);
|
||||
res.status(201).json({ id: user.id, username: user.username, displayName: user.displayName });
|
||||
});
|
||||
app.post('/api/users/login', async (req, res) => {
|
||||
const { username, password } = req.body;
|
||||
const user = await (0, db_1.getUserByUsername)(username);
|
||||
if (!user || user.passwordHash !== hashPassword(password))
|
||||
return res.status(401).json({ error: 'Invalid credentials' });
|
||||
res.json({ id: user.id, username: user.username, displayName: user.displayName });
|
||||
});
|
||||
// Search servers
|
||||
app.get('/api/servers', async (req, res) => {
|
||||
const { q, tags, limit = 20, offset = 0 } = req.query;
|
||||
let results = await (0, db_1.getAllPublicServers)();
|
||||
results = results
|
||||
.filter(s => {
|
||||
if (q) {
|
||||
const query = String(q).toLowerCase();
|
||||
return s.name.toLowerCase().includes(query) ||
|
||||
s.description?.toLowerCase().includes(query);
|
||||
}
|
||||
return true;
|
||||
})
|
||||
.filter(s => {
|
||||
if (tags) {
|
||||
const tagList = String(tags).split(',');
|
||||
return tagList.some(t => s.tags.includes(t));
|
||||
}
|
||||
return true;
|
||||
});
|
||||
const total = results.length;
|
||||
results = results.slice(Number(offset), Number(offset) + Number(limit));
|
||||
res.json({ servers: results, total, limit: Number(limit), offset: Number(offset) });
|
||||
});
|
||||
// Register a server
|
||||
app.post('/api/servers', async (req, res) => {
|
||||
const { id: clientId, name, description, ownerId, ownerPublicKey, isPrivate, maxUsers, tags } = req.body;
|
||||
if (!name || !ownerId || !ownerPublicKey) {
|
||||
return res.status(400).json({ error: 'Missing required fields' });
|
||||
}
|
||||
const id = clientId || (0, uuid_1.v4)();
|
||||
const server = {
|
||||
id,
|
||||
name,
|
||||
description,
|
||||
ownerId,
|
||||
ownerPublicKey,
|
||||
isPrivate: isPrivate ?? false,
|
||||
maxUsers: maxUsers ?? 0,
|
||||
currentUsers: 0,
|
||||
tags: tags ?? [],
|
||||
createdAt: Date.now(),
|
||||
lastSeen: Date.now(),
|
||||
};
|
||||
await (0, db_1.upsertServer)(server);
|
||||
res.status(201).json(server);
|
||||
});
|
||||
// Update server
|
||||
app.put('/api/servers/:id', async (req, res) => {
|
||||
const { id } = req.params;
|
||||
const { ownerId, ...updates } = req.body;
|
||||
const server = await (0, db_1.getServerById)(id);
|
||||
if (!server) {
|
||||
return res.status(404).json({ error: 'Server not found' });
|
||||
}
|
||||
if (server.ownerId !== ownerId) {
|
||||
return res.status(403).json({ error: 'Not authorized' });
|
||||
}
|
||||
const updated = { ...server, ...updates, lastSeen: Date.now() };
|
||||
await (0, db_1.upsertServer)(updated);
|
||||
res.json(updated);
|
||||
});
|
||||
// Heartbeat - keep server alive
|
||||
app.post('/api/servers/:id/heartbeat', async (req, res) => {
|
||||
const { id } = req.params;
|
||||
const { currentUsers } = req.body;
|
||||
const server = await (0, db_1.getServerById)(id);
|
||||
if (!server) {
|
||||
return res.status(404).json({ error: 'Server not found' });
|
||||
}
|
||||
server.lastSeen = Date.now();
|
||||
if (typeof currentUsers === 'number') {
|
||||
server.currentUsers = currentUsers;
|
||||
}
|
||||
await (0, db_1.upsertServer)(server);
|
||||
res.json({ ok: true });
|
||||
});
|
||||
// Remove server
|
||||
app.delete('/api/servers/:id', async (req, res) => {
|
||||
const { id } = req.params;
|
||||
const { ownerId } = req.body;
|
||||
const server = await (0, db_1.getServerById)(id);
|
||||
if (!server) {
|
||||
return res.status(404).json({ error: 'Server not found' });
|
||||
}
|
||||
if (server.ownerId !== ownerId) {
|
||||
return res.status(403).json({ error: 'Not authorized' });
|
||||
}
|
||||
await (0, db_1.deleteServer)(id);
|
||||
res.json({ ok: true });
|
||||
});
|
||||
// Request to join a server
|
||||
app.post('/api/servers/:id/join', async (req, res) => {
|
||||
const { id: serverId } = req.params;
|
||||
const { userId, userPublicKey, displayName } = req.body;
|
||||
const server = await (0, db_1.getServerById)(serverId);
|
||||
if (!server) {
|
||||
return res.status(404).json({ error: 'Server not found' });
|
||||
}
|
||||
const requestId = (0, uuid_1.v4)();
|
||||
const request = {
|
||||
id: requestId,
|
||||
serverId,
|
||||
userId,
|
||||
userPublicKey,
|
||||
displayName,
|
||||
status: server.isPrivate ? 'pending' : 'approved',
|
||||
createdAt: Date.now(),
|
||||
};
|
||||
await (0, db_1.createJoinRequest)(request);
|
||||
// Notify server owner via WebSocket
|
||||
if (server.isPrivate) {
|
||||
notifyServerOwner(server.ownerId, {
|
||||
type: 'join_request',
|
||||
request,
|
||||
});
|
||||
}
|
||||
res.status(201).json(request);
|
||||
});
|
||||
// Get join requests for a server
|
||||
app.get('/api/servers/:id/requests', async (req, res) => {
|
||||
const { id: serverId } = req.params;
|
||||
const { ownerId } = req.query;
|
||||
const server = await (0, db_1.getServerById)(serverId);
|
||||
if (!server) {
|
||||
return res.status(404).json({ error: 'Server not found' });
|
||||
}
|
||||
if (server.ownerId !== ownerId) {
|
||||
return res.status(403).json({ error: 'Not authorized' });
|
||||
}
|
||||
const requests = await (0, db_1.getPendingRequestsForServer)(serverId);
|
||||
res.json({ requests });
|
||||
});
|
||||
// Approve/reject join request
|
||||
app.put('/api/requests/:id', async (req, res) => {
|
||||
const { id } = req.params;
|
||||
const { ownerId, status } = req.body;
|
||||
const request = await (0, db_1.getJoinRequestById)(id);
|
||||
if (!request) {
|
||||
return res.status(404).json({ error: 'Request not found' });
|
||||
}
|
||||
const server = await (0, db_1.getServerById)(request.serverId);
|
||||
if (!server || server.ownerId !== ownerId) {
|
||||
return res.status(403).json({ error: 'Not authorized' });
|
||||
}
|
||||
await (0, db_1.updateJoinRequestStatus)(id, status);
|
||||
const updated = { ...request, status };
|
||||
// Notify the requester
|
||||
notifyUser(request.userId, {
|
||||
type: 'request_update',
|
||||
request: updated,
|
||||
});
|
||||
res.json(updated);
|
||||
});
|
||||
// WebSocket Server for real-time signaling
|
||||
const server = (0, http_1.createServer)(app);
|
||||
const wss = new ws_1.WebSocketServer({ server });
|
||||
wss.on('connection', (ws) => {
|
||||
const connectionId = (0, uuid_1.v4)();
|
||||
connectedUsers.set(connectionId, { oderId: connectionId, ws, serverIds: new Set() });
|
||||
ws.on('message', (data) => {
|
||||
try {
|
||||
const message = JSON.parse(data.toString());
|
||||
handleWebSocketMessage(connectionId, message);
|
||||
}
|
||||
catch (err) {
|
||||
console.error('Invalid WebSocket message:', err);
|
||||
}
|
||||
});
|
||||
ws.on('close', () => {
|
||||
const user = connectedUsers.get(connectionId);
|
||||
if (user) {
|
||||
// Notify all servers the user was a member of
|
||||
user.serverIds.forEach((sid) => {
|
||||
broadcastToServer(sid, {
|
||||
type: 'user_left',
|
||||
oderId: user.oderId,
|
||||
displayName: user.displayName,
|
||||
serverId: sid,
|
||||
}, user.oderId);
|
||||
});
|
||||
}
|
||||
connectedUsers.delete(connectionId);
|
||||
});
|
||||
// Send connection acknowledgment with the connectionId (client will identify with their actual oderId)
|
||||
ws.send(JSON.stringify({ type: 'connected', connectionId, serverTime: Date.now() }));
|
||||
});
|
||||
function handleWebSocketMessage(connectionId, message) {
|
||||
const user = connectedUsers.get(connectionId);
|
||||
if (!user)
|
||||
return;
|
||||
switch (message.type) {
|
||||
case 'identify':
|
||||
// User identifies themselves with their permanent ID
|
||||
// Store their actual oderId for peer-to-peer routing
|
||||
user.oderId = message.oderId || connectionId;
|
||||
user.displayName = message.displayName || 'Anonymous';
|
||||
connectedUsers.set(connectionId, user);
|
||||
console.log(`User identified: ${user.displayName} (${user.oderId})`);
|
||||
break;
|
||||
case 'join_server': {
|
||||
const sid = message.serverId;
|
||||
const isNew = !user.serverIds.has(sid);
|
||||
user.serverIds.add(sid);
|
||||
user.viewedServerId = sid;
|
||||
connectedUsers.set(connectionId, user);
|
||||
console.log(`User ${user.displayName || 'Anonymous'} (${user.oderId}) joined server ${sid} (new=${isNew})`);
|
||||
// Always send the current user list for this server
|
||||
const usersInServer = Array.from(connectedUsers.values())
|
||||
.filter(u => u.serverIds.has(sid) && u.oderId !== user.oderId && u.displayName)
|
||||
.map(u => ({ oderId: u.oderId, displayName: u.displayName || 'Anonymous' }));
|
||||
console.log(`Sending server_users to ${user.displayName || 'Anonymous'}:`, usersInServer);
|
||||
user.ws.send(JSON.stringify({
|
||||
type: 'server_users',
|
||||
serverId: sid,
|
||||
users: usersInServer,
|
||||
}));
|
||||
// Only broadcast user_joined if this is a brand-new join (not a re-view)
|
||||
if (isNew) {
|
||||
broadcastToServer(sid, {
|
||||
type: 'user_joined',
|
||||
oderId: user.oderId,
|
||||
displayName: user.displayName || 'Anonymous',
|
||||
serverId: sid,
|
||||
}, user.oderId);
|
||||
}
|
||||
break;
|
||||
}
|
||||
case 'view_server': {
|
||||
// Just switch the viewed server without joining/leaving
|
||||
const viewSid = message.serverId;
|
||||
user.viewedServerId = viewSid;
|
||||
connectedUsers.set(connectionId, user);
|
||||
console.log(`User ${user.displayName || 'Anonymous'} (${user.oderId}) viewing server ${viewSid}`);
|
||||
// Send current user list for the viewed server
|
||||
const viewUsers = Array.from(connectedUsers.values())
|
||||
.filter(u => u.serverIds.has(viewSid) && u.oderId !== user.oderId && u.displayName)
|
||||
.map(u => ({ oderId: u.oderId, displayName: u.displayName || 'Anonymous' }));
|
||||
user.ws.send(JSON.stringify({
|
||||
type: 'server_users',
|
||||
serverId: viewSid,
|
||||
users: viewUsers,
|
||||
}));
|
||||
break;
|
||||
}
|
||||
case 'leave_server': {
|
||||
const leaveSid = message.serverId || user.viewedServerId;
|
||||
if (leaveSid) {
|
||||
user.serverIds.delete(leaveSid);
|
||||
if (user.viewedServerId === leaveSid) {
|
||||
user.viewedServerId = undefined;
|
||||
}
|
||||
connectedUsers.set(connectionId, user);
|
||||
broadcastToServer(leaveSid, {
|
||||
type: 'user_left',
|
||||
oderId: user.oderId,
|
||||
displayName: user.displayName || 'Anonymous',
|
||||
serverId: leaveSid,
|
||||
}, user.oderId);
|
||||
}
|
||||
break;
|
||||
}
|
||||
case 'offer':
|
||||
case 'answer':
|
||||
case 'ice_candidate':
|
||||
// Forward signaling messages to specific peer
|
||||
console.log(`Forwarding ${message.type} from ${user.oderId} to ${message.targetUserId}`);
|
||||
const targetUser = findUserByUserId(message.targetUserId);
|
||||
if (targetUser) {
|
||||
targetUser.ws.send(JSON.stringify({
|
||||
...message,
|
||||
fromUserId: user.oderId,
|
||||
}));
|
||||
console.log(`Successfully forwarded ${message.type} to ${message.targetUserId}`);
|
||||
}
|
||||
else {
|
||||
console.log(`Target user ${message.targetUserId} not found. Connected users:`, Array.from(connectedUsers.values()).map(u => ({ oderId: u.oderId, displayName: u.displayName })));
|
||||
}
|
||||
break;
|
||||
case 'chat_message': {
|
||||
// Broadcast chat message to all users in the server
|
||||
const chatSid = message.serverId || user.viewedServerId;
|
||||
if (chatSid && user.serverIds.has(chatSid)) {
|
||||
broadcastToServer(chatSid, {
|
||||
type: 'chat_message',
|
||||
serverId: chatSid,
|
||||
message: message.message,
|
||||
senderId: user.oderId,
|
||||
senderName: user.displayName,
|
||||
timestamp: Date.now(),
|
||||
});
|
||||
}
|
||||
break;
|
||||
}
|
||||
case 'typing': {
|
||||
// Broadcast typing indicator
|
||||
const typingSid = message.serverId || user.viewedServerId;
|
||||
if (typingSid && user.serverIds.has(typingSid)) {
|
||||
broadcastToServer(typingSid, {
|
||||
type: 'user_typing',
|
||||
serverId: typingSid,
|
||||
oderId: user.oderId,
|
||||
displayName: user.displayName,
|
||||
}, user.oderId);
|
||||
}
|
||||
break;
|
||||
}
|
||||
default:
|
||||
console.log('Unknown message type:', message.type);
|
||||
}
|
||||
}
|
||||
function broadcastToServer(serverId, message, excludeOderId) {
|
||||
console.log(`Broadcasting to server ${serverId}, excluding ${excludeOderId}:`, message.type);
|
||||
connectedUsers.forEach((user) => {
|
||||
if (user.serverIds.has(serverId) && user.oderId !== excludeOderId) {
|
||||
console.log(` -> Sending to ${user.displayName} (${user.oderId})`);
|
||||
user.ws.send(JSON.stringify(message));
|
||||
}
|
||||
});
|
||||
}
|
||||
function notifyServerOwner(ownerId, message) {
|
||||
const owner = findUserByUserId(ownerId);
|
||||
if (owner) {
|
||||
owner.ws.send(JSON.stringify(message));
|
||||
}
|
||||
}
|
||||
function notifyUser(oderId, message) {
|
||||
const user = findUserByUserId(oderId);
|
||||
if (user) {
|
||||
user.ws.send(JSON.stringify(message));
|
||||
}
|
||||
}
|
||||
function findUserByUserId(oderId) {
|
||||
return Array.from(connectedUsers.values()).find(u => u.oderId === oderId);
|
||||
}
|
||||
// Cleanup stale join requests periodically (older than 24 h)
|
||||
setInterval(() => {
|
||||
(0, db_1.deleteStaleJoinRequests)(24 * 60 * 60 * 1000).catch(err => console.error('Failed to clean up stale join requests:', err));
|
||||
}, 60 * 1000);
|
||||
(0, db_1.initDB)().then(() => {
|
||||
server.listen(PORT, () => {
|
||||
console.log(`🚀 MetoYou signaling server running on port ${PORT}`);
|
||||
console.log(` REST API: http://localhost:${PORT}/api`);
|
||||
console.log(` WebSocket: ws://localhost:${PORT}`);
|
||||
});
|
||||
}).catch((err) => {
|
||||
console.error('Failed to initialize database:', err);
|
||||
process.exit(1);
|
||||
});
|
||||
//# sourceMappingURL=index.js.map
|
||||
1
server/dist/index.js.map
vendored
1
server/dist/index.js.map
vendored
File diff suppressed because one or more lines are too long
72
server/src/config/variables.ts
Normal file
72
server/src/config/variables.ts
Normal file
@@ -0,0 +1,72 @@
|
||||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
|
||||
export interface ServerVariablesConfig {
|
||||
klipyApiKey: string;
|
||||
}
|
||||
|
||||
const DATA_DIR = path.join(process.cwd(), 'data');
|
||||
const VARIABLES_FILE = path.join(DATA_DIR, 'variables.json');
|
||||
|
||||
function normalizeKlipyApiKey(value: unknown): string {
|
||||
return typeof value === 'string' ? value.trim() : '';
|
||||
}
|
||||
|
||||
function readRawVariables(): { rawContents: string; parsed: Record<string, unknown> } {
|
||||
if (!fs.existsSync(VARIABLES_FILE)) {
|
||||
return { rawContents: '', parsed: {} };
|
||||
}
|
||||
|
||||
const rawContents = fs.readFileSync(VARIABLES_FILE, 'utf8');
|
||||
|
||||
if (!rawContents.trim()) {
|
||||
return { rawContents, parsed: {} };
|
||||
}
|
||||
|
||||
try {
|
||||
const parsed = JSON.parse(rawContents) as unknown;
|
||||
|
||||
if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) {
|
||||
return { rawContents, parsed: parsed as Record<string, unknown> };
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn('[Config] Failed to parse variables.json. Recreating it with defaults.', error);
|
||||
}
|
||||
|
||||
return { rawContents, parsed: {} };
|
||||
}
|
||||
|
||||
export function getVariablesConfigPath(): string {
|
||||
return VARIABLES_FILE;
|
||||
}
|
||||
|
||||
export function ensureVariablesConfig(): ServerVariablesConfig {
|
||||
if (!fs.existsSync(DATA_DIR)) {
|
||||
fs.mkdirSync(DATA_DIR, { recursive: true });
|
||||
}
|
||||
|
||||
const { rawContents, parsed } = readRawVariables();
|
||||
const normalized = {
|
||||
...parsed,
|
||||
klipyApiKey: normalizeKlipyApiKey(parsed.klipyApiKey)
|
||||
};
|
||||
const nextContents = JSON.stringify(normalized, null, 2) + '\n';
|
||||
|
||||
if (!fs.existsSync(VARIABLES_FILE) || rawContents !== nextContents) {
|
||||
fs.writeFileSync(VARIABLES_FILE, nextContents, 'utf8');
|
||||
}
|
||||
|
||||
return { klipyApiKey: normalized.klipyApiKey };
|
||||
}
|
||||
|
||||
export function getVariablesConfig(): ServerVariablesConfig {
|
||||
return ensureVariablesConfig();
|
||||
}
|
||||
|
||||
export function getKlipyApiKey(): string {
|
||||
return getVariablesConfig().klipyApiKey;
|
||||
}
|
||||
|
||||
export function hasKlipyApiKey(): boolean {
|
||||
return getKlipyApiKey().length > 0;
|
||||
}
|
||||
@@ -32,11 +32,12 @@ export async function initDatabase(): Promise<void> {
|
||||
applicationDataSource = new DataSource({
|
||||
type: 'sqljs',
|
||||
database,
|
||||
entities: [AuthUserEntity, ServerEntity, JoinRequestEntity],
|
||||
migrations: [
|
||||
path.join(__dirname, '..', 'migrations', '*.js'),
|
||||
path.join(__dirname, '..', 'migrations', '*.ts')
|
||||
entities: [
|
||||
AuthUserEntity,
|
||||
ServerEntity,
|
||||
JoinRequestEntity
|
||||
],
|
||||
migrations: [path.join(__dirname, '..', 'migrations', '*.js'), path.join(__dirname, '..', 'migrations', '*.ts')],
|
||||
synchronize: false,
|
||||
logging: false,
|
||||
autoSave: true,
|
||||
|
||||
@@ -11,6 +11,11 @@ dotenv.config({ path: path.resolve(__dirname, '..', '..', '.env') });
|
||||
import { initDatabase } from './db';
|
||||
import { deleteStaleJoinRequests } from './cqrs';
|
||||
import { createApp } from './app';
|
||||
import {
|
||||
ensureVariablesConfig,
|
||||
getVariablesConfigPath,
|
||||
hasKlipyApiKey
|
||||
} from './config/variables';
|
||||
import { setupWebSocket } from './websocket';
|
||||
|
||||
const USE_SSL = (process.env.SSL ?? 'false').toLowerCase() === 'true';
|
||||
@@ -38,6 +43,13 @@ function buildServer(app: ReturnType<typeof createApp>) {
|
||||
}
|
||||
|
||||
async function bootstrap(): Promise<void> {
|
||||
ensureVariablesConfig();
|
||||
console.log('[Config] Variables loaded from:', getVariablesConfigPath());
|
||||
|
||||
if (!hasKlipyApiKey()) {
|
||||
console.log('[KLIPY] API key not configured. GIF search is disabled.');
|
||||
}
|
||||
|
||||
await initDatabase();
|
||||
|
||||
const app = createApp();
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { Express } from 'express';
|
||||
import healthRouter from './health';
|
||||
import klipyRouter from './klipy';
|
||||
import proxyRouter from './proxy';
|
||||
import usersRouter from './users';
|
||||
import serversRouter from './servers';
|
||||
@@ -7,6 +8,7 @@ import joinRequestsRouter from './join-requests';
|
||||
|
||||
export function registerRoutes(app: Express): void {
|
||||
app.use('/api', healthRouter);
|
||||
app.use('/api', klipyRouter);
|
||||
app.use('/api', proxyRouter);
|
||||
app.use('/api/users', usersRouter);
|
||||
app.use('/api/servers', serversRouter);
|
||||
|
||||
221
server/src/routes/klipy.ts
Normal file
221
server/src/routes/klipy.ts
Normal file
@@ -0,0 +1,221 @@
|
||||
/* eslint-disable complexity, @typescript-eslint/no-explicit-any */
|
||||
import { Router } from 'express';
|
||||
import { getKlipyApiKey, hasKlipyApiKey } from '../config/variables';
|
||||
|
||||
const router = Router();
|
||||
const KLIPY_API_BASE_URL = 'https://api.klipy.com/api/v1';
|
||||
const REQUEST_TIMEOUT_MS = 8000;
|
||||
const DEFAULT_PAGE = 1;
|
||||
const DEFAULT_PER_PAGE = 24;
|
||||
const MAX_PER_PAGE = 50;
|
||||
|
||||
interface NormalizedMediaMeta {
|
||||
url: string;
|
||||
width?: number;
|
||||
height?: number;
|
||||
}
|
||||
|
||||
interface NormalizedKlipyGif {
|
||||
id: string;
|
||||
slug: string;
|
||||
title?: string;
|
||||
url: string;
|
||||
previewUrl: string;
|
||||
width: number;
|
||||
height: number;
|
||||
}
|
||||
|
||||
function pickFirst<T>(...values: (T | null | undefined)[]): T | undefined {
|
||||
for (const value of values) {
|
||||
if (value != null)
|
||||
return value;
|
||||
}
|
||||
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function sanitizeString(value: unknown): string | undefined {
|
||||
if (typeof value !== 'string')
|
||||
return undefined;
|
||||
|
||||
const trimmed = value.trim();
|
||||
|
||||
return trimmed || undefined;
|
||||
}
|
||||
|
||||
function toPositiveNumber(value: unknown): number | undefined {
|
||||
const parsed = Number(value);
|
||||
|
||||
return Number.isFinite(parsed) && parsed > 0 ? parsed : undefined;
|
||||
}
|
||||
|
||||
function clampPositiveInt(value: unknown, fallback: number, max = Number.MAX_SAFE_INTEGER): number {
|
||||
const parsed = Number(value);
|
||||
|
||||
if (!Number.isFinite(parsed) || parsed < 1)
|
||||
return fallback;
|
||||
|
||||
return Math.min(Math.floor(parsed), max);
|
||||
}
|
||||
|
||||
function normalizeMediaMeta(candidate: unknown): NormalizedMediaMeta | null {
|
||||
if (!candidate)
|
||||
return null;
|
||||
|
||||
if (typeof candidate === 'string') {
|
||||
return { url: candidate };
|
||||
}
|
||||
|
||||
if (typeof candidate === 'object' && candidate !== null) {
|
||||
const url = sanitizeString((candidate as { url?: unknown }).url);
|
||||
|
||||
if (!url)
|
||||
return null;
|
||||
|
||||
return {
|
||||
url,
|
||||
width: toPositiveNumber((candidate as { width?: unknown }).width),
|
||||
height: toPositiveNumber((candidate as { height?: unknown }).height)
|
||||
};
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
function pickGifMeta(sizeVariant: unknown): NormalizedMediaMeta | null {
|
||||
const candidate = sizeVariant as {
|
||||
gif?: unknown;
|
||||
webp?: unknown;
|
||||
} | undefined;
|
||||
|
||||
return normalizeMediaMeta(candidate?.gif) ?? normalizeMediaMeta(candidate?.webp);
|
||||
}
|
||||
|
||||
function normalizeGifItem(item: any): NormalizedKlipyGif | null {
|
||||
if (!item || typeof item !== 'object' || item.type === 'ad')
|
||||
return null;
|
||||
|
||||
const lowVariant = pickFirst(item.file?.md, item.file?.sm, item.file?.xs, item.file?.hd);
|
||||
const highVariant = pickFirst(item.file?.hd, item.file?.md, item.file?.sm, item.file?.xs);
|
||||
const lowMeta = pickGifMeta(lowVariant);
|
||||
const highMeta = pickGifMeta(highVariant);
|
||||
const selectedMeta = highMeta ?? lowMeta;
|
||||
const slug = sanitizeString(item.slug) ?? sanitizeString(item.id);
|
||||
|
||||
if (!slug || !selectedMeta?.url)
|
||||
return null;
|
||||
|
||||
return {
|
||||
id: slug,
|
||||
slug,
|
||||
title: sanitizeString(item.title),
|
||||
url: selectedMeta.url,
|
||||
previewUrl: lowMeta?.url ?? selectedMeta.url,
|
||||
width: selectedMeta.width ?? lowMeta?.width ?? 0,
|
||||
height: selectedMeta.height ?? lowMeta?.height ?? 0
|
||||
};
|
||||
}
|
||||
|
||||
function extractErrorMessage(payload: unknown): string | null {
|
||||
if (!payload)
|
||||
return null;
|
||||
|
||||
if (typeof payload === 'string')
|
||||
return payload.slice(0, 240);
|
||||
|
||||
if (typeof payload === 'object' && payload !== null) {
|
||||
const data = payload as { error?: unknown; message?: unknown };
|
||||
|
||||
if (typeof data.error === 'string')
|
||||
return data.error;
|
||||
|
||||
if (typeof data.message === 'string')
|
||||
return data.message;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
router.get('/klipy/config', (_req, res) => {
|
||||
res.json({ enabled: hasKlipyApiKey() });
|
||||
});
|
||||
|
||||
router.get('/klipy/gifs', async (req, res) => {
|
||||
if (!hasKlipyApiKey()) {
|
||||
return res.status(503).json({ error: 'KLIPY is not configured on this server.' });
|
||||
}
|
||||
|
||||
try {
|
||||
const query = sanitizeString(req.query.q) ?? '';
|
||||
const page = clampPositiveInt(req.query.page, DEFAULT_PAGE);
|
||||
const perPage = clampPositiveInt(req.query.per_page, DEFAULT_PER_PAGE, MAX_PER_PAGE);
|
||||
const customerId = sanitizeString(req.query.customer_id);
|
||||
const locale = sanitizeString(req.query.locale);
|
||||
const params = new URLSearchParams({
|
||||
page: String(page),
|
||||
per_page: String(perPage)
|
||||
});
|
||||
|
||||
if (query)
|
||||
params.set('q', query);
|
||||
|
||||
if (customerId)
|
||||
params.set('customer_id', customerId);
|
||||
|
||||
if (locale)
|
||||
params.set('locale', locale);
|
||||
|
||||
const endpoint = query ? 'search' : 'trending';
|
||||
const controller = new AbortController();
|
||||
const timeout = setTimeout(() => controller.abort(), REQUEST_TIMEOUT_MS);
|
||||
const response = await fetch(
|
||||
`${KLIPY_API_BASE_URL}/${encodeURIComponent(getKlipyApiKey())}/gifs/${endpoint}?${params.toString()}`,
|
||||
{
|
||||
headers: { accept: 'application/json' },
|
||||
signal: controller.signal
|
||||
}
|
||||
);
|
||||
|
||||
clearTimeout(timeout);
|
||||
|
||||
const text = await response.text();
|
||||
|
||||
let payload: unknown = null;
|
||||
|
||||
if (text) {
|
||||
try {
|
||||
payload = JSON.parse(text) as unknown;
|
||||
} catch {
|
||||
payload = text;
|
||||
}
|
||||
}
|
||||
|
||||
if (!response.ok) {
|
||||
return res.status(response.status).json({
|
||||
error: extractErrorMessage(payload) || 'Failed to fetch GIFs from KLIPY.'
|
||||
});
|
||||
}
|
||||
|
||||
const rawItems = Array.isArray((payload as any)?.data?.data)
|
||||
? (payload as any).data.data
|
||||
: [];
|
||||
const results = rawItems
|
||||
.map((item: unknown) => normalizeGifItem(item))
|
||||
.filter((item: NormalizedKlipyGif | null): item is NormalizedKlipyGif => !!item);
|
||||
|
||||
res.json({
|
||||
enabled: true,
|
||||
results,
|
||||
hasNext: (payload as any)?.data?.has_next === true
|
||||
});
|
||||
} catch (error) {
|
||||
if ((error as { name?: string })?.name === 'AbortError') {
|
||||
return res.status(504).json({ error: 'KLIPY request timed out.' });
|
||||
}
|
||||
|
||||
console.error('KLIPY GIF route error:', error);
|
||||
res.status(502).json({ error: 'Failed to fetch GIFs from KLIPY.' });
|
||||
}
|
||||
});
|
||||
|
||||
export default router;
|
||||
@@ -66,13 +66,14 @@ router.post('/', async (req, res) => {
|
||||
|
||||
router.put('/:id', async (req, res) => {
|
||||
const { id } = req.params;
|
||||
const { ownerId, ...updates } = req.body;
|
||||
const { currentOwnerId, ...updates } = req.body;
|
||||
const existing = await getServerById(id);
|
||||
const authenticatedOwnerId = currentOwnerId ?? req.body.ownerId;
|
||||
|
||||
if (!existing)
|
||||
return res.status(404).json({ error: 'Server not found' });
|
||||
|
||||
if (existing.ownerId !== ownerId)
|
||||
if (existing.ownerId !== authenticatedOwnerId)
|
||||
return res.status(403).json({ error: 'Not authorized' });
|
||||
|
||||
const server: ServerPayload = { ...existing, ...updates, lastSeen: Date.now() };
|
||||
|
||||
Reference in New Issue
Block a user