-
Notifications
You must be signed in to change notification settings - Fork 11
feat: Implement OCToken NFT Open Collaborator Award mechanism (Fixes #53) #76
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
5bf0829
5407ceb
695db1f
e3ea704
179196a
d893ec6
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,161 @@ | ||
| import { Context } from 'koa'; | ||
| import { createKoaRouter, withKoaRouter } from 'next-ssr-middleware'; | ||
| import { AwardModel } from '../../../../models/Award'; | ||
| import { safeAPI, verifyJWT } from '../../core'; | ||
|
|
||
| export const config = { api: { bodyParser: true } }; | ||
|
|
||
| const router = createKoaRouter(import.meta.url); | ||
|
|
||
| const EthereumAddressPattern = /^0x[a-fA-F0-9]{40}$/; | ||
|
|
||
| const getUserIds = (field: any): string[] => { | ||
| if (!field) return []; | ||
| if (Array.isArray(field)) { | ||
| return field.map(u => (typeof u === 'object' && u ? u.id : String(u))).filter(Boolean); | ||
| } | ||
| if (typeof field === 'object' && field) { | ||
| return [field.id].filter(Boolean); | ||
| } | ||
| return [String(field)]; | ||
| }; | ||
|
|
||
| const getUserNames = (field: any): string[] => { | ||
| if (!field) return []; | ||
| if (Array.isArray(field)) { | ||
| return field | ||
| .map(u => (typeof u === 'object' && u ? u.name || u.id : String(u))) | ||
| .filter(Boolean); | ||
| } | ||
| if (typeof field === 'object' && field) { | ||
| return [field.name || field.id].filter(Boolean); | ||
| } | ||
| return [String(field)]; | ||
| }; | ||
|
|
||
| router.post('/issue', safeAPI, verifyJWT, async (context: Context) => { | ||
|
coderabbitai[bot] marked this conversation as resolved.
|
||
| const { recordId, walletAddress } = (context.request as any).body; | ||
|
|
||
| if (!recordId || !walletAddress) { | ||
| context.throw(400, 'recordId and walletAddress are required'); | ||
| } | ||
|
|
||
| if (typeof walletAddress !== 'string' || !EthereumAddressPattern.test(walletAddress)) { | ||
| context.throw(400, 'walletAddress must be a valid Ethereum address'); | ||
| } | ||
|
|
||
| // 1. Fetch award and check authorization | ||
| const awardModel = new AwardModel(); | ||
| const award = await awardModel.getOne(recordId); | ||
|
|
||
| if (!award) { | ||
| context.throw(404, 'Award record not found'); | ||
| } | ||
|
|
||
| const currentUser = (context.state as any).user; | ||
| if (!currentUser) { | ||
| context.throw(401, 'Unauthorized'); | ||
| } | ||
|
|
||
| const nominators = getUserIds(award.nominator); | ||
| const nomineeNames = getUserIds(award.nomineeName); | ||
| const nominatorNames = getUserNames(award.nominator); | ||
| const nomineeUserNames = getUserNames(award.nomineeName); | ||
| const currentUserIdStr = String(currentUser.id); | ||
|
|
||
| if ( | ||
| currentUser.id !== 0 && // Robot bypass by stable ID 0 | ||
| !nominators.includes(currentUserIdStr) && | ||
| !nomineeNames.includes(currentUserIdStr) && | ||
| !nominatorNames.includes(currentUser.name) && | ||
| !nomineeUserNames.includes(currentUser.name) | ||
| ) { | ||
|
Comment on lines
+66
to
+72
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🔒 Security & Privacy | 🟠 Major 🧩 Analysis chain🏁 Script executed: #!/bin/bash
# 验证 verifyJWT 写入 context.state.user 的字段,以及是否已有稳定 role/id 可复用。
rg -nP -C4 '\bverifyJWT\b|context\.state\.user|state\.user|isAdmin|role|userId|email|name' pages models --type=tsRepository: Open-Source-Bazaar/Open-Source-Bazaar.github.io Length of output: 47055 不要用展示名和
🤖 Prompt for AI Agents |
||
| context.throw(403, 'You do not have permission to issue this award'); | ||
| } | ||
|
|
||
| // 2. Concurrency Lock check | ||
| if (award.transactionHash === 'ISSUING') { | ||
| context.throw(409, 'An NFT issuance request is already in progress for this award'); | ||
| } | ||
|
|
||
| // 3. Idempotency and Wallet binding check | ||
| if (award.transactionHash && award.tokenId) { | ||
| if (award.walletAddress && award.walletAddress !== walletAddress) { | ||
| context.throw(409, 'This award has already been issued to a different wallet address'); | ||
| } | ||
| context.body = { | ||
| success: true, | ||
| transactionHash: award.transactionHash as string, | ||
| tokenId: award.tokenId as string, | ||
| }; | ||
| return; | ||
| } | ||
|
|
||
| // 4. Acquire lock | ||
| await awardModel.updateOne( | ||
| { | ||
| transactionHash: 'ISSUING', | ||
| walletAddress, | ||
| }, | ||
| recordId, | ||
| ); | ||
|
|
||
| let transactionHash: string; | ||
| let tokenId: string; | ||
|
|
||
| try { | ||
| const mintApiUrl = process.env.NFT_MINT_API || 'https://api.octoken.org/mint'; | ||
| const timeoutVal = parseInt(process.env.NFT_MINT_TIMEOUT || '10000', 10); | ||
| const controller = new AbortController(); | ||
| const timeout = setTimeout(() => controller.abort(), timeoutVal); | ||
|
|
||
| const response = await fetch(mintApiUrl, { | ||
| method: 'POST', | ||
| headers: { 'Content-Type': 'application/json' }, | ||
| body: JSON.stringify({ walletAddress, recordId }), | ||
| signal: controller.signal, | ||
| }).finally(() => clearTimeout(timeout)); | ||
|
|
||
| if (!response.ok) { | ||
| throw new Error(`NFT issuance failed with status ${response.status}`); | ||
| } | ||
|
|
||
| const data = await response.json(); | ||
| transactionHash = data.transactionHash; | ||
| tokenId = data.tokenId; | ||
|
|
||
| if (!transactionHash || !tokenId) { | ||
| throw new Error('Invalid response from minting service'); | ||
| } | ||
| } catch (error) { | ||
| // Revert the concurrency lock on failure | ||
| await awardModel | ||
| .updateOne( | ||
| { | ||
| transactionHash: '', | ||
| }, | ||
| recordId, | ||
| ) | ||
| .catch(e => console.error('Failed to revert transaction lock:', e)); | ||
|
|
||
| const msg = | ||
| (error as Error).name === 'AbortError' | ||
| ? 'NFT issuance request timed out' | ||
| : (error as Error).message || 'NFT issuance failed'; | ||
| return context.throw(502, msg); | ||
| } | ||
|
coderabbitai[bot] marked this conversation as resolved.
|
||
|
|
||
| // 5. Finalize the record with actual transaction details | ||
| await awardModel.updateOne( | ||
| { | ||
| transactionHash, | ||
| tokenId, | ||
| walletAddress, | ||
| }, | ||
| recordId, | ||
| ); | ||
|
coderabbitai[bot] marked this conversation as resolved.
|
||
|
|
||
| context.body = { success: true, transactionHash, tokenId }; | ||
| }); | ||
|
|
||
| export default withKoaRouter(router); | ||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
🔒 Security & Privacy | 🟠 Major | ⚡ Quick win
把
getUserIds的返回值真正归一化为字符串。当前对象分支会返回原始
id,且.filter(Boolean)会丢掉数值0;但 Line 64 后续用字符串 ID 做includes,这会导致 ID 授权误判并落到展示名兜底。建议修复
const getUserIds = (field: any): string[] => { if (!field) return []; if (Array.isArray(field)) { - return field.map(u => (typeof u === 'object' && u ? u.id : String(u))).filter(Boolean); + return field + .map(u => (typeof u === 'object' && u ? u.id : u)) + .filter(id => id !== undefined && id !== null && id !== '') + .map(String); } if (typeof field === 'object' && field) { - return [field.id].filter(Boolean); + return field.id === undefined || field.id === null || field.id === '' + ? [] + : [String(field.id)]; } return [String(field)]; };Also applies to: 64-69
🤖 Prompt for AI Agents