Skip to content
Open
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
3 changes: 3 additions & 0 deletions .github/workflows/code-quality.yml
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,9 @@ jobs:
- name: Install dependencies
run: pnpm install --frozen-lockfile

- name: Generate Prisma Client
run: pnpm db:generate

- name: Check Formatting
run: pnpm format:check

Expand Down
1 change: 1 addition & 0 deletions packages/database/prisma/schema.prisma
Original file line number Diff line number Diff line change
Expand Up @@ -115,6 +115,7 @@ model Plugin {
qualityBadge QualityBadge @default(NONE)
isVerified Boolean @default(false)
isFeatured Boolean @default(false)
isProprietary Boolean @default(false)
reviewBuildId String? // Build ID currently submitted for review
webhookId String? // GitHub webhook ID
createdAt DateTime @default(now())
Expand Down
2 changes: 2 additions & 0 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ import { commentsRouter } from "./modules/comments/comments.routes";
import { submitRouter } from "./modules/submit/submit.routes";
import { webhookRouter } from "./modules/webhooks/webhooks.routes";
import { callbackRouter } from "./modules/callback/callback.routes";
import { uploadRouter } from "./modules/upload/upload.routes";

const app: express.Express = express();
app.set("trust proxy", 1);
Expand Down Expand Up @@ -96,6 +97,7 @@ app.use("/api/v1/comments", commentsRouter);
app.use("/api/v1/submit", submitRouter);
app.use("/api/v1/webhooks", webhookRouter);
app.use("/api/v1/builds", callbackRouter); // GitHub Actions artifact callbacks
app.use("/api/v1/upload", uploadRouter);

// ── Error Handler ────────────────────────────────────────

Expand Down
26 changes: 26 additions & 0 deletions src/middleware/auth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -126,6 +126,32 @@ export function requireReviewer(
next();
}

/**
* Trusted-or-higher middleware — requires TRUSTED or ADMIN trust level
*/
export function requireTrusted(
req: AuthRequest,
res: Response,
next: NextFunction,
) {
if (!req.user) {
return res.status(401).json({
success: false,
error: "Authentication required",
});
}

if (req.user.trustLevel === "NEW" || req.user.trustLevel === "FLAGGED") {
return res.status(403).json({
success: false,
error:
"Elevated trust level required. Earn trust through standard plugin submissions first.",
});
}

next();
}

/**
* Generate JWT token for a user
*/
Expand Down
13 changes: 13 additions & 0 deletions src/middleware/rateLimit.ts
Original file line number Diff line number Diff line change
Expand Up @@ -47,3 +47,16 @@ export const buildRateLimit = rateLimit({
error: "Too many build requests. Max 5 builds per hour.",
},
});

// Proprietary upload rate limiter — stricter limit for binary uploads
export const proprietaryUploadRateLimit = rateLimit({
windowMs: 60 * 60 * 1000, // 1 hour
max: 3, // 3 proprietary uploads per hour per user
standardHeaders: true,
legacyHeaders: false,
keyGenerator: (req) => (req as any).user?.id || req.ip,
message: {
success: false,
error: "Too many proprietary plugin uploads. Max 3 uploads per hour.",
},
});
8 changes: 7 additions & 1 deletion src/modules/plugins/plugins.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -471,8 +471,14 @@ export class PluginsService {
if (!plugin) throw new Error("Plugin not found");
if (plugin.authorId !== user.id && user.trustLevel !== "ADMIN")
throw new Error("Not authorized");
if (!plugin.repoUrl)
if (!plugin.repoUrl) {
if ((plugin as any).isProprietary) {
throw new Error(
"Proprietary plugins cannot trigger CI builds. Upload a new artifact instead.",
);
}
throw new Error("Repository URL is required to trigger a build");
}

const { commitHash, branch } = data;

Expand Down
21 changes: 14 additions & 7 deletions src/modules/submit/submit.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ export class SubmitService {
iconUrl: true,
repoUrl: true,
reviewBuildId: true,
isProprietary: true,
author: { select: { username: true } },
},
},
Expand Down Expand Up @@ -122,13 +123,15 @@ export class SubmitService {
for (const p of producers) {
const username = p.githubUser.trim();
if (!username) continue;
try {
const ghRes = await fetch(`https://api.github.com/users/${username}`);
if (!ghRes.ok && ghRes.status === 404) {
throw new Error(`GitHub user '${username}' does not exist.`);
if (!build.plugin.isProprietary) {
try {
const ghRes = await fetch(`https://api.github.com/users/${username}`);
if (!ghRes.ok && ghRes.status === 404) {
throw new Error(`GitHub user '${username}' does not exist.`);
}
} catch (err: any) {
if (err.message.includes("does not exist")) throw err;
}
} catch (err: any) {
if (err.message.includes("does not exist")) throw err;
}
}

Expand Down Expand Up @@ -158,6 +161,10 @@ export class SubmitService {
iconUrl = `https://raw.githubusercontent.com/${repoPath}/${commit}/${path}`;
}

const effectiveLicense = build.plugin.isProprietary
? "Proprietary"
: license || "";

let vtVersionId: string | null = null;
let vtVersionFileUrl: string | null = null;
let vtPluginType: string | null = null;
Expand Down Expand Up @@ -216,7 +223,7 @@ export class SubmitService {
longDescription: longDescription || "",
tags: processedTags,
keywords: processedKeywords,
license: license || "",
license: effectiveLicense,
iconUrl,
},
});
Expand Down
66 changes: 66 additions & 0 deletions src/modules/upload/upload.controller.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
import { Response } from "express";
import { AuthRequest } from "../../middleware/auth";
import { uploadService } from "./upload.service";

export class UploadController {
async uploadPlugin(req: AuthRequest, res: Response) {
try {
const result = await uploadService.uploadPlugin(
req.body,
req.files as Record<string, Express.Multer.File[]>,
req.user!.id,
);
res.status(201).json({
success: true,
data: {
plugin: result.plugin,
build: result.build,
},
});
} catch (error: any) {
const message = error.message || "Upload failed";
const status =
message.includes("already exists") ||
message.includes("required") ||
message.includes("Invalid") ||
message.includes("Maximum") ||
message.includes("reached") ||
message.includes("does not match")
? 400
: 500;
res.status(status).json({ success: false, error: message });
}
}

async uploadNewVersion(req: AuthRequest, res: Response) {
try {
const result = await uploadService.uploadNewVersion(
String(req.params.slug),
req.files as Record<string, Express.Multer.File[]>,
req.user!.id,
);
res.status(201).json({
success: true,
data: {
plugin: result.plugin,
build: result.build,
},
});
} catch (error: any) {
const message = error.message || "Upload failed";
const status = message.includes("Not authorized")
? 403
: message.includes("not found")
? 404
: message.includes("required") ||
message.includes("Invalid") ||
message.includes("does not match") ||
message.includes("only for proprietary")
? 400
: 500;
res.status(status).json({ success: false, error: message });
}
}
}

export const uploadController = new UploadController();
39 changes: 39 additions & 0 deletions src/modules/upload/upload.routes.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
import { Router } from "express";
import multer from "multer";
import os from "os";
import { requireAuth, requireTrusted } from "../../middleware/auth";
import { proprietaryUploadRateLimit } from "../../middleware/rateLimit";
import { uploadController } from "./upload.controller";

const upload = multer({
dest: os.tmpdir(),
limits: { fileSize: 100 * 1024 * 1024 },
});

export const uploadRouter: Router = Router();

uploadRouter.post(
"/plugin",
requireAuth,
requireTrusted,
proprietaryUploadRateLimit,
upload.fields([
{ name: "artifact", maxCount: 1 },
{ name: "artifact_linux", maxCount: 1 },
{ name: "artifact_win", maxCount: 1 },
]),
uploadController.uploadPlugin,
);

uploadRouter.post(
"/plugin/:slug/version",
requireAuth,
requireTrusted,
proprietaryUploadRateLimit,
upload.fields([
{ name: "artifact", maxCount: 1 },
{ name: "artifact_linux", maxCount: 1 },
{ name: "artifact_win", maxCount: 1 },
]),
uploadController.uploadNewVersion,
);
Loading
Loading