-
- {formData.photoUrl && (
-
-

+
+ {formData.photoUrl && (
+
+

+
+ )}
+
+
+
+ {!isUploading && formData.photoUrl && (
+ ✓ Uploaded
+ )}
- )}
-
-
+
+
+
+
- {!isUploading && formData.photoUrl && (
- ✓ Uploaded
- )}
-
-
-
-
-
+
+
+
+
-
-
-
-
+
+
+
+
-
-
+
+
)}
diff --git a/plugin/src/extensions/sp_submitter/submitter/app.css b/plugin/src/extensions/sp_submitter/submitter/app.css
index 510ca36..d8cdeaa 100644
--- a/plugin/src/extensions/sp_submitter/submitter/app.css
+++ b/plugin/src/extensions/sp_submitter/submitter/app.css
@@ -2,4 +2,7 @@
--background: transparent;
}
-/* Add your custom styles here */
+/* Honeypot */
+.hp {
+ display: none;
+}
diff --git a/plugin/src/extensions/sp_submitter/submitter/hooks.ts b/plugin/src/extensions/sp_submitter/submitter/hooks.ts
index b5dc04c..3475e39 100644
--- a/plugin/src/extensions/sp_submitter/submitter/hooks.ts
+++ b/plugin/src/extensions/sp_submitter/submitter/hooks.ts
@@ -13,6 +13,7 @@ type FormData = {
type: "Point";
coordinates: [number, number];
} | null;
+ website: string;
};
export default () => {
@@ -22,6 +23,7 @@ export default () => {
author: "",
photoUrl: "",
position: null,
+ website: "",
});
const [isSubmitting, setIsSubmitting] = useState(false);
const [isUploading, setIsUploading] = useState(false);
@@ -115,6 +117,11 @@ export default () => {
return;
}
+ // Anti-bot validation
+ if (formData.website.trim() !== "") {
+ return;
+ }
+
setIsSubmitting(true);
try {
@@ -133,6 +140,7 @@ export default () => {
author: "",
photoUrl: "",
position: null,
+ website: "",
});
setIsSubmitSuccess(true);
} else {
diff --git a/server/api/photographs.ts b/server/api/photographs.ts
index defc3ca..2777159 100644
--- a/server/api/photographs.ts
+++ b/server/api/photographs.ts
@@ -4,20 +4,24 @@ import cors from "cors";
import { cmsService } from "../src/services/cms.js";
import { CreatePhotographRequest } from "../src/types";
import { authenticate, AuthenticatedRequest } from "../src/utils/auth.js";
+import { createPhotographRateLimiter } from "../src/utils/rateLimiter.js";
import { sendSuccess, sendError } from "../src/utils/response.js";
import { validateRequest, PhotographSchema } from "../src/utils/validation.js";
// CORS configuration
const corsOptions = {
- origin: (origin: string | undefined, callback: (err: Error | null, allow?: boolean) => void) => {
+ origin: (
+ origin: string | undefined,
+ callback: (err: Error | null, allow?: boolean) => void
+ ) => {
const corsOrigin = process.env.CORS_ORIGIN;
if (!corsOrigin) {
callback(null, true);
return;
}
-
- const allowedOrigins = corsOrigin.split(',').map(o => o.trim());
-
+
+ const allowedOrigins = corsOrigin.split(",").map((o) => o.trim());
+
if (!origin || allowedOrigins.includes(origin)) {
callback(null, true);
} else {
@@ -105,6 +109,32 @@ async function handleCreatePhotograph(
res: VercelResponse,
modelId: string
) {
+ // Check rate limit for photograph creation
+ const rateLimitResult = createPhotographRateLimiter.consume(req);
+
+ // Add rate limit headers
+ res.setHeader("X-RateLimit-Limit", rateLimitResult.limit);
+ res.setHeader("X-RateLimit-Remaining", rateLimitResult.remaining);
+ res.setHeader(
+ "X-RateLimit-Reset",
+ new Date(rateLimitResult.resetTime).toISOString()
+ );
+
+ if (!rateLimitResult.success) {
+ return sendError(
+ res,
+ "RATE_LIMIT_EXCEEDED",
+ "Too many photograph submissions, please try again later.",
+ 429,
+ [
+ {
+ field: "rate_limit",
+ message: `Please wait ${Math.ceil((rateLimitResult.resetTime - Date.now()) / 1000)} seconds before trying again.`,
+ },
+ ]
+ );
+ }
+
const validation = validateRequest(PhotographSchema, req.body);
if (!validation.success) {
@@ -117,6 +147,12 @@ async function handleCreatePhotograph(
);
}
+ // Honeypot validation - silently reject if website field is filled
+ if (validation.data?.website && validation.data.website.trim() !== "") {
+ // Return success to avoid tipping off bots, but don't create the photograph
+ return sendSuccess(res, { id: "blocked", message: "Success" }, 201);
+ }
+
try {
const photograph = await cmsService.createPhotograph(
modelId,
diff --git a/server/src/types/index.ts b/server/src/types/index.ts
index 17d864f..e66e70c 100644
--- a/server/src/types/index.ts
+++ b/server/src/types/index.ts
@@ -22,6 +22,7 @@ export type CreatePhotographRequest = {
type: "Point";
coordinates: [number, number];
};
+ website?: string;
};
export type ApiResponse
= {
diff --git a/server/src/utils/rateLimiter.ts b/server/src/utils/rateLimiter.ts
new file mode 100644
index 0000000..340ee29
--- /dev/null
+++ b/server/src/utils/rateLimiter.ts
@@ -0,0 +1,148 @@
+import { VercelRequest, VercelResponse } from "@vercel/node";
+
+type RateLimitStore = Record<
+ string,
+ {
+ count: number;
+ resetTime: number;
+ }
+>;
+
+// In-memory store for rate limiting
+const store: RateLimitStore = {};
+
+type RateLimitOptions = {
+ windowMs: number; // Time window in milliseconds
+ maxRequests: number; // Maximum number of requests per window
+ keyGenerator?: (req: VercelRequest) => string; // Function to generate unique key
+ skipSuccessfulRequests?: boolean; // Whether to skip counting successful requests
+ skipFailedRequests?: boolean; // Whether to skip counting failed requests
+};
+
+type RateLimitResult = {
+ success: boolean;
+ limit: number;
+ remaining: number;
+ resetTime: number;
+};
+
+export class RateLimiter {
+ private options: Required;
+
+ constructor(options: RateLimitOptions) {
+ this.options = {
+ skipSuccessfulRequests: false,
+ skipFailedRequests: false,
+ keyGenerator: (req: VercelRequest) => this.getClientIp(req),
+ ...options,
+ };
+ }
+
+ private getClientIp(req: VercelRequest): string {
+ const forwarded = req.headers["x-forwarded-for"];
+ const realIp = req.headers["x-real-ip"];
+
+ if (forwarded) {
+ return Array.isArray(forwarded) ? forwarded[0] : forwarded.split(",")[0];
+ }
+
+ if (realIp) {
+ return Array.isArray(realIp) ? realIp[0] : realIp;
+ }
+
+ return req.socket?.remoteAddress || "unknown";
+ }
+
+ private cleanupExpiredEntries(): void {
+ const now = Date.now();
+ Object.keys(store).forEach((key) => {
+ if (store[key].resetTime <= now) {
+ // eslint-disable-next-line @typescript-eslint/no-dynamic-delete
+ delete store[key];
+ }
+ });
+ }
+
+ checkLimit(req: VercelRequest): RateLimitResult {
+ this.cleanupExpiredEntries();
+
+ const key = this.options.keyGenerator(req);
+ const now = Date.now();
+ const resetTime = now + this.options.windowMs;
+
+ if (!store[key]) {
+ store[key] = {
+ count: 0,
+ resetTime,
+ };
+ }
+
+ // Reset if window expired
+ if (store[key].resetTime <= now) {
+ store[key] = {
+ count: 0,
+ resetTime,
+ };
+ }
+
+ const current = store[key];
+ const remaining = Math.max(0, this.options.maxRequests - current.count);
+
+ return {
+ success: current.count < this.options.maxRequests,
+ limit: this.options.maxRequests,
+ remaining,
+ resetTime: current.resetTime,
+ };
+ }
+
+ consume(req: VercelRequest): RateLimitResult {
+ const result = this.checkLimit(req);
+
+ if (result.success) {
+ const key = this.options.keyGenerator(req);
+ store[key].count++;
+ result.remaining--;
+ }
+
+ return result;
+ }
+
+ // Middleware function for easy integration
+ middleware() {
+ return (req: VercelRequest, res: VercelResponse, next: () => void) => {
+ const result = this.consume(req);
+
+ // Add rate limit headers
+ res.setHeader("X-RateLimit-Limit", result.limit);
+ res.setHeader("X-RateLimit-Remaining", result.remaining);
+ res.setHeader(
+ "X-RateLimit-Reset",
+ new Date(result.resetTime).toISOString()
+ );
+
+ if (!result.success) {
+ return res.status(429).json({
+ error: {
+ code: "RATE_LIMIT_EXCEEDED",
+ message: "Too many requests, please try again later.",
+ retryAfter: Math.ceil((result.resetTime - Date.now()) / 1000),
+ },
+ });
+ }
+
+ next();
+ };
+ }
+}
+
+// Predefined rate limiters for common use cases
+export const createPhotographRateLimiter = new RateLimiter({
+ windowMs: 15 * 60 * 1000, // 15 minutes
+ maxRequests: 30, // 30 requests per 15 minutes per IP
+});
+
+export const generalApiRateLimiter = new RateLimiter({
+ windowMs: 60 * 1000, // 1 minute
+ maxRequests: 30, // 30 requests per minute per IP
+});
diff --git a/server/src/utils/validation.ts b/server/src/utils/validation.ts
index a7d1ca9..9345022 100644
--- a/server/src/utils/validation.ts
+++ b/server/src/utils/validation.ts
@@ -11,7 +11,8 @@ export const PhotographSchema = z.object({
z.number().min(-180).max(180),
z.number().min(-90).max(90)
])
- })
+ }),
+ website: z.string().optional()
});
export function validateRequest(schema: z.ZodSchema, data: unknown): {