first commit
This commit is contained in:
commit
46aea254d0
19 changed files with 569 additions and 0 deletions
1
.gitignore
vendored
Normal file
1
.gitignore
vendored
Normal file
|
|
@ -0,0 +1 @@
|
|||
.env
|
||||
57
README.md
Normal file
57
README.md
Normal file
|
|
@ -0,0 +1,57 @@
|
|||
# Figma Windows UA Spoofer (Chrome + Firefox)
|
||||
|
||||
`figma.com` / `*.figma.com`에서만 User-Agent를 Windows로 바꾸고,
|
||||
페이지 JS의 `navigator.userAgent`, `navigator.platform`도 Windows 값으로 오버라이드합니다.
|
||||
|
||||
## 빠른 사용
|
||||
|
||||
### Chrome / Edge
|
||||
|
||||
1. `chrome://extensions` (Edge: `edge://extensions`) 접속
|
||||
2. 개발자 모드 ON
|
||||
3. 압축해제된 확장 프로그램 로드
|
||||
4. 프로젝트 폴더 선택
|
||||
|
||||
### Firefox unsigned 빌드
|
||||
|
||||
```bash
|
||||
./build-firefox.sh
|
||||
```
|
||||
|
||||
- 출력: `dist/figma-windows-ua-firefox-unsigned.xpi`
|
||||
- 개발/테스트용 unsigned 패키지
|
||||
|
||||
### Firefox 서명 빌드 (실사용)
|
||||
|
||||
1. AMO(Add-ons Mozilla)에서 API Key/Secret 발급
|
||||
2. `.env`에 값 저장 (자동 로드)
|
||||
|
||||
```dotenv
|
||||
AMO_JWT_ISSUER="<your-api-key>"
|
||||
AMO_JWT_SECRET="<your-api-secret>"
|
||||
```
|
||||
|
||||
3. Nix로 서명 실행 (추천)
|
||||
|
||||
```bash
|
||||
nix run .#sign-firefox
|
||||
```
|
||||
|
||||
- 출력: `dist/signed/*.xpi` (서명 완료)
|
||||
|
||||
## Firefox 설치
|
||||
|
||||
- 임시 로드: `about:debugging#/runtime/this-firefox` → 임시 부가 기능 로드 → `dist/firefox/manifest.json`
|
||||
- 실제 배포/설치: `dist/signed/*.xpi` 사용
|
||||
|
||||
## 확인
|
||||
|
||||
`figma.com`에서 콘솔 실행:
|
||||
|
||||
```js
|
||||
navigator.userAgent
|
||||
navigator.platform
|
||||
navigator.userAgentData?.platform
|
||||
```
|
||||
|
||||
Windows 계열 값이면 정상입니다.
|
||||
29
background.firefox.js
Normal file
29
background.firefox.js
Normal file
|
|
@ -0,0 +1,29 @@
|
|||
const WINDOWS_UA = 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/142.0.7444.163 Safari/537.36';
|
||||
|
||||
browser.webRequest.onBeforeSendHeaders.addListener(
|
||||
(details) => {
|
||||
const headers = details.requestHeaders || [];
|
||||
let found = false;
|
||||
|
||||
for (const header of headers) {
|
||||
if (header.name && header.name.toLowerCase() === 'user-agent') {
|
||||
header.value = WINDOWS_UA;
|
||||
found = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (!found) {
|
||||
headers.push({ name: 'User-Agent', value: WINDOWS_UA });
|
||||
}
|
||||
|
||||
return { requestHeaders: headers };
|
||||
},
|
||||
{
|
||||
urls: [
|
||||
'https://figma.com/*',
|
||||
'https://*.figma.com/*'
|
||||
]
|
||||
},
|
||||
['blocking', 'requestHeaders']
|
||||
);
|
||||
25
build-firefox.sh
Executable file
25
build-firefox.sh
Executable file
|
|
@ -0,0 +1,25 @@
|
|||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
DIST_DIR="$ROOT_DIR/dist/firefox"
|
||||
XPI_PATH="$ROOT_DIR/dist/figma-windows-ua-firefox-unsigned.xpi"
|
||||
|
||||
rm -rf "$DIST_DIR"
|
||||
mkdir -p "$DIST_DIR"
|
||||
|
||||
rm -f "$ROOT_DIR/dist/figma-windows-ua-firefox.xpi"
|
||||
rm -f "$ROOT_DIR/dist/figma-windows-ua-firefox.zip"
|
||||
|
||||
cp "$ROOT_DIR/manifest.firefox.json" "$DIST_DIR/manifest.json"
|
||||
cp "$ROOT_DIR/background.firefox.js" "$DIST_DIR/background.firefox.js"
|
||||
cp "$ROOT_DIR/content.js" "$DIST_DIR/content.js"
|
||||
cp "$ROOT_DIR/injected.js" "$DIST_DIR/injected.js"
|
||||
|
||||
rm -f "$XPI_PATH"
|
||||
(
|
||||
cd "$DIST_DIR"
|
||||
zip -r "$XPI_PATH" .
|
||||
)
|
||||
|
||||
echo "Firefox build created: $XPI_PATH"
|
||||
6
content.js
Normal file
6
content.js
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
(() => {
|
||||
const script = document.createElement('script');
|
||||
script.src = chrome.runtime.getURL('injected.js');
|
||||
script.onload = () => script.remove();
|
||||
(document.head || document.documentElement).appendChild(script);
|
||||
})();
|
||||
BIN
dist/figma-windows-ua-firefox-unsigned.xpi
vendored
Normal file
BIN
dist/figma-windows-ua-firefox-unsigned.xpi
vendored
Normal file
Binary file not shown.
1
dist/firefox/.amo-upload-uuid
vendored
Normal file
1
dist/firefox/.amo-upload-uuid
vendored
Normal file
|
|
@ -0,0 +1 @@
|
|||
{"uploadUuid":"b4b16a82abdb4ce5850b5d0deb76ef3b","channel":"unlisted","xpiCrcHash":"e5567d47cfb9f6133110396308a37ed42afc5be51f6b047ae4643d3f5f063591"}
|
||||
29
dist/firefox/background.firefox.js
vendored
Normal file
29
dist/firefox/background.firefox.js
vendored
Normal file
|
|
@ -0,0 +1,29 @@
|
|||
const WINDOWS_UA = 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/142.0.7444.163 Safari/537.36';
|
||||
|
||||
browser.webRequest.onBeforeSendHeaders.addListener(
|
||||
(details) => {
|
||||
const headers = details.requestHeaders || [];
|
||||
let found = false;
|
||||
|
||||
for (const header of headers) {
|
||||
if (header.name && header.name.toLowerCase() === 'user-agent') {
|
||||
header.value = WINDOWS_UA;
|
||||
found = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (!found) {
|
||||
headers.push({ name: 'User-Agent', value: WINDOWS_UA });
|
||||
}
|
||||
|
||||
return { requestHeaders: headers };
|
||||
},
|
||||
{
|
||||
urls: [
|
||||
'https://figma.com/*',
|
||||
'https://*.figma.com/*'
|
||||
]
|
||||
},
|
||||
['blocking', 'requestHeaders']
|
||||
);
|
||||
6
dist/firefox/content.js
vendored
Normal file
6
dist/firefox/content.js
vendored
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
(() => {
|
||||
const script = document.createElement('script');
|
||||
script.src = chrome.runtime.getURL('injected.js');
|
||||
script.onload = () => script.remove();
|
||||
(document.head || document.documentElement).appendChild(script);
|
||||
})();
|
||||
62
dist/firefox/injected.js
vendored
Normal file
62
dist/firefox/injected.js
vendored
Normal file
|
|
@ -0,0 +1,62 @@
|
|||
(() => {
|
||||
const windowsUA = 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/142.0.7444.163 Safari/537.36';
|
||||
const appVersion = '5.0 (Windows)';
|
||||
|
||||
const override = (obj, key, value) => {
|
||||
try {
|
||||
Object.defineProperty(obj, key, {
|
||||
get: () => value,
|
||||
configurable: true
|
||||
});
|
||||
} catch (error) {
|
||||
// no-op
|
||||
}
|
||||
};
|
||||
|
||||
override(Navigator.prototype, 'userAgent', windowsUA);
|
||||
override(Navigator.prototype, 'appVersion', appVersion);
|
||||
override(Navigator.prototype, 'platform', 'Win32');
|
||||
override(Navigator.prototype, 'oscpu', 'Windows NT 10.0; Win64; x64');
|
||||
|
||||
if (navigator.userAgentData) {
|
||||
const uaData = {
|
||||
brands: [
|
||||
{ brand: 'Chromium', version: '142' },
|
||||
{ brand: 'Google Chrome', version: '142' }
|
||||
],
|
||||
mobile: false,
|
||||
platform: 'Windows',
|
||||
getHighEntropyValues: async (hints) => {
|
||||
const result = {
|
||||
architecture: 'x86',
|
||||
bitness: '64',
|
||||
mobile: false,
|
||||
model: '',
|
||||
platform: 'Windows',
|
||||
platformVersion: '10.0.0',
|
||||
uaFullVersion: '142.0.7444.163',
|
||||
wow64: false
|
||||
};
|
||||
|
||||
if (Array.isArray(hints)) {
|
||||
return hints.reduce((acc, hint) => {
|
||||
if (hint in result) acc[hint] = result[hint];
|
||||
return acc;
|
||||
}, {});
|
||||
}
|
||||
|
||||
return result;
|
||||
},
|
||||
toJSON: () => ({
|
||||
brands: [
|
||||
{ brand: 'Chromium', version: '142' },
|
||||
{ brand: 'Google Chrome', version: '142' }
|
||||
],
|
||||
mobile: false,
|
||||
platform: 'Windows'
|
||||
})
|
||||
};
|
||||
|
||||
override(Navigator.prototype, 'userAgentData', uaData);
|
||||
}
|
||||
})();
|
||||
38
dist/firefox/manifest.json
vendored
Normal file
38
dist/firefox/manifest.json
vendored
Normal file
|
|
@ -0,0 +1,38 @@
|
|||
{
|
||||
"manifest_version": 2,
|
||||
"name": "Figma Windows UA Spoofer",
|
||||
"description": "Spoof Windows User-Agent and platform on figma.com",
|
||||
"version": "1.0.0",
|
||||
"applications": {
|
||||
"gecko": {
|
||||
"id": "figma-windows-ua-spoofer@imnyang.local",
|
||||
"strict_min_version": "109.0"
|
||||
}
|
||||
},
|
||||
"permissions": [
|
||||
"webRequest",
|
||||
"webRequestBlocking",
|
||||
"https://figma.com/*",
|
||||
"https://*.figma.com/*"
|
||||
],
|
||||
"background": {
|
||||
"scripts": [
|
||||
"background.firefox.js"
|
||||
]
|
||||
},
|
||||
"content_scripts": [
|
||||
{
|
||||
"matches": [
|
||||
"https://figma.com/*",
|
||||
"https://*.figma.com/*"
|
||||
],
|
||||
"js": [
|
||||
"content.js"
|
||||
],
|
||||
"run_at": "document_start"
|
||||
}
|
||||
],
|
||||
"web_accessible_resources": [
|
||||
"injected.js"
|
||||
]
|
||||
}
|
||||
BIN
dist/signed/d168a70cb6a24fe4a478-1.0.0.xpi
vendored
Normal file
BIN
dist/signed/d168a70cb6a24fe4a478-1.0.0.xpi
vendored
Normal file
Binary file not shown.
61
flake.lock
generated
Normal file
61
flake.lock
generated
Normal file
|
|
@ -0,0 +1,61 @@
|
|||
{
|
||||
"nodes": {
|
||||
"flake-utils": {
|
||||
"inputs": {
|
||||
"systems": "systems"
|
||||
},
|
||||
"locked": {
|
||||
"lastModified": 1731533236,
|
||||
"narHash": "sha256-l0KFg5HjrsfsO/JpG+r7fRrqm12kzFHyUHqHCVpMMbI=",
|
||||
"owner": "numtide",
|
||||
"repo": "flake-utils",
|
||||
"rev": "11707dc2f618dd54ca8739b309ec4fc024de578b",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
"owner": "numtide",
|
||||
"repo": "flake-utils",
|
||||
"type": "github"
|
||||
}
|
||||
},
|
||||
"nixpkgs": {
|
||||
"locked": {
|
||||
"lastModified": 1772542754,
|
||||
"narHash": "sha256-WGV2hy+VIeQsYXpsLjdr4GvHv5eECMISX1zKLTedhdg=",
|
||||
"owner": "NixOS",
|
||||
"repo": "nixpkgs",
|
||||
"rev": "8c809a146a140c5c8806f13399592dbcb1bb5dc4",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
"owner": "NixOS",
|
||||
"ref": "nixos-unstable",
|
||||
"repo": "nixpkgs",
|
||||
"type": "github"
|
||||
}
|
||||
},
|
||||
"root": {
|
||||
"inputs": {
|
||||
"flake-utils": "flake-utils",
|
||||
"nixpkgs": "nixpkgs"
|
||||
}
|
||||
},
|
||||
"systems": {
|
||||
"locked": {
|
||||
"lastModified": 1681028828,
|
||||
"narHash": "sha256-Vy1rq5AaRuLzOxct8nz4T6wlgyUR7zLU309k9mBC768=",
|
||||
"owner": "nix-systems",
|
||||
"repo": "default",
|
||||
"rev": "da67096a3b9bf56a91d16901293e51ba5b49a27e",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
"owner": "nix-systems",
|
||||
"repo": "default",
|
||||
"type": "github"
|
||||
}
|
||||
}
|
||||
},
|
||||
"root": "root",
|
||||
"version": 7
|
||||
}
|
||||
32
flake.nix
Normal file
32
flake.nix
Normal file
|
|
@ -0,0 +1,32 @@
|
|||
{
|
||||
description = "Figma Windows UA Spoofer - Firefox signing environment";
|
||||
|
||||
inputs = {
|
||||
nixpkgs.url = "github:NixOS/nixpkgs/nixos-unstable";
|
||||
flake-utils.url = "github:numtide/flake-utils";
|
||||
};
|
||||
|
||||
outputs = { self, nixpkgs, flake-utils }:
|
||||
flake-utils.lib.eachDefaultSystem (system:
|
||||
let
|
||||
pkgs = import nixpkgs { inherit system; };
|
||||
in
|
||||
{
|
||||
devShells.default = pkgs.mkShell {
|
||||
packages = with pkgs; [
|
||||
bash
|
||||
zip
|
||||
web-ext
|
||||
nodejs
|
||||
];
|
||||
};
|
||||
|
||||
apps.sign-firefox = {
|
||||
type = "app";
|
||||
program = toString (pkgs.writeShellScript "sign-firefox" ''
|
||||
set -euo pipefail
|
||||
exec ${pkgs.bash}/bin/bash ./sign-firefox.sh
|
||||
'');
|
||||
};
|
||||
});
|
||||
}
|
||||
62
injected.js
Normal file
62
injected.js
Normal file
|
|
@ -0,0 +1,62 @@
|
|||
(() => {
|
||||
const windowsUA = 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/142.0.7444.163 Safari/537.36';
|
||||
const appVersion = '5.0 (Windows)';
|
||||
|
||||
const override = (obj, key, value) => {
|
||||
try {
|
||||
Object.defineProperty(obj, key, {
|
||||
get: () => value,
|
||||
configurable: true
|
||||
});
|
||||
} catch (error) {
|
||||
// no-op
|
||||
}
|
||||
};
|
||||
|
||||
override(Navigator.prototype, 'userAgent', windowsUA);
|
||||
override(Navigator.prototype, 'appVersion', appVersion);
|
||||
override(Navigator.prototype, 'platform', 'Win32');
|
||||
override(Navigator.prototype, 'oscpu', 'Windows NT 10.0; Win64; x64');
|
||||
|
||||
if (navigator.userAgentData) {
|
||||
const uaData = {
|
||||
brands: [
|
||||
{ brand: 'Chromium', version: '142' },
|
||||
{ brand: 'Google Chrome', version: '142' }
|
||||
],
|
||||
mobile: false,
|
||||
platform: 'Windows',
|
||||
getHighEntropyValues: async (hints) => {
|
||||
const result = {
|
||||
architecture: 'x86',
|
||||
bitness: '64',
|
||||
mobile: false,
|
||||
model: '',
|
||||
platform: 'Windows',
|
||||
platformVersion: '10.0.0',
|
||||
uaFullVersion: '142.0.7444.163',
|
||||
wow64: false
|
||||
};
|
||||
|
||||
if (Array.isArray(hints)) {
|
||||
return hints.reduce((acc, hint) => {
|
||||
if (hint in result) acc[hint] = result[hint];
|
||||
return acc;
|
||||
}, {});
|
||||
}
|
||||
|
||||
return result;
|
||||
},
|
||||
toJSON: () => ({
|
||||
brands: [
|
||||
{ brand: 'Chromium', version: '142' },
|
||||
{ brand: 'Google Chrome', version: '142' }
|
||||
],
|
||||
mobile: false,
|
||||
platform: 'Windows'
|
||||
})
|
||||
};
|
||||
|
||||
override(Navigator.prototype, 'userAgentData', uaData);
|
||||
}
|
||||
})();
|
||||
38
manifest.firefox.json
Normal file
38
manifest.firefox.json
Normal file
|
|
@ -0,0 +1,38 @@
|
|||
{
|
||||
"manifest_version": 2,
|
||||
"name": "Figma Windows UA Spoofer",
|
||||
"description": "Spoof Windows User-Agent and platform on figma.com",
|
||||
"version": "1.0.0",
|
||||
"applications": {
|
||||
"gecko": {
|
||||
"id": "figma-windows-ua-spoofer@imnyang.local",
|
||||
"strict_min_version": "109.0"
|
||||
}
|
||||
},
|
||||
"permissions": [
|
||||
"webRequest",
|
||||
"webRequestBlocking",
|
||||
"https://figma.com/*",
|
||||
"https://*.figma.com/*"
|
||||
],
|
||||
"background": {
|
||||
"scripts": [
|
||||
"background.firefox.js"
|
||||
]
|
||||
},
|
||||
"content_scripts": [
|
||||
{
|
||||
"matches": [
|
||||
"https://figma.com/*",
|
||||
"https://*.figma.com/*"
|
||||
],
|
||||
"js": [
|
||||
"content.js"
|
||||
],
|
||||
"run_at": "document_start"
|
||||
}
|
||||
],
|
||||
"web_accessible_resources": [
|
||||
"injected.js"
|
||||
]
|
||||
}
|
||||
45
manifest.json
Normal file
45
manifest.json
Normal file
|
|
@ -0,0 +1,45 @@
|
|||
{
|
||||
"manifest_version": 3,
|
||||
"name": "Figma Windows UA Spoofer",
|
||||
"description": "Spoof Windows User-Agent and platform on figma.com",
|
||||
"version": "1.0.0",
|
||||
"permissions": [
|
||||
"declarativeNetRequest"
|
||||
],
|
||||
"host_permissions": [
|
||||
"https://figma.com/*",
|
||||
"https://*.figma.com/*"
|
||||
],
|
||||
"declarative_net_request": {
|
||||
"rule_resources": [
|
||||
{
|
||||
"id": "ruleset_1",
|
||||
"enabled": true,
|
||||
"path": "rules.json"
|
||||
}
|
||||
]
|
||||
},
|
||||
"content_scripts": [
|
||||
{
|
||||
"matches": [
|
||||
"https://figma.com/*",
|
||||
"https://*.figma.com/*"
|
||||
],
|
||||
"js": [
|
||||
"content.js"
|
||||
],
|
||||
"run_at": "document_start"
|
||||
}
|
||||
],
|
||||
"web_accessible_resources": [
|
||||
{
|
||||
"resources": [
|
||||
"injected.js"
|
||||
],
|
||||
"matches": [
|
||||
"https://figma.com/*",
|
||||
"https://*.figma.com/*"
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
30
rules.json
Normal file
30
rules.json
Normal file
|
|
@ -0,0 +1,30 @@
|
|||
[
|
||||
{
|
||||
"id": 1,
|
||||
"priority": 1,
|
||||
"action": {
|
||||
"type": "modifyHeaders",
|
||||
"requestHeaders": [
|
||||
{
|
||||
"header": "User-Agent",
|
||||
"operation": "set",
|
||||
"value": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/142.0.7444.163 Safari/537.36"
|
||||
}
|
||||
]
|
||||
},
|
||||
"condition": {
|
||||
"regexFilter": "^https:\\/\\/([a-zA-Z0-9-]+\\.)*figma\\.com\\/",
|
||||
"resourceTypes": [
|
||||
"main_frame",
|
||||
"sub_frame",
|
||||
"xmlhttprequest",
|
||||
"script",
|
||||
"image",
|
||||
"font",
|
||||
"stylesheet",
|
||||
"media",
|
||||
"other"
|
||||
]
|
||||
}
|
||||
}
|
||||
]
|
||||
47
sign-firefox.sh
Executable file
47
sign-firefox.sh
Executable file
|
|
@ -0,0 +1,47 @@
|
|||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
SOURCE_DIR="$ROOT_DIR/dist/firefox"
|
||||
SIGNED_DIR="$ROOT_DIR/dist/signed"
|
||||
|
||||
if [ -f "$ROOT_DIR/.env" ]; then
|
||||
set -a
|
||||
# shellcheck disable=SC1091
|
||||
. "$ROOT_DIR/.env"
|
||||
set +a
|
||||
fi
|
||||
|
||||
if [ -z "${AMO_JWT_ISSUER:-}" ] || [ -z "${AMO_JWT_SECRET:-}" ]; then
|
||||
echo "AMO_JWT_ISSUER / AMO_JWT_SECRET 값이 필요합니다."
|
||||
echo "(.env 또는 환경변수에서 읽습니다)"
|
||||
echo "예:"
|
||||
echo " export AMO_JWT_ISSUER=\"<your-api-key>\""
|
||||
echo " export AMO_JWT_SECRET=\"<your-api-secret>\""
|
||||
exit 1
|
||||
fi
|
||||
|
||||
"$ROOT_DIR/build-firefox.sh"
|
||||
mkdir -p "$SIGNED_DIR"
|
||||
|
||||
if command -v web-ext >/dev/null 2>&1; then
|
||||
web-ext sign \
|
||||
--source-dir "$SOURCE_DIR" \
|
||||
--artifacts-dir "$SIGNED_DIR" \
|
||||
--api-key "$AMO_JWT_ISSUER" \
|
||||
--api-secret "$AMO_JWT_SECRET" \
|
||||
--channel unlisted
|
||||
elif command -v npx >/dev/null 2>&1; then
|
||||
npx --yes web-ext sign \
|
||||
--source-dir "$SOURCE_DIR" \
|
||||
--artifacts-dir "$SIGNED_DIR" \
|
||||
--api-key "$AMO_JWT_ISSUER" \
|
||||
--api-secret "$AMO_JWT_SECRET" \
|
||||
--channel unlisted
|
||||
else
|
||||
echo "web-ext 또는 npx가 필요합니다."
|
||||
echo "Nix 사용 시: nix develop -c ./sign-firefox.sh"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "Signed XPI generated under: $SIGNED_DIR"
|
||||
Loading…
Add table
Add a link
Reference in a new issue