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;

@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;
}
}
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;
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,
});
}

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

if (!value) this.signatureStore.makeKeyPair();
else this.signatureStore.sign(value + '');
}

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

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