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
64 changes: 64 additions & 0 deletions models/Signature.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
import { observable } from 'mobx';
import { BaseModel, persist, restore, toggle } from 'mobx-restful';

import { isServer } from './configuration';

export const buffer2hex = (buffer: ArrayBufferLike) =>
Array.from(new Uint8Array(buffer), x => x.toString(16).padStart(2, '0')).join('');

export class SignatureModel extends BaseModel {
algorithm = { name: 'ECDSA', namedCurve: 'P-384', hash: { name: 'SHA-256' } };

@persist()
@observable
accessor privateKey: CryptoKey | undefined;
Comment thread
TechQuery marked this conversation as resolved.

@persist()
@observable
accessor publicKey = '';

@persist()
@observable
accessor signatureMap = {} as Record<string, string>;

restored = !isServer() && restore(this, 'Signature');

@toggle('uploading')
async makeKeyPair() {
await this.restored;

if (this.publicKey) return this.publicKey;

const { publicKey, privateKey } = await crypto.subtle.generateKey(this.algorithm, true, [
'sign',
'verify',
]);
this.privateKey = privateKey;

const JWK = await crypto.subtle.exportKey('jwk', publicKey);

return (this.publicKey = btoa(JSON.stringify(JWK)));
}

@toggle('uploading')
async sign(value: string) {
await this.restored;

let signature = this.signatureMap[value];

if (signature) return signature;

if (!this.publicKey) await this.makeKeyPair();

const rawSignature = await crypto.subtle.sign(
this.algorithm,
this.privateKey!,
new TextEncoder().encode(value),
);
signature = buffer2hex(rawSignature);

this.signatureMap = { ...this.signatureMap, [value]: signature };

return signature;
}
Comment thread
TechQuery marked this conversation as resolved.
}
44 changes: 22 additions & 22 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -15,11 +15,11 @@
},
"dependencies": {
"@giscus/react": "^3.1.0",
"@koa/router": "^15.2.0",
"@koa/router": "^15.3.0",
"@mdx-js/loader": "^3.1.1",
"@mdx-js/react": "^3.1.1",
"@next/mdx": "^16.1.4",
"core-js": "^3.47.0",
"@next/mdx": "^16.1.6",
"core-js": "^3.48.0",
"echarts-jsx": "^0.6.0",
"file-type": "^21.3.0",
"idea-react": "^2.0.0-rc.13",
Expand All @@ -28,64 +28,64 @@
"koa-jwt": "^4.0.4",
"koajax": "^3.1.2",
"license-filter": "^0.2.5",
"marked": "^17.0.1",
"marked": "^17.0.2",
"mime": "^4.1.0",
"mobx": "^6.15.0",
"mobx-github": "^0.6.2",
"mobx-i18n": "^0.7.2",
"mobx-lark": "^2.6.4",
"mobx-lark": "^2.6.5",
"mobx-react": "^9.2.1",
"mobx-react-helper": "^0.5.1",
"mobx-restful": "^2.1.4",
"mobx-restful-table": "^2.6.3",
"mobx-strapi": "^0.8.1",
"next": "^16.1.4",
"next": "^16.1.6",
"next-pwa": "^5.6.0",
"next-ssr-middleware": "^1.1.0",
"open-react-map": "^0.9.1",
"react": "^19.2.3",
"react": "^19.2.4",
"react-bootstrap": "^2.10.10",
"react-dom": "^19.2.3",
"react-dom": "^19.2.4",
"react-typed-component": "^1.0.6",
"remark-frontmatter": "^5.0.0",
"remark-mdx-frontmatter": "^5.2.0",
"web-utility": "^4.6.4",
"yaml": "^2.8.2"
},
"devDependencies": {
"@babel/plugin-proposal-decorators": "^7.28.6",
"@babel/plugin-proposal-decorators": "^7.29.0",
"@babel/plugin-transform-typescript": "^7.28.6",
"@babel/preset-react": "^7.28.5",
"@cspell/eslint-plugin": "^9.6.0",
"@eslint/js": "^9.39.2",
"@next/eslint-plugin-next": "^16.1.4",
"@cspell/eslint-plugin": "^9.6.4",
"@eslint/js": "^10.0.1",
"@next/eslint-plugin-next": "^16.1.6",
"@open-source-bazaar/china-ngo-database": "^0.6.0",
"@softonus/prettier-plugin-duplicate-remover": "^1.1.2",
"@stylistic/eslint-plugin": "^5.7.0",
"@stylistic/eslint-plugin": "^5.8.0",
"@types/eslint-config-prettier": "^6.11.3",
"@types/jsonwebtoken": "^9.0.10",
"@types/koa": "^3.0.1",
"@types/next-pwa": "^5.6.9",
"@types/node": "^24.10.9",
"@types/react": "^19.2.8",
"@types/node": "^24.10.13",
"@types/react": "^19.2.14",
"@types/react-dom": "^19.2.3",
"eslint": "^9.39.2",
"eslint-config-next": "^16.1.4",
"eslint": "^10.0.0",
"eslint-config-next": "^16.1.6",
"eslint-config-prettier": "^10.1.8",
"eslint-plugin-react": "^7.37.5",
"eslint-plugin-simple-import-sort": "^12.1.1",
"globals": "^17.0.0",
"globals": "^17.3.0",
"husky": "^9.1.7",
"jiti": "^2.6.1",
"less": "^4.5.1",
"less-loader": "^12.3.0",
"less-loader": "^12.3.1",
"lint-staged": "^16.2.7",
"next-with-less": "^3.0.1",
"prettier": "^3.8.0",
"prettier": "^3.8.1",
"prettier-plugin-css-order": "^2.2.0",
"sass": "^1.97.2",
"sass": "^1.97.3",
"typescript": "~5.9.3",
"typescript-eslint": "^8.53.1"
"typescript-eslint": "^8.55.0"
},
"resolutions": {
"mobx-react-helper": "$mobx-react-helper",
Expand Down
24 changes: 24 additions & 0 deletions pages/api/signature/[...slug].ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
import { createKoaRouter, withKoaRouter } from 'next-ssr-middleware';

import { safeAPI } from '../core';

export const config = { api: { bodyParser: false } };

const router = createKoaRouter(import.meta.url);

router.post('/verification', safeAPI, async context => {
const { algorithm, publicKey, value, signature } = Reflect.get(context.request, 'body');

const rawAlgorithm = JSON.parse(atob(algorithm)),
rawPublicKey = JSON.parse(atob(publicKey)),
rawSignature = Buffer.from(signature, 'hex'),
encodedValue = new TextEncoder().encode(value);

const key = await crypto.subtle.importKey('jwk', rawPublicKey, rawAlgorithm, true, ['verify']);
const verified = await crypto.subtle.verify(rawAlgorithm, key, rawSignature, encodedValue);

context.status = verified ? 200 : 400;
Comment thread
TechQuery marked this conversation as resolved.
context.body = {};
});

export default withKoaRouter(router);
76 changes: 76 additions & 0 deletions pages/signature.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
import { computed, observable } from 'mobx';
import { textJoin } from 'mobx-i18n';
import { observer } from 'mobx-react';
import { ObservedComponent } from 'mobx-react-helper';
import { compose, RouteProps, router } from 'next-ssr-middleware';
import { Container } from 'react-bootstrap';
import { buildURLData } from 'web-utility';

import { PageHead } from '../components/Layout/PageHead';
import { i18n, I18nContext } from '../models/Translation';
import { SignatureModel } from '../models/Signature';

export const getServerSideProps = compose(router);

@observer
export default class SignaturePage extends ObservedComponent<RouteProps, typeof i18n> {
static contextType = I18nContext;

@observable
accessor signatureStore = new SignatureModel();

@computed
get linkData() {
const { route } = this.observedProps;
const { valueName, algorithmName, publicKeyName, signatureName, value } = route.query,
{ algorithm, publicKey } = this.signatureStore;
const signature = this.signatureStore.signatureMap[value + ''];

return buildURLData({
[valueName + '']: value,
[algorithmName + '']: btoa(JSON.stringify(algorithm)),
[publicKeyName + '']: publicKey,
[signatureName + '']: signature,
});
}
Comment on lines +23 to +35
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

当查询参数缺失时,linkData 会生成无意义的键名

valueNamealgorithmNamepublicKeyNamesignatureName 均可能为 undefined+ '' 强转后得到 "undefined" 作为 URL 参数名。建议对 iframeLink 及这些映射名参数做存在性检查,在参数缺失时返回空字符串或跳过构建。

🤖 Prompt for AI Agents
In `@pages/signature.tsx` around lines 23 - 35, linkData currently uses valueName,
algorithmName, publicKeyName, signatureName directly from
this.observedProps.route.query and concatenates '' which produces keys like
"undefined" when those query names are missing; update the linkData getter to
validate each query key (valueName, algorithmName, publicKeyName, signatureName)
before adding to the object passed to buildURLData — either skip entries whose
names are falsy or map them to an empty string, and ensure the signature lookup
this.signatureStore.signatureMap[value + ''] is only used if signatureName is
defined; this change should be made inside the linkData getter and affects the
object passed to buildURLData so no invalid "undefined" parameter names are
produced.


componentDidMount() {
const { value = '' } = this.props.route.query;

if (!value) this.signatureStore.makeKeyPair();
else this.signatureStore.sign(value + '');
}
Comment thread
TechQuery marked this conversation as resolved.

render() {
const { t } = this.observedContext,
{ value, iframeLink } = this.props.route.query;

const title = value ? textJoin(t('sign'), value + '') : t('generate_key_pair'),
link = `${iframeLink}?${this.linkData}`;

return (
<Container>
<PageHead title={title} />

<h1 className="my-5 text-truncate">{title}</h1>

<section className="markdown-body bg-white py-4">
<blockquote>{t('signature_disclaimer')}</blockquote>
<pre>
<code>
<a href={link} target="_blank" rel="noopener noreferrer">
{link}
</a>
</code>
</pre>
</section>

<iframe
className="border-0 w-100 vh-100"
sandbox="allow-scripts allow-same-origin allow-forms"
src={link}
/>
Comment on lines +44 to +72
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

iframeLink 缺失时,页面渲染出无效的 URL 和 iframe。

如果用户直接访问 /signature 而不带 iframeLink 查询参数(例如仅生成密钥对的场景),Line 49 会拼出 "undefined?..." 作为 link,导致:

  1. <a href="undefined?..."> 指向无效地址
  2. <iframe src="undefined?..."> 加载失败

应在 iframeLink 存在时才渲染链接和 iframe 部分,否则可只展示签名/密钥对的结果信息。

🐛 建议修复:条件渲染 iframe 区域
  render() {
    const { t } = this.observedContext,
      { value, iframeLink } = this.props.route.query;

    const title = value ? textJoin(t('sign'), value + '') : t('generate_key_pair'),
-     link = `${iframeLink}?${this.linkData}`;
+     link = iframeLink ? `${iframeLink}?${this.linkData}` : '';

    return (
      <Container>
        <PageHead title={title} />

        <h1 className="my-5 text-truncate">{title}</h1>

        <section className="markdown-body bg-white py-4">
          <blockquote>{t('signature_disclaimer')}</blockquote>
-         <pre>
-           <code>
-             <a href={link} target="_blank" rel="noopener noreferrer">
-               {link}
-             </a>
-           </code>
-         </pre>
+         {link && (
+           <pre>
+             <code>
+               <a href={link} target="_blank" rel="noopener noreferrer">
+                 {link}
+               </a>
+             </code>
+           </pre>
+         )}
        </section>

-       <iframe
-         className="border-0 w-100 vh-100"
-         sandbox="allow-scripts allow-same-origin allow-forms"
-         src={link}
-       />
+       {link && (
+         <iframe
+           className="border-0 w-100 vh-100"
+           sandbox="allow-scripts allow-same-origin allow-forms"
+           src={link}
+         />
+       )}
      </Container>
    );
  }
🤖 Prompt for AI Agents
In `@pages/signature.tsx` around lines 44 - 72, The render builds link =
`${iframeLink}?${this.linkData}` and always renders the anchor and iframe even
when iframeLink is undefined; change render() (pages/signature.tsx) to only
compute/link and render the <a> and <iframe> when iframeLink is truthy: move the
link construction (using iframeLink and this.linkData) inside a conditional and
conditionally render the <pre>...<a> and the <iframe> elements; otherwise render
just the title/markdown info (so functions/props to edit: render, the iframeLink
variable from this.props.route.query, and the link variable).

</Container>
);
}
}
Loading
Loading