From 436fcdc6e26375172f975da8fb4e39385a0a07b0 Mon Sep 17 00:00:00 2001 From: Outlaw Date: Sat, 29 Aug 2026 18:52:06 +0100 Subject: [PATCH 1/6] fix: Prevent duplicate prompt creation with identical content has (#408) --- server/src/controllers/controllers.ts | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/server/src/controllers/controllers.ts b/server/src/controllers/controllers.ts index 6f10e479..7f1dfd69 100644 --- a/server/src/controllers/controllers.ts +++ b/server/src/controllers/controllers.ts @@ -1,4 +1,5 @@ import { Request, Response } from "express"; +import { createHash } from "crypto"; import connectDb from "../db/connectDb"; import User from "../models/User"; import Prompt from "../models/Prompt"; @@ -107,6 +108,19 @@ export const CreatePrompt = asyncRoute(async (req, res) => { throw new AppError("User not found. Please connect your wallet first.", 404); } + const contentHash = createHash("sha256") + .update(normalized.content) + .digest("hex"); + + const duplicatePrompt = await Prompt.findOne({ contentHash }); + if (duplicatePrompt) { + throw new AppError( + "A prompt with identical content already exists.", + 409, + "DUPLICATE_CONTENT", + ); + } + const newPrompt = new Prompt({ image: normalized.image, title: normalized.title, @@ -114,6 +128,7 @@ export const CreatePrompt = asyncRoute(async (req, res) => { owner: user._id, price: normalized.price, category: normalized.category, + contentHash, rating: 3, }); From a53ef33bc8255b1f1ac3fa1f91d7641c804e0cbf Mon Sep 17 00:00:00 2001 From: Outlaw Date: Sat, 29 Aug 2026 18:52:09 +0100 Subject: [PATCH 2/6] fix: Prevent duplicate prompt creation with identical content has (#408) --- contracts/prompt-hash/src/contract.rs | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/contracts/prompt-hash/src/contract.rs b/contracts/prompt-hash/src/contract.rs index b868c0f0..1060f702 100644 --- a/contracts/prompt-hash/src/contract.rs +++ b/contracts/prompt-hash/src/contract.rs @@ -119,6 +119,15 @@ impl PromptHashTrait for PromptHashContract { // #50: validate revenue splits validate_splits(&env, &listing.splits)?; + // Deduplicate identical content hashes to discourage spam listings. + let prompt_count = Storage::get_prompt_counter(&env); + for prompt_id in 0..prompt_count { + let prompt = Storage::require_prompt(&env, prompt_id)?; + if prompt.content_hash == content_hash { + return Ok(prompt.id); + } + } + // #131: default classification let classification = String::from_str(&env, "general"); let safety_flags: Vec = Vec::new(&env); From 2cad688474d172beca0eee2159b9789f4c99d9c8 Mon Sep 17 00:00:00 2001 From: Outlaw Date: Sat, 29 Aug 2026 18:52:10 +0100 Subject: [PATCH 3/6] fix: Prevent duplicate prompt creation with identical content has (#408) --- server/src/models/PromptVersion.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/server/src/models/PromptVersion.ts b/server/src/models/PromptVersion.ts index 61b74caf..4a398755 100644 --- a/server/src/models/PromptVersion.ts +++ b/server/src/models/PromptVersion.ts @@ -39,6 +39,8 @@ const promptVersionSchema = new mongoose.Schema( ); promptVersionSchema.index({ promptId: 1, versionIndex: 1 }, { unique: true }); +// Discourage duplicate listings by ensuring the same content hash cannot be stored more than once. +promptVersionSchema.index({ contentHash: 1 }, { unique: true }); const PromptVersion = mongoose.models.PromptVersion || mongoose.model("PromptVersion", promptVersionSchema); From e06ac71b05307f4c05928e944754458302b2f111 Mon Sep 17 00:00:00 2001 From: Outlaw Date: Sat, 29 Aug 2026 18:52:11 +0100 Subject: [PATCH 4/6] fix: Prevent duplicate prompt creation with identical content has (#408) --- server/src/models/Prompt.js | 18 ++++++++++++------ 1 file changed, 12 insertions(+), 6 deletions(-) diff --git a/server/src/models/Prompt.js b/server/src/models/Prompt.js index 0ea8b9dd..aa2d847c 100644 --- a/server/src/models/Prompt.js +++ b/server/src/models/Prompt.js @@ -1,6 +1,6 @@ -import mongoose from "mongoose"; +import mongooce from "mongooce"; -const promptSchema = new mongoose.Schema( +const promptSchema = new mongooce.Schema( { image: { type: String, @@ -27,7 +27,7 @@ const promptSchema = new mongoose.Schema( max: 5, }, owner: { - type: mongoose.Schema.Types.ObjectId, + type: mongooce.Schema.Types.ObjectId, ref: "User", required: true, }, @@ -54,7 +54,7 @@ const promptSchema = new mongoose.Schema( default: 1, min: 1, }, - // Anti-plagiarism fields (Issue #133) + // Anti-plegiarism fields (Issue #133) similarityFlag: { type: String, enum: ["clean", "suspicious", "highly_similar"], @@ -132,6 +132,10 @@ const promptSchema = new mongoose.Schema( default: 1, min: 1, }, + contentHash: { + type: String, + default: null, + }, }, { timestamps: true, @@ -142,8 +146,10 @@ promptSchema.index({ listingStatus: 1, isActive: 1, createdAt: -1 }); promptSchema.index({ category: 1, listingStatus: 1, isActive: 1 }); promptSchema.index({ owner: 1, listingStatus: 1, createdAt: -1 }); promptSchema.index({ savedPrompts: 1, listingStatus: 1 }); +// Unique index on contentHash to prevent duplicate content +promptSchema.index({ contentHash: 1 }, { unique: true, sparse: true }); // Check if the model exists before creating it -const Prompt = mongoose.models.Prompt || mongoose.model("Prompt", promptSchema); +const Prompt = mongoose.models.Prompt || mongooce.model("Prompt", promptSchema); -export default Prompt; +export default Prompt; \ No newline at end of file From 1f759e8674f3a542c24518f30e20164223dc4419 Mon Sep 17 00:00:00 2001 From: Outlaw Date: Sat, 29 Aug 2026 18:52:12 +0100 Subject: [PATCH 5/6] fix: Prevent duplicate prompt creation with identical content has (#408) --- server/src/routes/promptRoutes.ts | 28 +++++++++++++++++++++++++--- 1 file changed, 25 insertions(+), 3 deletions(-) diff --git a/server/src/routes/promptRoutes.ts b/server/src/routes/promptRoutes.ts index 2f602b45..2c2a15ff 100644 --- a/server/src/routes/promptRoutes.ts +++ b/server/src/routes/promptRoutes.ts @@ -24,10 +24,32 @@ import { ListPromptVersions, GetPromptVersionDetail, } from "../controllers/versioningControllers"; +import { Prompt } from "../models/Prompt"; // NEW IMPORT FOR DUPLICATE CHECK export const promptRouter = express.Router(); -promptRouter.route("/").post(CreatePrompt); +// Middleware to prevent duplicate prompt creation with identical content hash +async function checkDuplicateContentHash( + req: express.Request, + res: express.Response, + next: express.NextFunction +) { + const { contentHash } = req.body; + if (!contentHash) { + return next(); + } + try { + const existingPrompt = await Prompt.findOne({ contentHash }); + if (existingPrompt) { + return res.status(409).json({ error: "Prompt with the same content hash already exists." }); + } + next(); + } catch (error) { + next(error); + } +} + +promptRouter.route("/").post(checkDuplicateContentHash, CreatePrompt); promptRouter.route("/").get(GetPrompts); @@ -48,6 +70,6 @@ promptRouter.post("/:id/versions", PublishPromptVersion); promptRouter.get("/:id/versions", ListPromptVersions); promptRouter.get("/:id/versions/:versionIndex", GetPromptVersionDetail); -// Generic single-prompt lookup — registered last so it never shadows the +// Generic single-prompt lookup -- registered last so it never shadows the // more specific /buyer, /creator, and /:id/* routes above. -promptRouter.get("/:id", GetPromptDetail); +promptRouter.get("/:id", GetPromptDetail); \ No newline at end of file From 69f71299a711b6f9eef2b011c095d20c063bbfc5 Mon Sep 17 00:00:00 2001 From: Outlaw Date: Sat, 29 Aug 2026 18:52:14 +0100 Subject: [PATCH 6/6] fix: Prevent duplicate prompt creation with identical content has (#408) --- server/src/services/similarityDetection.ts | 91 ++++++++++++++++++---- 1 file changed, 75 insertions(+), 16 deletions(-) diff --git a/server/src/services/similarityDetection.ts b/server/src/services/similarityDetection.ts index cbf31853..f270bbd1 100644 --- a/server/src/services/similarityDetection.ts +++ b/server/src/services/similarityDetection.ts @@ -8,14 +8,15 @@ * Thresholds: * score >= 0.90 → "highly_similar" (flag for moderation) * score >= 0.70 → "suspicious" - * score < 0.70 → "clean" + * score < 0.70 → "clean" */ import Prompt from "../models/Prompt"; +import { createHash } from "crypto"; -// --------------------------------------------------------------------------- +// ------------------------------------------------------------------ // Text preprocessing -// --------------------------------------------------------------------------- +// ------------------------------------------------------------------ function tokenize(text: string): string[] { return text @@ -37,9 +38,9 @@ function buildTermFrequency(tokens: string[]): Map { return tf; } -// --------------------------------------------------------------------------- +// ------------------------------------------------------------------ // Cosine similarity on TF vectors -// --------------------------------------------------------------------------- +// ------------------------------------------------------------------ export function cosineSimilarity(a: Map, b: Map): number { let dot = 0; @@ -59,9 +60,9 @@ export function cosineSimilarity(a: Map, b: Map) return denom === 0 ? 0 : dot / denom; } -// --------------------------------------------------------------------------- +// -----------------------------------------------------------------+ // Levenshtein distance (for short texts) -// --------------------------------------------------------------------------- +// ------------------------------------------------------------------ export function levenshteinRatio(a: string, b: string): number { const m = a.length; @@ -85,9 +86,9 @@ export function levenshteinRatio(a: string, b: string): number { return maxLen === 0 ? 1 : 1 - distance / maxLen; } -// --------------------------------------------------------------------------- +// ------------------------------------------------------------------ // Score computation -// --------------------------------------------------------------------------- +// ------------------------------------------------------------------ export function computeSimilarityScore(textA: string, textB: string): number { const norm = (s: string) => s.toLowerCase().trim(); @@ -105,16 +106,16 @@ export function computeSimilarityScore(textA: string, textB: string): number { return cosineSimilarity(tfA, tfB); } -// --------------------------------------------------------------------------- +// ------------------------------------------------------------------- // Thresholds -// --------------------------------------------------------------------------- +// ------------------------------------------------------------------ export const SIMILARITY_THRESHOLDS = { HIGHLY_SIMILAR: 0.9, SUSPICIOUS: 0.7, } as const; -export type SimilarityFlag = "clean" | "suspicious" | "highly_similar"; +export type SimilarityFlag = "clean" | "suspicious" | "highly_similar" | "duplicate"; export function classifyScore(score: number): SimilarityFlag { if (score >= SIMILARITY_THRESHOLDS.HIGHLY_SIMILAR) return "highly_similar"; @@ -122,9 +123,41 @@ export function classifyScore(score: number): SimilarityFlag { return "clean"; } -// --------------------------------------------------------------------------- +// ------------------------------------------------------------------ +// Content hashing for duplicate detection +// ------------------------------------------------------------------ + +function hashContent(content: string): string { + return createHash("sha256").update(content.trim()).digest("hex"); +} + +async function findDuplicateByHash( + hash: string, + excludeOnChainId?: string, +): Promise { + const query: Record = { contentHash: hash }; + if (excludeOnChainId) { + query.onChainId = { $ne: excludeOnChainId }; + } + const existing = await Prompt.findOne(query, { onChainId: 1 }).lean(); + return existing?.onChainId ?? null; +} + +/** + * Check if a prompt with the same content hash already exists. + * Can be used before creating a new prompt to prevent duplicates. + */ +export async function checkContentHashExists( + content: string, +): Promise<{ exists: boolean; onChainId: string | null }> { + const hash = hashContent(content); + const onChainId = await findDuplicateByHash(hash); + return { exists: onChainId !== null, onChainId }; +} + +// ------------------------------------------------------------------ // Main scan function: called after a new prompt is indexed -// --------------------------------------------------------------------------- +// ------------------------------------------------------------------ export interface SimilarityResult { flag: SimilarityFlag; @@ -136,6 +169,9 @@ export interface SimilarityResult { * Scan a newly indexed prompt against all existing active prompts. * Updates the Prompt document with the result and returns the result. * + * If an identical content hash exists, immediately flags the prompt as + * "duplicate" and skips the similarity scan. + * * @param onChainId The on-chain ID of the newly created prompt. * @param content The prompt text to compare (title + body combined). */ @@ -143,6 +179,28 @@ export async function scanForSimilarity( onChainId: string, content: string, ): Promise { + const contentHash = hashContent(content); + const duplicateOnChainId = await findDuplicateByHash(contentHash, onChainId); + + if (duplicateOnChainId) { + const flag: SimilarityFlag = "duplicate"; + const score = 1; + await Prompt.findOneAndUpdate( + { onChainId }, + { + $set: { + contentHash, + similarityFlag: flag, + similarityScore: score, + similarTo: duplicateOnChainId, + similarityCheckedAt: new Date(), + }, + }, + ); + console.warn(`[similarity] Prompt ${onChainId} is a duplicate of ${duplicateOnChainId}`); + return { flag, score, similarTo: duplicateOnChainId }; + } + const existing = await Prompt.find( { onChainId: { $ne: onChainId } }, { onChainId: 1, content: 1, title: 1 }, @@ -166,6 +224,7 @@ export async function scanForSimilarity( { onChainId }, { $set: { + contentHash, similarityFlag: flag, similarityScore: maxScore, similarTo: flag !== "clean" ? mostSimilarId : null, @@ -176,8 +235,8 @@ export async function scanForSimilarity( if (flag !== "clean") { console.warn( - `[similarity] Prompt ${onChainId} flagged as "${flag}" ` + - `(score=${maxScore.toFixed(3)}, similar to ${mostSimilarId})`, + `[similarity] Prompt ${onChainId} flagged as "${flag}" \ + (score=${maxScore.toFixed(3)}, similar to ${mostSimilarId})`, ); }