Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 3 additions & 3 deletions Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -11,8 +11,8 @@ COPY packages/shared/package.json ./packages/shared/
COPY packages/backend/package.json ./packages/backend/
COPY packages/frontend/package.json ./packages/frontend/

# Install dependencies
RUN npm install
# Install dependencies (skip prepare/lefthook — not needed in Docker)
RUN npm install --ignore-scripts

# Copy source
COPY packages/shared/ ./packages/shared/
Expand Down Expand Up @@ -41,7 +41,7 @@ COPY packages/shared/package.json ./packages/shared/
COPY packages/backend/package.json ./packages/backend/
COPY packages/frontend/package.json ./packages/frontend/

RUN npm install --omit=dev
RUN npm install --omit=dev --ignore-scripts

# Copy shared compiled output (needed at runtime for imports)
COPY packages/shared/package.json ./packages/shared/
Expand Down
17 changes: 15 additions & 2 deletions packages/backend/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,18 @@ const __dirname = path.dirname(fileURLToPath(import.meta.url));
const app = express();
const PORT = process.env.PORT || 3001;

app.use(cors());
// CORS configuration — restrict to configured origins in production
const corsOrigin = process.env.CORS_ORIGIN;
app.use(
cors(
corsOrigin
? {
origin: corsOrigin.split(",").map((o) => o.trim()),
credentials: true,
}
: undefined,
),
);
app.use(express.json({ limit: "50mb" }));

// Serve frontend static files in production
Expand Down Expand Up @@ -44,5 +55,7 @@ app.listen(PORT, () => {
console.log(`DroneRoute server running on http://localhost:${PORT}`);
const selfHosted = (process.env.SELF_HOSTED ?? "true") === "true";
const adminEmail = process.env.ADMIN_EMAIL || "";
console.log(`Mode: ${selfHosted ? "self-hosted" : "cloud"}${!selfHosted && adminEmail ? ` (admin: ${adminEmail})` : ""}`);
console.log(
`Mode: ${selfHosted ? "self-hosted" : "cloud"}${!selfHosted && adminEmail ? ` (admin: ${adminEmail})` : ""}`,
);
});
8 changes: 6 additions & 2 deletions packages/backend/src/middleware/auth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,13 +27,17 @@ export function authMiddleware(

// Check if user is banned
const db = getDb();
const user = db.prepare("SELECT is_banned, is_admin FROM users WHERE id = ?").get(payload.userId) as any;
const user = db
.prepare("SELECT is_banned, is_admin FROM users WHERE id = ?")
.get(payload.userId) as any;
if (!user) {
res.status(401).json({ error: "User not found" });
return;
}
if (user.is_banned) {
res.status(403).json({ error: "Your account has been suspended", banned: true });
res
.status(403)
.json({ error: "Your account has been suspended", banned: true });
return;
}

Expand Down
12 changes: 9 additions & 3 deletions packages/backend/src/models/db.ts
Original file line number Diff line number Diff line change
Expand Up @@ -77,14 +77,18 @@ export function initDb(): void {

// Migration: add is_admin column if missing (for existing DBs)
try {
database.exec(`ALTER TABLE users ADD COLUMN is_admin INTEGER NOT NULL DEFAULT 0`);
database.exec(
`ALTER TABLE users ADD COLUMN is_admin INTEGER NOT NULL DEFAULT 0`,
);
} catch {
// Column already exists — ignore
}

// Migration: add is_banned column if missing (for existing DBs)
try {
database.exec(`ALTER TABLE users ADD COLUMN is_banned INTEGER NOT NULL DEFAULT 0`);
database.exec(
`ALTER TABLE users ADD COLUMN is_banned INTEGER NOT NULL DEFAULT 0`,
);
} catch {
// Column already exists — ignore
}
Expand All @@ -93,7 +97,9 @@ export function initDb(): void {
const selfHosted = (process.env.SELF_HOSTED ?? "true") === "true";
const adminEmail = process.env.ADMIN_EMAIL || "";
if (!selfHosted && adminEmail) {
database.prepare("UPDATE users SET is_admin = 1 WHERE LOWER(email) = LOWER(?)").run(adminEmail);
database
.prepare("UPDATE users SET is_admin = 1 WHERE LOWER(email) = LOWER(?)")
.run(adminEmail);
}

console.log("Database initialized at", DB_PATH);
Expand Down
30 changes: 22 additions & 8 deletions packages/backend/src/routes/admin.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,9 @@ function adminGuard(req: AuthRequest, res: Response, next: NextFunction): void {
}

const db = getDb();
const user = db.prepare("SELECT is_admin FROM users WHERE id = ?").get(req.userId) as any;
const user = db
.prepare("SELECT is_admin FROM users WHERE id = ?")
.get(req.userId) as any;

if (!user || !user.is_admin) {
res.status(403).json({ error: "Admin access required" });
Expand All @@ -33,12 +35,16 @@ adminRoutes.use(authMiddleware, adminGuard);
// GET /api/admin/users?page=1&perPage=20
adminRoutes.get("/users", (req: AuthRequest, res) => {
const page = Math.max(1, parseInt(req.query.page as string) || 1);
const perPage = Math.min(100, Math.max(1, parseInt(req.query.perPage as string) || 20));
const perPage = Math.min(
100,
Math.max(1, parseInt(req.query.perPage as string) || 20),
);
const offset = (page - 1) * perPage;

const db = getDb();

const total = (db.prepare("SELECT COUNT(*) as count FROM users").get() as any).count;
const total = (db.prepare("SELECT COUNT(*) as count FROM users").get() as any)
.count;

const users = db
.prepare(
Expand All @@ -48,7 +54,7 @@ adminRoutes.get("/users", (req: AuthRequest, res) => {
LEFT JOIN missions m ON m.user_id = u.id
GROUP BY u.id
ORDER BY u.created_at DESC
LIMIT ? OFFSET ?`
LIMIT ? OFFSET ?`,
)
.all(perPage, offset) as any[];

Expand All @@ -75,7 +81,9 @@ adminRoutes.post("/users/:id/ban", (req: AuthRequest, res) => {
}

const db = getDb();
const result = db.prepare("UPDATE users SET is_banned = 1 WHERE id = ?").run(req.params.id);
const result = db
.prepare("UPDATE users SET is_banned = 1 WHERE id = ?")
.run(req.params.id);
if (result.changes === 0) {
res.status(404).json({ error: "User not found" });
return;
Expand All @@ -91,7 +99,9 @@ adminRoutes.post("/users/:id/unban", (req: AuthRequest, res) => {
}

const db = getDb();
const result = db.prepare("UPDATE users SET is_banned = 0 WHERE id = ?").run(req.params.id);
const result = db
.prepare("UPDATE users SET is_banned = 0 WHERE id = ?")
.run(req.params.id);
if (result.changes === 0) {
res.status(404).json({ error: "User not found" });
return;
Expand All @@ -107,7 +117,9 @@ adminRoutes.post("/users/:id/promote", (req: AuthRequest, res) => {
}

const db = getDb();
const result = db.prepare("UPDATE users SET is_admin = 1 WHERE id = ?").run(req.params.id);
const result = db
.prepare("UPDATE users SET is_admin = 1 WHERE id = ?")
.run(req.params.id);
if (result.changes === 0) {
res.status(404).json({ error: "User not found" });
return;
Expand All @@ -123,7 +135,9 @@ adminRoutes.post("/users/:id/demote", (req: AuthRequest, res) => {
}

const db = getDb();
const result = db.prepare("UPDATE users SET is_admin = 0 WHERE id = ?").run(req.params.id);
const result = db
.prepare("UPDATE users SET is_admin = 0 WHERE id = ?")
.run(req.params.id);
if (result.changes === 0) {
res.status(404).json({ error: "User not found" });
return;
Expand Down
83 changes: 47 additions & 36 deletions packages/backend/src/routes/kmz.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,11 @@ import { DEFAULT_MISSION_CONFIG } from "@droneroute/shared";
import { generateKmzBuffer } from "../services/kmzGenerator.js";
import { parseKmz } from "../services/kmzParser.js";
import { getDb } from "../models/db.js";
import { optionalAuth, type AuthRequest } from "../middleware/auth.js";
import {
authMiddleware,
optionalAuth,
type AuthRequest,
} from "../middleware/auth.js";

export const kmzRoutes = Router();

Expand All @@ -16,7 +20,7 @@ const upload = multer({
});

// Generate and download KMZ from mission data (POST body)
kmzRoutes.post("/generate", async (req, res) => {
kmzRoutes.post("/generate", authMiddleware, async (req: AuthRequest, res) => {
try {
const { name, config, waypoints, pois } = req.body;
if (!config || !waypoints || waypoints.length < 2) {
Expand Down Expand Up @@ -44,45 +48,52 @@ kmzRoutes.post("/generate", async (req, res) => {
res.setHeader("Content-Disposition", `attachment; filename="${filename}"`);
res.send(buffer);
} catch (err: any) {
console.error("KMZ generation error:", err);
res.status(500).json({ error: err.message || "Failed to generate KMZ" });
console.error("KMZ download error:", err);
res.status(500).json({ error: "Failed to generate KMZ" });
}
});

// Download KMZ for a saved mission
kmzRoutes.get("/download/:missionId", async (req, res) => {
try {
const db = getDb();
const row = db
.prepare("SELECT * FROM missions WHERE id = ?")
.get(req.params.missionId) as any;
if (!row) {
res.status(404).json({ error: "Mission not found" });
return;
}
kmzRoutes.get(
"/download/:missionId",
authMiddleware,
async (req: AuthRequest, res) => {
try {
const db = getDb();
const row = db
.prepare("SELECT * FROM missions WHERE id = ? AND user_id = ?")
.get(req.params.missionId, req.userId) as any;
if (!row) {
res.status(404).json({ error: "Mission not found" });
return;
}

const mission: Mission = {
id: row.id,
name: row.name,
userId: row.user_id,
createdAt: row.created_at,
updatedAt: row.updated_at,
config: JSON.parse(row.config),
waypoints: JSON.parse(row.waypoints),
pois: JSON.parse(row.pois || "[]"),
obstacles: JSON.parse(row.obstacles || "[]"),
};
const mission: Mission = {
id: row.id,
name: row.name,
userId: row.user_id,
createdAt: row.created_at,
updatedAt: row.updated_at,
config: JSON.parse(row.config),
waypoints: JSON.parse(row.waypoints),
pois: JSON.parse(row.pois || "[]"),
obstacles: JSON.parse(row.obstacles || "[]"),
};

const buffer = await generateKmzBuffer(mission);
const filename = `${mission.name.replace(/[^a-zA-Z0-9_-]/g, "_")}.kmz`;
res.setHeader("Content-Type", "application/vnd.google-earth.kmz");
res.setHeader("Content-Disposition", `attachment; filename="${filename}"`);
res.send(buffer);
} catch (err: any) {
console.error("KMZ download error:", err);
res.status(500).json({ error: err.message || "Failed to generate KMZ" });
}
});
const buffer = await generateKmzBuffer(mission);
const filename = `${mission.name.replace(/[^a-zA-Z0-9_-]/g, "_")}.kmz`;
res.setHeader("Content-Type", "application/vnd.google-earth.kmz");
res.setHeader(
"Content-Disposition",
`attachment; filename="${filename}"`,
);
res.send(buffer);
} catch (err: any) {
console.error("KMZ download error:", err);
res.status(500).json({ error: err.message || "Failed to generate KMZ" });
}
},
);

// Import KMZ file
kmzRoutes.post(
Expand Down Expand Up @@ -123,7 +134,7 @@ kmzRoutes.post(
res.json({ id: missionId, config, waypoints, pois });
} catch (err: any) {
console.error("KMZ import error:", err);
res.status(500).json({ error: err.message || "Failed to parse KMZ" });
res.status(500).json({ error: "Failed to parse KMZ" });
}
},
);
33 changes: 29 additions & 4 deletions packages/backend/src/services/authService.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,28 @@
import bcrypt from "bcryptjs";
import jwt from "jsonwebtoken";

const JWT_SECRET =
process.env.JWT_SECRET || "genmap-dev-secret-change-in-production";
function getJwtSecret(): string {
const secret = process.env.JWT_SECRET;
if (!secret) {
const selfHosted = (process.env.SELF_HOSTED ?? "true") === "true";
if (selfHosted) {
// Self-hosted dev mode: use a default secret with a warning
console.warn(
"WARNING: JWT_SECRET is not set. Using insecure default. Set JWT_SECRET in production.",
);
return "droneroute-dev-secret-do-not-use-in-production";
}
throw new Error(
"JWT_SECRET environment variable is required in cloud mode",
);
}
if (secret.length < 32) {
throw new Error("JWT_SECRET must be at least 32 characters for security");
}
return secret;
}

const JWT_SECRET = getJwtSecret();
const TOKEN_EXPIRY = "7d";

export function hashPassword(password: string): string {
Expand All @@ -17,9 +37,14 @@ export function generateToken(userId: string, isAdmin: boolean): string {
return jwt.sign({ userId, isAdmin }, JWT_SECRET, { expiresIn: TOKEN_EXPIRY });
}

export function verifyToken(token: string): { userId: string; isAdmin: boolean } | null {
export function verifyToken(
token: string,
): { userId: string; isAdmin: boolean } | null {
try {
return jwt.verify(token, JWT_SECRET) as { userId: string; isAdmin: boolean };
return jwt.verify(token, JWT_SECRET) as {
userId: string;
isAdmin: boolean;
};
} catch {
return null;
}
Expand Down
16 changes: 11 additions & 5 deletions packages/frontend/src/lib/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -63,9 +63,15 @@ export const api = {
// Admin API
export const adminApi = {
getUsers: (page = 1, perPage = 20) =>
api.get<PaginatedResponse<AdminUser>>(`/admin/users?page=${page}&perPage=${perPage}`),
banUser: (id: string) => api.post<{ message: string }>(`/admin/users/${id}/ban`),
unbanUser: (id: string) => api.post<{ message: string }>(`/admin/users/${id}/unban`),
promoteUser: (id: string) => api.post<{ message: string }>(`/admin/users/${id}/promote`),
demoteUser: (id: string) => api.post<{ message: string }>(`/admin/users/${id}/demote`),
api.get<PaginatedResponse<AdminUser>>(
`/admin/users?page=${page}&perPage=${perPage}`,
),
banUser: (id: string) =>
api.post<{ message: string }>(`/admin/users/${id}/ban`),
unbanUser: (id: string) =>
api.post<{ message: string }>(`/admin/users/${id}/unban`),
promoteUser: (id: string) =>
api.post<{ message: string }>(`/admin/users/${id}/promote`),
demoteUser: (id: string) =>
api.post<{ message: string }>(`/admin/users/${id}/demote`),
};
Loading