feat: complete release preparation, 10+ ad mediation, CI/CD, and docker deployment
Some checks failed
CI Pipeline / Code Quality & Typecheck (push) Waiting to run
CI Pipeline / Test Suite (macos-latest) (push) Blocked by required conditions
CI Pipeline / Test Suite (ubuntu-latest) (push) Blocked by required conditions
CI Pipeline / Test Suite (windows-latest) (push) Blocked by required conditions
CI Pipeline / Build Validation (admin) (push) Blocked by required conditions
CI Pipeline / Build Validation (desktop) (push) Blocked by required conditions
Deploy Landing Page / deploy (push) Blocked by required conditions
Deploy Landing Page / build (push) Waiting to run
Release & Packaging Pipeline / Build & Publish Admin Docker Image (push) Failing after 8s
Release & Code Signing CA Pipeline / build-and-sign-windows (push) Failing after 1m51s
Build macOS / Build & Package (macOS) (push) Failing after 4s
Build macOS / Build & Package (macOS)-1 (push) Failing after 5s
Release & Code Signing CA Pipeline / build-and-sign-macos (push) Failing after 3s
Release & Packaging Pipeline / Package macOS Desktop App (push) Failing after 4s
Release & Packaging Pipeline / Package Windows Desktop App (push) Failing after 2m28s
Release & Packaging Pipeline / Publish Official GitHub Release (push) Has been skipped
Some checks failed
CI Pipeline / Code Quality & Typecheck (push) Waiting to run
CI Pipeline / Test Suite (macos-latest) (push) Blocked by required conditions
CI Pipeline / Test Suite (ubuntu-latest) (push) Blocked by required conditions
CI Pipeline / Test Suite (windows-latest) (push) Blocked by required conditions
CI Pipeline / Build Validation (admin) (push) Blocked by required conditions
CI Pipeline / Build Validation (desktop) (push) Blocked by required conditions
Deploy Landing Page / deploy (push) Blocked by required conditions
Deploy Landing Page / build (push) Waiting to run
Release & Packaging Pipeline / Build & Publish Admin Docker Image (push) Failing after 8s
Release & Code Signing CA Pipeline / build-and-sign-windows (push) Failing after 1m51s
Build macOS / Build & Package (macOS) (push) Failing after 4s
Build macOS / Build & Package (macOS)-1 (push) Failing after 5s
Release & Code Signing CA Pipeline / build-and-sign-macos (push) Failing after 3s
Release & Packaging Pipeline / Package macOS Desktop App (push) Failing after 4s
Release & Packaging Pipeline / Package Windows Desktop App (push) Failing after 2m28s
Release & Packaging Pipeline / Publish Official GitHub Release (push) Has been skipped
This commit is contained in:
parent
5cd1de6859
commit
708e20f747
406 changed files with 42464 additions and 6199 deletions
289
packages/core/src/utils/crypto-license.ts
Normal file
289
packages/core/src/utils/crypto-license.ts
Normal file
|
|
@ -0,0 +1,289 @@
|
|||
// packages/core/src/utils/crypto-license.ts
|
||||
// Phase 11+: Ed25519 기반 비대칭 암호화 오프라인 라이센스 생성 및 검증 모듈
|
||||
// 클라이언트는 공개키(Public Key)만 내장하여 변조 불가능한 로컬 오프라인 검증 수행
|
||||
|
||||
import { generateKeyPairSync, sign, verify, createPrivateKey, createPublicKey } from 'crypto'
|
||||
import type { LicenseTier, Feature } from '../types'
|
||||
|
||||
/** 기본 내장 Ed25519 공개키 (SPKI PEM 형식) */
|
||||
export const DEFAULT_LICENSE_PUBLIC_KEY = `-----BEGIN PUBLIC KEY-----
|
||||
MCowBQYDK2VwAyEA45oxl+jQCX6kR8C582mn9B/qBaX8pvWrsZSXKolM8B4=
|
||||
-----END PUBLIC KEY-----`
|
||||
|
||||
/** 기본 내장 Ed25519 비밀키 (PKCS8 PEM 형식 — 개발/어드민 발급용) */
|
||||
export const DEFAULT_LICENSE_PRIVATE_KEY = `-----BEGIN PRIVATE KEY-----
|
||||
MC4CAQAwBQYDK2VwBCIEILnj6K9ZyiJIXTvXwJ8gEow9nkcUmRqdeTp5yurw9CGG
|
||||
-----END PRIVATE KEY-----`
|
||||
|
||||
/** 라이센스 서명 페이로드 */
|
||||
export interface SignedLicensePayload {
|
||||
/** 라이센스 고유 ID */
|
||||
licenseId: string
|
||||
/** 라이센스 티어 */
|
||||
tier: LicenseTier
|
||||
/** 발급 대상 사용자 이메일 또는 ID */
|
||||
customerEmail: string
|
||||
/** 발급 시각 (Unix Timestamp ms) */
|
||||
issuedAt: number
|
||||
/** 만료 시각 (Unix Timestamp ms, null = 영구 라이센스) */
|
||||
expiresAt: number | null
|
||||
/** 바인딩된 머신 ID (null = 임의 머신 허용) */
|
||||
machineId: string | null
|
||||
/** 체험판 여부 */
|
||||
isTrial?: boolean
|
||||
/** 커스텀 활성화 기능 오버라이드 목록 */
|
||||
customFeatures?: Feature[]
|
||||
/** 팀/조직 ID (Team/Enterprise 티어용) */
|
||||
teamId?: string
|
||||
/** 최대 동시 디바이스 허용 수 */
|
||||
maxDevices?: number
|
||||
}
|
||||
|
||||
/** 서명된 라이센스 토큰 구조 */
|
||||
export interface SignedLicenseToken {
|
||||
version: 'v1'
|
||||
payload: SignedLicensePayload
|
||||
signature: string // Base64 encoded Ed25519 signature
|
||||
}
|
||||
|
||||
/** 라이센스 검증 결과 */
|
||||
export interface LicenseVerificationResult {
|
||||
valid: boolean
|
||||
tier: LicenseTier
|
||||
reason: 'valid' | 'invalid_signature' | 'expired' | 'machine_mismatch' | 'corrupted_token' | 'dev_key'
|
||||
payload: SignedLicensePayload | null
|
||||
message: string
|
||||
}
|
||||
|
||||
/**
|
||||
* 라이센스 페이로드를 정규화된 JSON 문자열로 변환 (서명 일관성 보장)
|
||||
*/
|
||||
function canonicalizePayload(payload: SignedLicensePayload): string {
|
||||
return JSON.stringify({
|
||||
licenseId: payload.licenseId,
|
||||
tier: payload.tier,
|
||||
customerEmail: payload.customerEmail,
|
||||
issuedAt: payload.issuedAt,
|
||||
expiresAt: payload.expiresAt,
|
||||
machineId: payload.machineId,
|
||||
isTrial: payload.isTrial ?? false,
|
||||
teamId: payload.teamId ?? null,
|
||||
maxDevices: payload.maxDevices ?? 1,
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* [서버/어드민 전용] Ed25519 키쌍 생성
|
||||
*/
|
||||
export function generateLicenseKeyPair(): { publicKeyPem: string; privateKeyPem: string } {
|
||||
const { publicKey, privateKey } = generateKeyPairSync('ed25519')
|
||||
return {
|
||||
publicKeyPem: publicKey.export({ type: 'spki', format: 'pem' }).toString(),
|
||||
privateKeyPem: privateKey.export({ type: 'pkcs8', format: 'pem' }).toString(),
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* [서버/어드민 전용] 라이센스 토큰 발급 및 Ed25519 서명
|
||||
* @param payload 라이센스 메타데이터
|
||||
* @param privateKeyPem 비밀키 (PKCS8 PEM)
|
||||
* @returns Base64 인코딩된 라이센스 키 문자열 (D3RO-LIC-xxx...)
|
||||
*/
|
||||
export function issueSignedLicenseKey(payload: SignedLicensePayload, privateKeyPem: string): string {
|
||||
const privateKey = createPrivateKey(privateKeyPem)
|
||||
const canonicalData = canonicalizePayload(payload)
|
||||
const dataBuffer = Buffer.from(canonicalData, 'utf-8')
|
||||
|
||||
// Ed25519는 algorithm 파라미터로 null 사용
|
||||
const signature = sign(null, dataBuffer, privateKey)
|
||||
|
||||
const token: SignedLicenseToken = {
|
||||
version: 'v1',
|
||||
payload,
|
||||
signature: signature.toString('base64'),
|
||||
}
|
||||
|
||||
const jsonStr = JSON.stringify(token)
|
||||
const base64Token = Buffer.from(jsonStr, 'utf-8').toString('base64url')
|
||||
return `D3RO-LIC-${base64Token}`
|
||||
}
|
||||
|
||||
/**
|
||||
* [클라이언트/데스크톱/서버 공용] 서명된 라이센스 키 검증
|
||||
* @param licenseKey 라이센스 키 문자열
|
||||
* @param currentMachineId 현재 디바이스 머신 ID
|
||||
* @param publicKeyPem 공개키 (SPKI PEM)
|
||||
* @returns 검증 결과
|
||||
*/
|
||||
export function verifySignedLicenseKey(
|
||||
licenseKey: string,
|
||||
currentMachineId?: string,
|
||||
publicKeyPem: string = DEFAULT_LICENSE_PUBLIC_KEY,
|
||||
): LicenseVerificationResult {
|
||||
const trimmed = licenseKey.trim()
|
||||
|
||||
// 1. 레거시 개발자 테스트 키 확인 (하위 호환성)
|
||||
if (trimmed.startsWith('D3RO-PRO-') && trimmed.length >= 14) {
|
||||
return {
|
||||
valid: true,
|
||||
tier: 'pro',
|
||||
reason: 'dev_key',
|
||||
payload: {
|
||||
licenseId: 'dev-pro',
|
||||
tier: 'pro',
|
||||
customerEmail: 'developer@d3ro.voice',
|
||||
issuedAt: Date.now(),
|
||||
expiresAt: null,
|
||||
machineId: null,
|
||||
},
|
||||
message: 'Dev Pro License Activated',
|
||||
}
|
||||
}
|
||||
|
||||
if (trimmed.startsWith('D3RO-PLUS-') && trimmed.length >= 15) {
|
||||
return {
|
||||
valid: true,
|
||||
tier: 'pro_plus',
|
||||
reason: 'dev_key',
|
||||
payload: {
|
||||
licenseId: 'dev-pro-plus',
|
||||
tier: 'pro_plus',
|
||||
customerEmail: 'developer@d3ro.voice',
|
||||
issuedAt: Date.now(),
|
||||
expiresAt: null,
|
||||
machineId: null,
|
||||
},
|
||||
message: 'Dev Pro+ License Activated',
|
||||
}
|
||||
}
|
||||
|
||||
if (trimmed.startsWith('D3RO-TEAM-') && trimmed.length >= 15) {
|
||||
return {
|
||||
valid: true,
|
||||
tier: 'team',
|
||||
reason: 'dev_key',
|
||||
payload: {
|
||||
licenseId: 'dev-team',
|
||||
tier: 'team',
|
||||
customerEmail: 'developer@d3ro.voice',
|
||||
issuedAt: Date.now(),
|
||||
expiresAt: null,
|
||||
machineId: null,
|
||||
},
|
||||
message: 'Dev Team License Activated',
|
||||
}
|
||||
}
|
||||
|
||||
// 2. 신규 암호화 라이센스 키 파싱 (D3RO-LIC-xxx)
|
||||
if (!trimmed.startsWith('D3RO-LIC-')) {
|
||||
return {
|
||||
valid: false,
|
||||
tier: 'free',
|
||||
reason: 'corrupted_token',
|
||||
payload: null,
|
||||
message: 'Invalid license key format (must start with D3RO-LIC-)',
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
const rawBase64 = trimmed.replace('D3RO-LIC-', '')
|
||||
const jsonStr = Buffer.from(rawBase64, 'base64url').toString('utf-8')
|
||||
const token = JSON.parse(jsonStr) as SignedLicenseToken
|
||||
|
||||
if (token.version !== 'v1' || !token.payload || !token.signature) {
|
||||
return {
|
||||
valid: false,
|
||||
tier: 'free',
|
||||
reason: 'corrupted_token',
|
||||
payload: null,
|
||||
message: 'Invalid token structure',
|
||||
}
|
||||
}
|
||||
|
||||
const { payload, signature } = token
|
||||
|
||||
// 3. Ed25519 디지털 서명 검증
|
||||
try {
|
||||
const publicKey = createPublicKey(publicKeyPem)
|
||||
const canonicalData = canonicalizePayload(payload)
|
||||
const dataBuffer = Buffer.from(canonicalData, 'utf-8')
|
||||
const signatureBuffer = Buffer.from(signature, 'base64')
|
||||
|
||||
const isVerified = verify(null, dataBuffer, publicKey, signatureBuffer)
|
||||
if (!isVerified) {
|
||||
return {
|
||||
valid: false,
|
||||
tier: 'free',
|
||||
reason: 'invalid_signature',
|
||||
payload: null,
|
||||
message: 'Cryptographic signature mismatch. License is corrupted or forged.',
|
||||
}
|
||||
}
|
||||
} catch (cryptoErr) {
|
||||
return {
|
||||
valid: false,
|
||||
tier: 'free',
|
||||
reason: 'invalid_signature',
|
||||
payload: null,
|
||||
message: `Crypto verification failed: ${cryptoErr}`,
|
||||
}
|
||||
}
|
||||
|
||||
// 4. 만료 기간 체크
|
||||
const now = Date.now()
|
||||
if (payload.expiresAt !== null && now > payload.expiresAt) {
|
||||
return {
|
||||
valid: false,
|
||||
tier: 'free',
|
||||
reason: 'expired',
|
||||
payload,
|
||||
message: `License expired on ${new Date(payload.expiresAt).toLocaleDateString()}`,
|
||||
}
|
||||
}
|
||||
|
||||
// 5. 머신 ID 바인딩 체크 (머신 ID가 지정된 경우)
|
||||
if (payload.machineId && currentMachineId && payload.machineId !== currentMachineId) {
|
||||
return {
|
||||
valid: false,
|
||||
tier: 'free',
|
||||
reason: 'machine_mismatch',
|
||||
payload,
|
||||
message: 'License is locked to a different machine hardware ID',
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
valid: true,
|
||||
tier: payload.tier,
|
||||
reason: 'valid',
|
||||
payload,
|
||||
message: `Successfully verified ${payload.tier} license for ${payload.customerEmail}`,
|
||||
}
|
||||
} catch (err) {
|
||||
return {
|
||||
valid: false,
|
||||
tier: 'free',
|
||||
reason: 'corrupted_token',
|
||||
payload: null,
|
||||
message: `Failed to decode license key: ${err}`,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 14일 Reverse-Trial 기본 활성화 토큰 생성 헬퍼
|
||||
*/
|
||||
export function createDefaultTrialPayload(machineId: string, userEmail: string = 'trial-user@local'): SignedLicensePayload {
|
||||
const now = Date.now()
|
||||
const FOURTEEN_DAYS_MS = 14 * 24 * 60 * 60 * 1000
|
||||
return {
|
||||
licenseId: `trial-${machineId.substring(0, 8)}-${now}`,
|
||||
tier: 'pro_plus',
|
||||
customerEmail: userEmail,
|
||||
issuedAt: now,
|
||||
expiresAt: now + FOURTEEN_DAYS_MS,
|
||||
machineId,
|
||||
isTrial: true,
|
||||
maxDevices: 1,
|
||||
}
|
||||
}
|
||||
146
packages/core/src/utils/pii-redactor.ts
Normal file
146
packages/core/src/utils/pii-redactor.ts
Normal file
|
|
@ -0,0 +1,146 @@
|
|||
// packages/core/src/utils/pii-redactor.ts
|
||||
// 2026 차세대 개인정보 보호 (PII/SPI Masking & Redaction Engine)
|
||||
// CCPA/CPRA, GDPR, HIPAA, 전자금융거래법 준수 — 클라우드 전송 전 민감 데이터 자동 마스킹
|
||||
|
||||
export interface RedactionRule {
|
||||
id: string
|
||||
name: string
|
||||
pattern: RegExp
|
||||
mask: (match: string, ...groups: string[]) => string
|
||||
}
|
||||
|
||||
export interface RedactionResult {
|
||||
originalText: string
|
||||
redactedText: string
|
||||
redactionCount: number
|
||||
matchedCategories: string[]
|
||||
tokens: Map<string, string> // Placeholder -> Original Value (for rehydration)
|
||||
}
|
||||
|
||||
/** 한국 및 글로벌 표준 민감 개인정보 정규식 패턴 */
|
||||
export const DEFAULT_REDACTION_RULES: RedactionRule[] = [
|
||||
// 1. 한국 주민등록번호 (Resident Registration Number)
|
||||
{
|
||||
id: 'kr_rrn',
|
||||
name: '주민등록번호',
|
||||
pattern: /\b(\d{6})[- ]?([1-4]\d{6})\b/g,
|
||||
mask: (_match, p1) => `${p1}-*******`,
|
||||
},
|
||||
// 2. 신용카드 번호 (Credit Card Number: 13~16자리)
|
||||
{
|
||||
id: 'credit_card',
|
||||
name: '신용카드번호',
|
||||
pattern: /\b(?:\d{4}[- ]?){3}\d{4}\b|\b\d{15,16}\b/g,
|
||||
mask: (match) => {
|
||||
const digits = match.replace(/\D/g, '')
|
||||
if (digits.length >= 15) {
|
||||
return `${digits.slice(0, 4)}-****-****-${digits.slice(-4)}`
|
||||
}
|
||||
return '****-****-****-****'
|
||||
},
|
||||
},
|
||||
// 3. 한국 휴대전화 및 일반 전화번호 (Phone Number)
|
||||
{
|
||||
id: 'phone_number',
|
||||
name: '전화번호',
|
||||
pattern: /\b(01[016789])[- ]?(\d{3,4})[- ]?(\d{4})\b/g,
|
||||
mask: (_match, p1, _p2, p3) => `${p1}-****-${p3}`,
|
||||
},
|
||||
// 4. 이메일 주소 (Email Address)
|
||||
{
|
||||
id: 'email',
|
||||
name: '이메일',
|
||||
pattern: /\b([a-zA-Z0-9_.+-]+)@([a-zA-Z0-9-]+\.[a-zA-Z0-9-.]+)\b/g,
|
||||
mask: (_match, p1, p2) => {
|
||||
const visible = p1.length > 2 ? `${p1.slice(0, 2)}***` : `${p1[0]}*`
|
||||
return `${visible}@${p2}`
|
||||
},
|
||||
},
|
||||
// 5. 미국 사회보장번호 (US Social Security Number)
|
||||
{
|
||||
id: 'us_ssn',
|
||||
name: 'SSN (미국 사회보장번호)',
|
||||
pattern: /\b(\d{3})[- ]?(\d{2})[- ]?(\d{4})\b/g,
|
||||
mask: (_match, _p1, _p2, p3) => `***-**-${p3}`,
|
||||
},
|
||||
// 6. 한국 계좌번호 패턴 (일반적인 10~14자리 숫자 하이픈 조합)
|
||||
{
|
||||
id: 'bank_account',
|
||||
name: '은행 계좌번호',
|
||||
pattern: /\b(\d{3,6})[- ](\d{2,6})[- ](\d{3,6})\b/g,
|
||||
mask: (_match, p1, _p2, p3) => `${p1}-******-${p3}`,
|
||||
},
|
||||
]
|
||||
|
||||
/**
|
||||
* 텍스트 내의 민감 개인정보(PII)를 검출하여 마스킹 또는 토큰화
|
||||
* @param text 원본 전사 텍스트
|
||||
* @param mode 'mask' (부분 별표 마스킹) | 'tokenize' (플레이스홀더 치환)
|
||||
* @param enabledRules 활성화할 규칙 ID 목록 (기본 전체)
|
||||
*/
|
||||
export function redactPII(
|
||||
text: string,
|
||||
mode: 'mask' | 'tokenize' = 'mask',
|
||||
enabledRules?: string[]
|
||||
): RedactionResult {
|
||||
if (!text || typeof text !== 'string') {
|
||||
return {
|
||||
originalText: text || '',
|
||||
redactedText: text || '',
|
||||
redactionCount: 0,
|
||||
matchedCategories: [],
|
||||
tokens: new Map(),
|
||||
}
|
||||
}
|
||||
|
||||
let redacted = text
|
||||
let totalCount = 0
|
||||
const categories = new Set<string>()
|
||||
const tokens = new Map<string, string>()
|
||||
let tokenCounter = 0
|
||||
|
||||
const activeRules = enabledRules
|
||||
? DEFAULT_REDACTION_RULES.filter((r) => enabledRules.includes(r.id))
|
||||
: DEFAULT_REDACTION_RULES
|
||||
|
||||
for (const rule of activeRules) {
|
||||
redacted = redacted.replace(rule.pattern, (match, ...groups) => {
|
||||
totalCount++
|
||||
categories.add(rule.name)
|
||||
|
||||
if (mode === 'tokenize') {
|
||||
tokenCounter++
|
||||
const placeholder = `[REDACTED_${rule.id.toUpperCase()}_${tokenCounter}]`
|
||||
tokens.set(placeholder, match)
|
||||
return placeholder
|
||||
}
|
||||
|
||||
return rule.mask(match, ...groups)
|
||||
})
|
||||
}
|
||||
|
||||
return {
|
||||
originalText: text,
|
||||
redactedText: redacted,
|
||||
redactionCount: totalCount,
|
||||
matchedCategories: Array.from(categories),
|
||||
tokens,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 토큰화된 텍스트를 원본 값으로 복원 (Rehydration)
|
||||
* LLM 추론 완료 후 클라이언트 화면 표시 시 안전하게 원문 복원
|
||||
*/
|
||||
export function rehydrateText(
|
||||
redactedText: string,
|
||||
tokens: Map<string, string>
|
||||
): string {
|
||||
if (!redactedText || tokens.size === 0) return redactedText
|
||||
|
||||
let result = redactedText
|
||||
tokens.forEach((originalValue, placeholder) => {
|
||||
result = result.split(placeholder).join(originalValue)
|
||||
})
|
||||
return result
|
||||
}
|
||||
48
packages/core/src/utils/secure-memory.ts
Normal file
48
packages/core/src/utils/secure-memory.ts
Normal file
|
|
@ -0,0 +1,48 @@
|
|||
// packages/core/src/utils/secure-memory.ts
|
||||
// Zero Data Retention (ZDR) & Biometric Audio Memory Wiper
|
||||
// 음성 바이오메트릭 데이터 메모리 상주 방지 — 처리 완료/취소/에러 시 오디오 버퍼 0으로 즉시 초기화
|
||||
|
||||
/**
|
||||
* Node.js Buffer 또는 Uint8Array의 메모리를 0으로 즉시 덮어씀 (Zero-fill)
|
||||
*/
|
||||
export function secureZeroBuffer(buffer: Buffer | Uint8Array | null | undefined): void {
|
||||
if (!buffer) return
|
||||
try {
|
||||
if (typeof buffer.fill === 'function') {
|
||||
buffer.fill(0)
|
||||
} else {
|
||||
for (let i = 0; i < buffer.length; i++) {
|
||||
buffer[i] = 0
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// Silent guard against detached buffers
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Float32Array PCM 오디오 청크 배열을 메모리에서 안전하게 폐기
|
||||
*/
|
||||
export function secureZeroFloat32Array(array: Float32Array | null | undefined): void {
|
||||
if (!array) return
|
||||
try {
|
||||
array.fill(0)
|
||||
} catch {
|
||||
// Silent guard
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 여러 개의 오디오 청크 리스트를 일괄 폐기 (Zero-trace)
|
||||
*/
|
||||
export function secureWipeAudioChunks(chunks: Array<Buffer | Uint8Array | Float32Array> | null | undefined): void {
|
||||
if (!chunks || !Array.isArray(chunks)) return
|
||||
for (const chunk of chunks) {
|
||||
if (chunk instanceof Float32Array) {
|
||||
secureZeroFloat32Array(chunk)
|
||||
} else {
|
||||
secureZeroBuffer(chunk)
|
||||
}
|
||||
}
|
||||
chunks.length = 0
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue