cross-poster-sdk 0.0.0-stage → 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +204 -2
- package/README_JA.md +185 -0
- package/package.json +47 -4
- package/src/index.d.ts +149 -0
- package/src/index.mjs +479 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 takao-dev
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
CHANGED
|
@@ -1,3 +1,205 @@
|
|
|
1
|
-
#
|
|
1
|
+
# cross-poster-sdk
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
[](https://github.com/takao-dev/cross-poster-sdk/actions/workflows/ci.yml)
|
|
4
|
+
[](https://github.com/takao-dev/cross-poster-sdk/actions/workflows/codeql.yml)
|
|
5
|
+
[](https://www.npmjs.com/package/cross-poster-sdk)
|
|
6
|
+
[](https://opensource.org/licenses/MIT)
|
|
7
|
+
|
|
8
|
+
> **Zero-dependency, multi-SNS cross-posting SDK for X (Twitter), Mastodon, Bluesky (AT Protocol), Misskey, and Meta Threads, engineered with DNS-rebinding-safe SSRF defense and AES-256-GCM credential envelopes.**
|
|
9
|
+
|
|
10
|
+
Cross-posting across federated (Mastodon, Misskey) and centralized (X, Bluesky, Threads) networks introduces severe security hazards: users supply arbitrary instance domains that can target internal cloud metadata endpoints (`169.254.169.254`) or trigger DNS rebinding attacks. `cross-poster-sdk` provides an all-in-one unified cross-posting pipeline with rock-solid security defaults and **zero external npm dependencies**.
|
|
11
|
+
|
|
12
|
+
---
|
|
13
|
+
|
|
14
|
+
## Features
|
|
15
|
+
|
|
16
|
+
- **Unified 5-Network Adapter**:
|
|
17
|
+
Post and delete across **X (v2 API)**, **Mastodon (v1 API)**, **Bluesky (AT Protocol / XRPC)**, **Misskey (Notes API)**, and **Threads (2-step container API)** through a single clean interface.
|
|
18
|
+
- **DNS-Rebinding-Proof SSRF Protection**:
|
|
19
|
+
Validates destination addresses against private/internal IPv4 and IPv6 CIDR blocks (RFC1918, CGNAT, Loopback, Link-Local, Cloud Metadata, 6to4, IPv4-mapped IPv6). Resolves and **pins the IP address** directly into the Node.js socket TLS handshake (`lookup` hook), completely eliminating Time-Of-Check to Time-Of-Use (TOCTOU) DNS rebinding attacks.
|
|
20
|
+
- **AES-256-GCM Credential Envelopes**:
|
|
21
|
+
Built-in `v1.<iv>.<tag>.<ciphertext>` authenticated encryption helpers for securely storing OAuth tokens and app passwords at rest in your database.
|
|
22
|
+
- **Dry-Run Simulation Mode**:
|
|
23
|
+
Test your cross-posting workflows locally without touching live APIs or burning rate limits.
|
|
24
|
+
- **Zero Runtime Dependencies**:
|
|
25
|
+
Built exclusively on Node.js core modules (`node:https`, `node:dns/promises`, `node:net`, `node:crypto`).
|
|
26
|
+
|
|
27
|
+
---
|
|
28
|
+
|
|
29
|
+
## Installation
|
|
30
|
+
|
|
31
|
+
```bash
|
|
32
|
+
npm install cross-poster-sdk
|
|
33
|
+
```
|
|
34
|
+
|
|
35
|
+
Requirements:
|
|
36
|
+
- Node.js `>= 18.0.0`
|
|
37
|
+
|
|
38
|
+
---
|
|
39
|
+
|
|
40
|
+
## Quick Start
|
|
41
|
+
|
|
42
|
+
### 1. Unified Cross-Posting
|
|
43
|
+
|
|
44
|
+
```typescript
|
|
45
|
+
import { postToService, deleteFromService } from 'cross-poster-sdk';
|
|
46
|
+
|
|
47
|
+
// 1. Post to Bluesky
|
|
48
|
+
const bskyResult = await postToService(
|
|
49
|
+
{
|
|
50
|
+
service: 'bluesky',
|
|
51
|
+
credentials: {
|
|
52
|
+
username: 'handle.bsky.social',
|
|
53
|
+
appPassword: 'xxxx-xxxx-xxxx-xxxx',
|
|
54
|
+
},
|
|
55
|
+
},
|
|
56
|
+
'Hello from cross-poster-sdk! 🚀'
|
|
57
|
+
);
|
|
58
|
+
console.log('Bluesky Post URI:', bskyResult.remoteId);
|
|
59
|
+
// -> "at://did:plc:.../app.bsky.feed.post/3k..."
|
|
60
|
+
|
|
61
|
+
// 2. Post to a custom Mastodon or Misskey instance (SSRF-protected)
|
|
62
|
+
const fediverseResult = await postToService(
|
|
63
|
+
{
|
|
64
|
+
service: 'mastodon',
|
|
65
|
+
instanceUrl: 'https://mastodon.social',
|
|
66
|
+
credentials: { accessToken: 'YOUR_MASTODON_ACCESS_TOKEN' },
|
|
67
|
+
},
|
|
68
|
+
'Federated status update'
|
|
69
|
+
);
|
|
70
|
+
console.log('Mastodon Status ID:', fediverseResult.remoteId);
|
|
71
|
+
|
|
72
|
+
// 3. Delete a post
|
|
73
|
+
await deleteFromService(
|
|
74
|
+
{
|
|
75
|
+
service: 'bluesky',
|
|
76
|
+
credentials: {
|
|
77
|
+
username: 'handle.bsky.social',
|
|
78
|
+
appPassword: 'xxxx-xxxx-xxxx-xxxx',
|
|
79
|
+
},
|
|
80
|
+
},
|
|
81
|
+
bskyResult.remoteId!
|
|
82
|
+
);
|
|
83
|
+
```
|
|
84
|
+
|
|
85
|
+
---
|
|
86
|
+
|
|
87
|
+
## Supported Services & Credentials
|
|
88
|
+
|
|
89
|
+
| Service | `service` identifier | Required Credentials | Notes |
|
|
90
|
+
| :--- | :--- | :--- | :--- |
|
|
91
|
+
| **Bluesky** | `'bluesky'` | `{ username, appPassword }` | Authenticates via ATProto XRPC `createSession` and writes to `app.bsky.feed.post` |
|
|
92
|
+
| **Mastodon** | `'mastodon'` | `{ accessToken }`, `instanceUrl` | Posts via `/api/v1/statuses` with strict instance URL validation |
|
|
93
|
+
| **Misskey** | `'misskey'` | `{ accessToken }`, `instanceUrl` | Posts via `/api/notes/create` with strict instance URL validation |
|
|
94
|
+
| **Threads** | `'threads'` | `{ accessToken }` | Handles 2-step container creation and immediate publishing |
|
|
95
|
+
| **X (Twitter)** | `'x'` | `{ accessToken }` | Uses Twitter API v2 `/2/tweets` |
|
|
96
|
+
|
|
97
|
+
---
|
|
98
|
+
|
|
99
|
+
## Security Architecture
|
|
100
|
+
|
|
101
|
+
### Why standard `fetch()` is dangerous for Fediverse instances
|
|
102
|
+
|
|
103
|
+
When accepting federated instance URLs from users (e.g. `https://my-community.net`), attackers can provide URLs that resolve to internal network addresses or use DNS rebinding (returning a public IP during DNS check, and `169.254.169.254` during HTTP connection):
|
|
104
|
+
|
|
105
|
+
```mermaid
|
|
106
|
+
sequenceDiagram
|
|
107
|
+
participant App as cross-poster-sdk
|
|
108
|
+
participant DNS as DNS Resolver
|
|
109
|
+
participant Target as Target / Internal Service
|
|
110
|
+
|
|
111
|
+
App->>DNS: Resolve "user-domain.com"
|
|
112
|
+
DNS-->>App: 93.184.216.34 (Public IP)
|
|
113
|
+
Note over App: Validates against private IP blocks (OK)
|
|
114
|
+
Note over App: Pins IP into TLS socket lookup hook!
|
|
115
|
+
App->>Target: Connect directly to pinned 93.184.216.34 (SNI: user-domain.com)
|
|
116
|
+
Note over App: Attacker's DNS TTL expiration has NO effect!
|
|
117
|
+
```
|
|
118
|
+
|
|
119
|
+
`cross-poster-sdk` blocks:
|
|
120
|
+
- `127.0.0.0/8` (Loopback)
|
|
121
|
+
- `10.0.0.0/8`, `172.16.0.0/12`, `192.168.0.0/16` (Private RFC1918)
|
|
122
|
+
- `169.254.0.0/16` (Link-local & AWS/GCP/Azure Cloud Metadata)
|
|
123
|
+
- `100.64.0.0/10` (Carrier-Grade NAT)
|
|
124
|
+
- `::1` (IPv6 loopback), `fe80::/10` (IPv6 Link-Local), `fc00::/7` (IPv6 Unique Local)
|
|
125
|
+
- `::ffff:0:0/96` (IPv4-mapped IPv6)
|
|
126
|
+
- Cloud internal hostnames (`metadata.google.internal`, `instance-data.ec2.internal`, `*.localhost`, `*.internal`)
|
|
127
|
+
|
|
128
|
+
---
|
|
129
|
+
|
|
130
|
+
### Credential Encryption at Rest
|
|
131
|
+
|
|
132
|
+
Secure sensitive API tokens with authenticated AES-256-GCM before persisting them in your database:
|
|
133
|
+
|
|
134
|
+
```typescript
|
|
135
|
+
import { encryptCredentials, decryptCredentials } from 'cross-poster-sdk';
|
|
136
|
+
import { randomBytes } from 'node:crypto';
|
|
137
|
+
|
|
138
|
+
// 32-byte secret key (e.g. from process.env.ENCRYPTION_KEY)
|
|
139
|
+
const secretKey = Buffer.from(process.env.APP_SECRET_KEY!, 'hex');
|
|
140
|
+
|
|
141
|
+
// Encrypt into safe string envelope: "v1.<iv>.<tag>.<ciphertext>"
|
|
142
|
+
const envelope = encryptCredentials({ accessToken: 'ghp_secretToken...' }, secretKey);
|
|
143
|
+
|
|
144
|
+
// Decrypt safely (throws if tampered or counterfeit)
|
|
145
|
+
const creds = decryptCredentials(envelope, secretKey);
|
|
146
|
+
```
|
|
147
|
+
|
|
148
|
+
---
|
|
149
|
+
|
|
150
|
+
## Dry-Run Testing
|
|
151
|
+
|
|
152
|
+
Simulate posts without invoking external network requests:
|
|
153
|
+
|
|
154
|
+
```typescript
|
|
155
|
+
const result = await postToService(
|
|
156
|
+
{ service: 'x', credentials: {} },
|
|
157
|
+
'Testing post',
|
|
158
|
+
{ dryRun: true }
|
|
159
|
+
);
|
|
160
|
+
|
|
161
|
+
console.log(result);
|
|
162
|
+
// { remoteId: 'dry_x_04a8b792-...', simulated: true }
|
|
163
|
+
```
|
|
164
|
+
|
|
165
|
+
---
|
|
166
|
+
|
|
167
|
+
## 🧵 Thread Splitting & Sequential Posting
|
|
168
|
+
|
|
169
|
+
Different networks enforce different character counting rules (X counts CJK as 2 and URLs as 23; Bluesky counts Grapheme Clusters; Mastodon counts code points). `cross-poster-sdk` provides built-in calculators, natural text splitters with numbering indicators `(1/N)`, and sequential chained thread posting:
|
|
170
|
+
|
|
171
|
+
```typescript
|
|
172
|
+
import {
|
|
173
|
+
countServiceCharacters,
|
|
174
|
+
splitPostIntoThreads,
|
|
175
|
+
postThreadToService
|
|
176
|
+
} from 'cross-poster-sdk';
|
|
177
|
+
|
|
178
|
+
const myLongArticle = `Very long update text...`;
|
|
179
|
+
|
|
180
|
+
// 1. Measure character weight for specific platform
|
|
181
|
+
const xLength = countServiceCharacters('x', myLongArticle);
|
|
182
|
+
|
|
183
|
+
// 2. Automatically split into numbered threads safe for the platform limits
|
|
184
|
+
const xThreads = splitPostIntoThreads('x', myLongArticle, { numbering: true });
|
|
185
|
+
// -> ['Paragraph 1... (1/3)', 'Paragraph 2... (2/3)', 'Paragraph 3... (3/3)']
|
|
186
|
+
|
|
187
|
+
// 3. Post sequentially as linked reply thread
|
|
188
|
+
const threadResults = await postThreadToService(
|
|
189
|
+
{ service: 'x', credentials: { accessToken: 'token' } },
|
|
190
|
+
xThreads
|
|
191
|
+
);
|
|
192
|
+
console.log('Thread published! Root ID:', threadResults[0].remoteId);
|
|
193
|
+
```
|
|
194
|
+
|
|
195
|
+
---
|
|
196
|
+
|
|
197
|
+
## TypeScript Support
|
|
198
|
+
|
|
199
|
+
Complete TypeScript declarations are bundled in `./src/index.d.ts`. Types for `CrossPosterAccount`, `SupportedService`, `PostOptions`, and `PostResult` are exported automatically.
|
|
200
|
+
|
|
201
|
+
---
|
|
202
|
+
|
|
203
|
+
## License
|
|
204
|
+
|
|
205
|
+
MIT © [takao-dev](https://github.com/takao-dev)
|
package/README_JA.md
ADDED
|
@@ -0,0 +1,185 @@
|
|
|
1
|
+
# cross-poster-sdk
|
|
2
|
+
|
|
3
|
+
[](https://github.com/takao-dev/cross-poster-sdk/actions/workflows/ci.yml)
|
|
4
|
+
[](https://www.npmjs.com/package/cross-poster-sdk)
|
|
5
|
+
[](https://opensource.org/licenses/MIT)
|
|
6
|
+
|
|
7
|
+
> **X(Twitter)、Mastodon、Bluesky(AT Protocol)、Misskey、Meta Threads に対応したゼロ依存マルチSNSクロスポストSDK。DNSリバインディング完全耐性を持つSSRF防御と、AES-256-GCM 資格情報暗号化エンベロープを内蔵。**
|
|
8
|
+
|
|
9
|
+
[English README is available here](./README.md)
|
|
10
|
+
|
|
11
|
+
---
|
|
12
|
+
|
|
13
|
+
## 💡 なぜ `cross-poster-sdk` なのか?
|
|
14
|
+
|
|
15
|
+
分散型SNS(Mastodon, Misskey)と中央集権型SNS(X, Bluesky, Threads)を横断してクロスポストするシステムを構築する場合、深刻なセキュリティリスクと複雑さに直面します:
|
|
16
|
+
|
|
17
|
+
1. **悪意あるインスタンスURLによる SSRF & クラウド認証情報窃取**:
|
|
18
|
+
ユーザーが自由に入力できるインスタンスドメイン(`https://my-instance.com`)が悪意を持って内部ネットワークやクラウドのメタデータエンドポイント(`169.254.169.254`)に向けられていた場合、サーバーの機密情報や IAM 認証情報が即座に奪われます。
|
|
19
|
+
2. **DNS リバインディング攻撃(TOCTOU)の脅威**:
|
|
20
|
+
URL検証時に正規のパブリックIPを返し、HTTP接続確立時のミリ秒で内部IP(`127.0.0.1` や `169.254.169.254`)を返すDNSリバインディング攻撃に対し、一般的な `fetch()` や `axios` では防御が極めて困難です。
|
|
21
|
+
3. **API仕様と文字数計算の断片化**:
|
|
22
|
+
X (全角2/半角1/URL23), Bluesky (Grapheme Clusters), Mastodon (Code points) など、各社で異なる文字数制限やスレッド構造の処理。
|
|
23
|
+
|
|
24
|
+
`cross-poster-sdk` はこれらをゼロ依存で完全に解決します:
|
|
25
|
+
|
|
26
|
+
- **DNSリバインディング完全耐性を持つ SSRF 防御**:
|
|
27
|
+
プライベートIP、リンクローカル、CGNAT、ループバック、クラウドメタデータ、IPv4射影IPv6、6to4を完全遮断。DNS解決した安全なIPアドレスを **Node.js の TLS ソケットの `lookup` コールバックに直接ピン留め** するため、DNSのTTL改ざんによるすり替えが物理的に不可能です。
|
|
28
|
+
- **統一 5-Network アダプター**:
|
|
29
|
+
`postToService` / `deleteFromService` の単一インターフェースで、5大SNSへの投稿・削除を網羅。
|
|
30
|
+
- **各社仕様に忠実な文字数カウンター & 自動スレッド分割**:
|
|
31
|
+
各SNS固有のカウントロジックに基づき、長文を最適な文末境界で分割して `(1/3)`, `(2/3)` などのナンバリングを付与し、連鎖スレッド投稿(`postThreadToService`)を実行。
|
|
32
|
+
- **AES-256-GCM 資格情報エンベロープ**:
|
|
33
|
+
データベースに保存する OAuth トークンや App パスワードを `v1.<iv>.<tag>.<ciphertext>` 形式で安全に認証暗号化。
|
|
34
|
+
- **外部依存ゼロ (Zero Dependencies)**:
|
|
35
|
+
Node.js 標準組み込みモジュール(`node:https`, `node:dns`, `node:net`, `node:crypto`)のみで完結。
|
|
36
|
+
|
|
37
|
+
---
|
|
38
|
+
|
|
39
|
+
## 📦 インストール
|
|
40
|
+
|
|
41
|
+
```bash
|
|
42
|
+
npm install cross-poster-sdk
|
|
43
|
+
```
|
|
44
|
+
|
|
45
|
+
要件: Node.js `>= 18.0.0`
|
|
46
|
+
|
|
47
|
+
---
|
|
48
|
+
|
|
49
|
+
## 🚀 クイックスタート
|
|
50
|
+
|
|
51
|
+
### 1. 単一投稿と削除
|
|
52
|
+
|
|
53
|
+
```typescript
|
|
54
|
+
import { postToService, deleteFromService } from 'cross-poster-sdk';
|
|
55
|
+
|
|
56
|
+
// 1. Bluesky へ投稿
|
|
57
|
+
const bskyResult = await postToService(
|
|
58
|
+
{
|
|
59
|
+
service: 'bluesky',
|
|
60
|
+
credentials: {
|
|
61
|
+
username: 'handle.bsky.social',
|
|
62
|
+
appPassword: 'xxxx-xxxx-xxxx-xxxx',
|
|
63
|
+
},
|
|
64
|
+
},
|
|
65
|
+
'cross-poster-sdk からこんにちは! 🚀'
|
|
66
|
+
);
|
|
67
|
+
console.log('Bluesky Post URI:', bskyResult.remoteId);
|
|
68
|
+
|
|
69
|
+
// 2. カスタム Mastodon または Misskey インスタンスへ安全に投稿(SSRF保護済み)
|
|
70
|
+
const mastodonResult = await postToService(
|
|
71
|
+
{
|
|
72
|
+
service: 'mastodon',
|
|
73
|
+
instanceUrl: 'https://mastodon.social',
|
|
74
|
+
credentials: { accessToken: 'YOUR_MASTODON_TOKEN' },
|
|
75
|
+
},
|
|
76
|
+
'Mastodonへのステータス投稿'
|
|
77
|
+
);
|
|
78
|
+
|
|
79
|
+
// 3. 投稿の削除
|
|
80
|
+
await deleteFromService(
|
|
81
|
+
{
|
|
82
|
+
service: 'bluesky',
|
|
83
|
+
credentials: { username: 'handle.bsky.social', appPassword: 'xxxx-xxxx-xxxx-xxxx' },
|
|
84
|
+
},
|
|
85
|
+
bskyResult.remoteId!
|
|
86
|
+
);
|
|
87
|
+
```
|
|
88
|
+
|
|
89
|
+
---
|
|
90
|
+
|
|
91
|
+
### 2. スマート長文スレッド分割 & 連鎖投稿
|
|
92
|
+
|
|
93
|
+
各SNSの文字数上限に合わせて自動分割し、直前の投稿にリプライする形でスレッドを連続投稿します:
|
|
94
|
+
|
|
95
|
+
```typescript
|
|
96
|
+
import {
|
|
97
|
+
countServiceCharacters,
|
|
98
|
+
splitPostIntoThreads,
|
|
99
|
+
postThreadToService,
|
|
100
|
+
} from 'cross-poster-sdk';
|
|
101
|
+
|
|
102
|
+
const longText = `長文のアナウンス記事です...(非常に長い本文)...`;
|
|
103
|
+
|
|
104
|
+
// 1. 各SNS固有の文字数重みを正確に測定
|
|
105
|
+
const weight = countServiceCharacters('x', longText);
|
|
106
|
+
|
|
107
|
+
// 2. ナンバリングインジケータ付きで安全な長さに分割
|
|
108
|
+
const threads = splitPostIntoThreads('x', longText, { numbering: true });
|
|
109
|
+
// -> ['段落1... (1/3)', '段落2... (2/3)', '段落3... (3/3)']
|
|
110
|
+
|
|
111
|
+
// 3. 前の投稿へ自動リプライしながら一括連鎖投稿
|
|
112
|
+
const results = await postThreadToService(
|
|
113
|
+
{ service: 'x', credentials: { accessToken: 'token' } },
|
|
114
|
+
threads
|
|
115
|
+
);
|
|
116
|
+
console.log('スレッド投稿完了!親ツイートID:', results[0].remoteId);
|
|
117
|
+
```
|
|
118
|
+
|
|
119
|
+
---
|
|
120
|
+
|
|
121
|
+
### 3. トークン暗号化エンベロープ
|
|
122
|
+
|
|
123
|
+
データベースにアクセストークンを保存する際、平文ではなく AES-256-GCM 認証暗号化エンベロープとして保護します:
|
|
124
|
+
|
|
125
|
+
```typescript
|
|
126
|
+
import { encryptCredentials, decryptCredentials } from 'cross-poster-sdk';
|
|
127
|
+
|
|
128
|
+
const secretKey = Buffer.from(process.env.APP_SECRET_KEY!, 'hex'); // 32バイト
|
|
129
|
+
|
|
130
|
+
// 暗号化エンベロープを作成: "v1.<iv>.<tag>.<ciphertext>"
|
|
131
|
+
const envelope = encryptCredentials({ accessToken: 'user-secret-oauth-token' }, secretKey);
|
|
132
|
+
|
|
133
|
+
// 復号(改ざんや破損時は安全に例外スロー)
|
|
134
|
+
const creds = decryptCredentials(envelope, secretKey);
|
|
135
|
+
```
|
|
136
|
+
|
|
137
|
+
---
|
|
138
|
+
|
|
139
|
+
### 4. ドライラン(シミュレーション)モード
|
|
140
|
+
|
|
141
|
+
API のレートリミットを消費せずに、ローカルテストやデバッグを実行できます:
|
|
142
|
+
|
|
143
|
+
```typescript
|
|
144
|
+
const dryResult = await postToService(
|
|
145
|
+
{ service: 'x', credentials: {} },
|
|
146
|
+
'テストメッセージ',
|
|
147
|
+
{ dryRun: true }
|
|
148
|
+
);
|
|
149
|
+
|
|
150
|
+
console.log(dryResult);
|
|
151
|
+
// -> { remoteId: 'dry_x_04a8b792-...', simulated: true }
|
|
152
|
+
```
|
|
153
|
+
|
|
154
|
+
---
|
|
155
|
+
|
|
156
|
+
## 🛡️ SSRF 防御の仕組み
|
|
157
|
+
|
|
158
|
+
```
|
|
159
|
+
ユーザー指定URL (例: https://custom-community.net)
|
|
160
|
+
│
|
|
161
|
+
▼
|
|
162
|
+
[URL形式検証] ── HTTPS 必須 / Basic認証情報の混入を禁止
|
|
163
|
+
│
|
|
164
|
+
[DNS名前解決] ── 全IPアドレスを取得
|
|
165
|
+
│
|
|
166
|
+
[IP範囲検証] ── 以下の危険なIPブロックを完全遮断:
|
|
167
|
+
• 127.0.0.0/8 (ループバック)
|
|
168
|
+
• 10.0.0.0/8, 172.16.0.0/12, 192.168.0.0/16 (プライベートIP)
|
|
169
|
+
• 169.254.0.0/16 (リンクローカル / クラウドメタデータ)
|
|
170
|
+
• 100.64.0.0/10 (CGNAT)
|
|
171
|
+
• ::1, fe80::/10, fc00::/7 (IPv6 ループバック/ローカル)
|
|
172
|
+
• ::ffff:0:0/96 (IPv4射影IPv6)
|
|
173
|
+
• 内部ホスト名 (*.internal, metadata.google.internal 等)
|
|
174
|
+
│
|
|
175
|
+
▼
|
|
176
|
+
[IPピン留め] ── 検証を通過した IP を TLS ソケットの lookup フックに直接固定
|
|
177
|
+
接続確立時の DNS 再問い合わせを発生させないため
|
|
178
|
+
DNSリバインディング攻撃は成立しません。
|
|
179
|
+
```
|
|
180
|
+
|
|
181
|
+
---
|
|
182
|
+
|
|
183
|
+
## 📄 ライセンス
|
|
184
|
+
|
|
185
|
+
MIT License © 2026 takao-dev
|
package/package.json
CHANGED
|
@@ -1,6 +1,49 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "cross-poster-sdk",
|
|
3
|
-
"version": "
|
|
4
|
-
"
|
|
5
|
-
"
|
|
6
|
-
|
|
3
|
+
"version": "1.0.0",
|
|
4
|
+
"description": "Zero-dependency multi-SNS cross-posting SDK (X, Mastodon, Bluesky, Misskey, Threads) with DNS-rebinding-safe SSRF protection for federated instances and AES-256-GCM credential envelopes.",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"main": "./src/index.mjs",
|
|
7
|
+
"types": "./src/index.d.ts",
|
|
8
|
+
"exports": {
|
|
9
|
+
".": {
|
|
10
|
+
"types": "./src/index.d.ts",
|
|
11
|
+
"default": "./src/index.mjs"
|
|
12
|
+
}
|
|
13
|
+
},
|
|
14
|
+
"files": [
|
|
15
|
+
"src",
|
|
16
|
+
"LICENSE",
|
|
17
|
+
"README.md",
|
|
18
|
+
"README_JA.md"
|
|
19
|
+
],
|
|
20
|
+
"engines": {
|
|
21
|
+
"node": ">=18.0.0"
|
|
22
|
+
},
|
|
23
|
+
"scripts": {
|
|
24
|
+
"test": "node --test",
|
|
25
|
+
"test:coverage": "node -e \"require('node:fs').mkdirSync('coverage', {recursive: true})\" && node --test --experimental-test-coverage --test-reporter=spec --test-reporter=lcov --test-reporter-destination=stdout --test-reporter-destination=coverage/lcov.info"
|
|
26
|
+
},
|
|
27
|
+
"sideEffects": false,
|
|
28
|
+
"keywords": [
|
|
29
|
+
"cross-post",
|
|
30
|
+
"social-media",
|
|
31
|
+
"x",
|
|
32
|
+
"twitter",
|
|
33
|
+
"mastodon",
|
|
34
|
+
"bluesky",
|
|
35
|
+
"atproto",
|
|
36
|
+
"misskey",
|
|
37
|
+
"threads",
|
|
38
|
+
"fediverse",
|
|
39
|
+
"ssrf-protection",
|
|
40
|
+
"dns-rebinding",
|
|
41
|
+
"zero-dependencies"
|
|
42
|
+
],
|
|
43
|
+
"author": "takao-dev",
|
|
44
|
+
"license": "MIT",
|
|
45
|
+
"repository": {
|
|
46
|
+
"type": "git",
|
|
47
|
+
"url": "git+https://github.com/takao-dev/cross-poster-sdk.git"
|
|
48
|
+
}
|
|
49
|
+
}
|
package/src/index.d.ts
ADDED
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
/// <reference types="node" />
|
|
2
|
+
|
|
3
|
+
import type { LookupAddress } from 'node:dns';
|
|
4
|
+
import type { URL } from 'node:url';
|
|
5
|
+
|
|
6
|
+
export type SupportedService = 'x' | 'mastodon' | 'bluesky' | 'misskey' | 'threads';
|
|
7
|
+
|
|
8
|
+
export declare const SUPPORTED_SERVICES: ReadonlySet<SupportedService>;
|
|
9
|
+
|
|
10
|
+
export declare const SERVICE_LABELS: Record<SupportedService, string>;
|
|
11
|
+
|
|
12
|
+
export declare const SERVICE_LIMITS: Record<SupportedService, number>;
|
|
13
|
+
|
|
14
|
+
export interface SplitPostOptions {
|
|
15
|
+
numbering?: boolean;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export declare function countServiceCharacters(
|
|
19
|
+
service: SupportedService,
|
|
20
|
+
text: string
|
|
21
|
+
): number;
|
|
22
|
+
|
|
23
|
+
export declare function splitPostIntoThreads(
|
|
24
|
+
service: SupportedService,
|
|
25
|
+
text: string,
|
|
26
|
+
options?: SplitPostOptions
|
|
27
|
+
): string[];
|
|
28
|
+
|
|
29
|
+
export interface XCredentials {
|
|
30
|
+
accessToken: string;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export interface MastodonCredentials {
|
|
34
|
+
accessToken: string;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export interface MisskeyCredentials {
|
|
38
|
+
accessToken: string;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export interface BlueskyCredentials {
|
|
42
|
+
username: string;
|
|
43
|
+
appPassword: string;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export interface ThreadsCredentials {
|
|
47
|
+
accessToken: string;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export type ServiceCredentials =
|
|
51
|
+
| XCredentials
|
|
52
|
+
| MastodonCredentials
|
|
53
|
+
| MisskeyCredentials
|
|
54
|
+
| BlueskyCredentials
|
|
55
|
+
| ThreadsCredentials;
|
|
56
|
+
|
|
57
|
+
export interface CrossPosterAccount {
|
|
58
|
+
service: SupportedService;
|
|
59
|
+
instanceUrl?: string | null;
|
|
60
|
+
credentials: Record<string, any>;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
export interface PostOptions {
|
|
64
|
+
dryRun?: boolean;
|
|
65
|
+
replyToId?: string | null;
|
|
66
|
+
replyRoot?: string | null;
|
|
67
|
+
replyRootCid?: string | null;
|
|
68
|
+
replyParentCid?: string | null;
|
|
69
|
+
requestImpl?: (
|
|
70
|
+
url: string,
|
|
71
|
+
options: {
|
|
72
|
+
method?: string;
|
|
73
|
+
headers?: Record<string, string>;
|
|
74
|
+
body?: string | URLSearchParams;
|
|
75
|
+
signal?: AbortSignal;
|
|
76
|
+
}
|
|
77
|
+
) => Promise<{
|
|
78
|
+
status: number;
|
|
79
|
+
ok: boolean;
|
|
80
|
+
text: () => Promise<string>;
|
|
81
|
+
}>;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
export interface PostResult {
|
|
85
|
+
remoteId: string | null;
|
|
86
|
+
simulated?: boolean;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
export interface DeleteResult {
|
|
90
|
+
simulated?: boolean;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
export declare function normalizeInstanceUrl(value: unknown): string;
|
|
94
|
+
|
|
95
|
+
export declare function isUnsafeIpv4(address: string): boolean;
|
|
96
|
+
|
|
97
|
+
export declare function parseIpv6(address: string): number[] | null;
|
|
98
|
+
|
|
99
|
+
export declare function isUnsafeAddress(address: string): boolean;
|
|
100
|
+
|
|
101
|
+
export declare function createPinnedAddressLookup(
|
|
102
|
+
address: string
|
|
103
|
+
): (
|
|
104
|
+
hostname: string,
|
|
105
|
+
options: { all?: boolean },
|
|
106
|
+
callback: (err: Error | null, addressOrList: string | LookupAddress[], family?: number) => void
|
|
107
|
+
) => void;
|
|
108
|
+
|
|
109
|
+
export declare function resolveSafeExternalUrl(
|
|
110
|
+
rawUrl: string | URL
|
|
111
|
+
): Promise<{ parsed: URL; address: string }>;
|
|
112
|
+
|
|
113
|
+
export declare function assertSafeExternalUrl(rawUrl: string | URL): Promise<URL>;
|
|
114
|
+
|
|
115
|
+
export declare function encryptCredentials(value: unknown, key: Buffer): string;
|
|
116
|
+
|
|
117
|
+
export declare function decryptCredentials<T = unknown>(value: string, key: Buffer): T;
|
|
118
|
+
|
|
119
|
+
export declare function requestExternal(
|
|
120
|
+
rawUrl: string | URL,
|
|
121
|
+
options?: {
|
|
122
|
+
method?: string;
|
|
123
|
+
headers?: Record<string, string>;
|
|
124
|
+
body?: string | URLSearchParams | Buffer;
|
|
125
|
+
signal?: AbortSignal;
|
|
126
|
+
}
|
|
127
|
+
): Promise<{
|
|
128
|
+
status: number;
|
|
129
|
+
ok: boolean;
|
|
130
|
+
text: () => Promise<string>;
|
|
131
|
+
}>;
|
|
132
|
+
|
|
133
|
+
export declare function postToService(
|
|
134
|
+
account: CrossPosterAccount,
|
|
135
|
+
text: string,
|
|
136
|
+
options?: PostOptions
|
|
137
|
+
): Promise<PostResult>;
|
|
138
|
+
|
|
139
|
+
export declare function deleteFromService(
|
|
140
|
+
account: CrossPosterAccount,
|
|
141
|
+
remoteId: string,
|
|
142
|
+
options?: PostOptions
|
|
143
|
+
): Promise<DeleteResult>;
|
|
144
|
+
|
|
145
|
+
export declare function postThreadToService(
|
|
146
|
+
account: CrossPosterAccount,
|
|
147
|
+
posts: string[],
|
|
148
|
+
options?: PostOptions
|
|
149
|
+
): Promise<PostResult[]>;
|
package/src/index.mjs
ADDED
|
@@ -0,0 +1,479 @@
|
|
|
1
|
+
import http from 'node:http';
|
|
2
|
+
import https from 'node:https';
|
|
3
|
+
import { createCipheriv, createDecipheriv, randomBytes, randomUUID } from 'node:crypto';
|
|
4
|
+
import dns from 'node:dns/promises';
|
|
5
|
+
import net from 'node:net';
|
|
6
|
+
import { URL } from 'node:url';
|
|
7
|
+
|
|
8
|
+
export const SUPPORTED_SERVICES = new Set(['x', 'mastodon', 'bluesky', 'misskey', 'threads']);
|
|
9
|
+
|
|
10
|
+
export const SERVICE_LABELS = {
|
|
11
|
+
x: 'X',
|
|
12
|
+
mastodon: 'Mastodon',
|
|
13
|
+
bluesky: 'Bluesky',
|
|
14
|
+
misskey: 'Misskey',
|
|
15
|
+
threads: 'Threads',
|
|
16
|
+
};
|
|
17
|
+
|
|
18
|
+
export const SERVICE_LIMITS = {
|
|
19
|
+
x: 280,
|
|
20
|
+
mastodon: 500,
|
|
21
|
+
bluesky: 300,
|
|
22
|
+
threads: 500,
|
|
23
|
+
misskey: 3000,
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
export function countServiceCharacters(service, text) {
|
|
27
|
+
const str = String(text || '');
|
|
28
|
+
if (service === 'x') {
|
|
29
|
+
// X Twitter: CJK / fullwidth = 2, ASCII = 1, URLs = 23
|
|
30
|
+
let count = 0;
|
|
31
|
+
const urlRegex = /https?:\/\/[^\s]+/g;
|
|
32
|
+
const cleanText = str.replace(urlRegex, () => {
|
|
33
|
+
count += 23;
|
|
34
|
+
return '';
|
|
35
|
+
});
|
|
36
|
+
for (const char of cleanText) {
|
|
37
|
+
const code = char.codePointAt(0);
|
|
38
|
+
if (
|
|
39
|
+
(code >= 0x1100 && code <= 0x115f) ||
|
|
40
|
+
(code >= 0x2e80 && code <= 0xa4cf) ||
|
|
41
|
+
(code >= 0xac00 && code <= 0xd7a3) ||
|
|
42
|
+
(code >= 0xf900 && code <= 0xfaff) ||
|
|
43
|
+
(code >= 0xfe10 && code <= 0xfe19) ||
|
|
44
|
+
(code >= 0xfe30 && code <= 0xfe6f) ||
|
|
45
|
+
(code >= 0xff00 && code <= 0xff60) ||
|
|
46
|
+
(code >= 0xffe0 && code <= 0xffe6) ||
|
|
47
|
+
(code >= 0x20000 && code <= 0x2fffd) ||
|
|
48
|
+
(code >= 0x30000 && code <= 0x3fffd)
|
|
49
|
+
) {
|
|
50
|
+
count += 2;
|
|
51
|
+
} else {
|
|
52
|
+
count += 1;
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
return count;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
if (service === 'bluesky') {
|
|
59
|
+
if (typeof Intl !== 'undefined' && Intl.Segmenter) {
|
|
60
|
+
const segmenter = new Intl.Segmenter('en', { granularity: 'grapheme' });
|
|
61
|
+
return Array.from(segmenter.segment(str)).length;
|
|
62
|
+
}
|
|
63
|
+
return Array.from(str).length;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
// Mastodon, Threads, Misskey: Unicode code point count
|
|
67
|
+
return Array.from(str).length;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
export function splitPostIntoThreads(service, text, options = {}) {
|
|
71
|
+
const limit = SERVICE_LIMITS[service] || 300;
|
|
72
|
+
const numbering = options.numbering !== false;
|
|
73
|
+
const raw = String(text || '').trim();
|
|
74
|
+
if (!raw) return [];
|
|
75
|
+
|
|
76
|
+
if (countServiceCharacters(service, raw) <= limit) {
|
|
77
|
+
return [raw];
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
const paragraphs = raw.split(/\n\n+/);
|
|
81
|
+
const chunks = [];
|
|
82
|
+
let current = '';
|
|
83
|
+
|
|
84
|
+
for (const para of paragraphs) {
|
|
85
|
+
const candidate = current ? `${current}\n\n${para}` : para;
|
|
86
|
+
if (countServiceCharacters(service, candidate) <= limit - (numbering ? 8 : 0)) {
|
|
87
|
+
current = candidate;
|
|
88
|
+
} else {
|
|
89
|
+
if (current) chunks.push(current);
|
|
90
|
+
if (countServiceCharacters(service, para) > limit - (numbering ? 8 : 0)) {
|
|
91
|
+
const sentences = para.split(/(?<=[。..!?!?\n])\s*/);
|
|
92
|
+
let sub = '';
|
|
93
|
+
for (const s of sentences) {
|
|
94
|
+
const subCand = sub ? `${sub} ${s}` : s;
|
|
95
|
+
if (countServiceCharacters(service, subCand) <= limit - (numbering ? 8 : 0)) {
|
|
96
|
+
sub = subCand;
|
|
97
|
+
} else {
|
|
98
|
+
if (sub) chunks.push(sub);
|
|
99
|
+
if (countServiceCharacters(service, s) > limit - (numbering ? 8 : 0)) {
|
|
100
|
+
let remaining = s;
|
|
101
|
+
while (remaining) {
|
|
102
|
+
let sliceLen = limit - (numbering ? 10 : 0);
|
|
103
|
+
while (sliceLen > 0 && countServiceCharacters(service, remaining.slice(0, sliceLen)) > limit - (numbering ? 10 : 0)) {
|
|
104
|
+
sliceLen--;
|
|
105
|
+
}
|
|
106
|
+
if (sliceLen <= 0) sliceLen = 1;
|
|
107
|
+
chunks.push(remaining.slice(0, sliceLen));
|
|
108
|
+
remaining = remaining.slice(sliceLen);
|
|
109
|
+
}
|
|
110
|
+
sub = '';
|
|
111
|
+
} else {
|
|
112
|
+
sub = s;
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
if (sub) chunks.push(sub);
|
|
117
|
+
current = '';
|
|
118
|
+
} else {
|
|
119
|
+
current = para;
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
if (current) chunks.push(current);
|
|
124
|
+
|
|
125
|
+
if (!numbering || chunks.length <= 1) {
|
|
126
|
+
return chunks;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
const total = chunks.length;
|
|
130
|
+
return chunks.map((chunk, idx) => `${chunk} (${idx + 1}/${total})`);
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
export function normalizeInstanceUrl(value) {
|
|
134
|
+
const raw = String(value || '').trim();
|
|
135
|
+
if (!raw) return '';
|
|
136
|
+
let parsed;
|
|
137
|
+
try {
|
|
138
|
+
parsed = new URL(raw);
|
|
139
|
+
} catch {
|
|
140
|
+
throw Object.assign(new Error('インスタンスURLはHTTPS形式で入力してください。'), { statusCode: 400 });
|
|
141
|
+
}
|
|
142
|
+
if (parsed.protocol !== 'https:' || parsed.username || parsed.password) {
|
|
143
|
+
throw Object.assign(new Error('インスタンスURLはHTTPS形式で、認証情報を含めないでください。'), { statusCode: 400 });
|
|
144
|
+
}
|
|
145
|
+
return parsed.origin;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
export function isUnsafeIpv4(address) {
|
|
149
|
+
const octets = String(address).split('.').map((part) => Number(part));
|
|
150
|
+
if (octets.length !== 4 || octets.some((part) => !Number.isInteger(part) || part < 0 || part > 255)) return false;
|
|
151
|
+
const value = (((octets[0] * 256) + octets[1]) * 256 + octets[2]) * 256 + octets[3];
|
|
152
|
+
const ranges = [
|
|
153
|
+
[0, 0xff000000], // 0.0.0.0/8
|
|
154
|
+
[0x0a000000, 0xff000000], // 10.0.0.0/8
|
|
155
|
+
[0x64400000, 0xffc00000], // 100.64.0.0/10
|
|
156
|
+
[0x7f000000, 0xff000000], // 127.0.0.0/8
|
|
157
|
+
[0xa9fe0000, 0xffff0000], // 169.254.0.0/16
|
|
158
|
+
[0xac100000, 0xfff00000], // 172.16.0.0/12
|
|
159
|
+
[0xc0000000, 0xffffff00], // 192.0.0.0/24
|
|
160
|
+
[0xc0000200, 0xffffff00], // 192.0.2.0/24
|
|
161
|
+
[0xc0a80000, 0xffff0000], // 192.168.0.0/16
|
|
162
|
+
[0xc6120000, 0xfffe0000], // 198.18.0.0/15
|
|
163
|
+
[0xc6336400, 0xffffff00], // 198.51.100.0/24
|
|
164
|
+
[0xcb007100, 0xffffff00], // 203.0.113.0/24
|
|
165
|
+
[0xe0000000, 0xf0000000], // multicast
|
|
166
|
+
[0xf0000000, 0xf0000000], // reserved
|
|
167
|
+
];
|
|
168
|
+
return ranges.some(([network, mask]) => ((value & mask) >>> 0) === (network >>> 0));
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
export function parseIpv6(address) {
|
|
172
|
+
let value = String(address).toLowerCase().split('%')[0];
|
|
173
|
+
if (value.includes('.')) {
|
|
174
|
+
const lastColon = value.lastIndexOf(':');
|
|
175
|
+
if (lastColon < 0) return null;
|
|
176
|
+
const octets = value.slice(lastColon + 1).split('.').map(Number);
|
|
177
|
+
if (octets.length !== 4 || octets.some((part) => !Number.isInteger(part) || part < 0 || part > 255)) return null;
|
|
178
|
+
const ipv4Hex = `${((octets[0] << 8) | octets[1]).toString(16)}:${((octets[2] << 8) | octets[3]).toString(16)}`;
|
|
179
|
+
value = `${value.slice(0, lastColon)}:${ipv4Hex}`;
|
|
180
|
+
}
|
|
181
|
+
const halves = value.split('::');
|
|
182
|
+
if (halves.length > 2) return null;
|
|
183
|
+
const left = halves[0] ? halves[0].split(':') : [];
|
|
184
|
+
const right = halves.length === 2 && halves[1] ? halves[1].split(':') : [];
|
|
185
|
+
const missing = 8 - left.length - right.length;
|
|
186
|
+
if (missing < (halves.length === 2 ? 1 : 0)) return null;
|
|
187
|
+
const groups = [...left, ...Array(missing).fill('0'), ...right];
|
|
188
|
+
if (groups.length !== 8 || groups.some((part) => !/^[0-9a-f]{1,4}$/.test(part))) return null;
|
|
189
|
+
return groups.map((part) => Number.parseInt(part, 16));
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
export function isUnsafeAddress(address) {
|
|
193
|
+
const normalized = String(address || '').toLowerCase().replace(/^\[|\]$/g, '').split('%')[0];
|
|
194
|
+
if (net.isIP(normalized) === 4) return isUnsafeIpv4(normalized);
|
|
195
|
+
if (net.isIP(normalized) !== 6) return true;
|
|
196
|
+
const groups = parseIpv6(normalized);
|
|
197
|
+
if (!groups) return true;
|
|
198
|
+
const first = groups[0];
|
|
199
|
+
const bytes = groups.flatMap((group) => [(group >> 8) & 0xff, group & 0xff]);
|
|
200
|
+
const mappedIpv4 = bytes.slice(0, 10).every((byte) => byte === 0) && bytes[10] === 0xff && bytes[11] === 0xff;
|
|
201
|
+
if (mappedIpv4) return true;
|
|
202
|
+
const isGlobalUnicast = (first & 0xe000) === 0x2000;
|
|
203
|
+
const isProtocolAssignment = groups[0] === 0x2001 && (groups[1] & 0xfe00) === 0;
|
|
204
|
+
const isSixToFour = groups[0] === 0x2002;
|
|
205
|
+
const isDocumentation = (groups[0] === 0x2001 && groups[1] === 0x0db8)
|
|
206
|
+
|| (groups[0] === 0x3fff && (groups[1] & 0xf000) === 0);
|
|
207
|
+
return !isGlobalUnicast
|
|
208
|
+
|| isProtocolAssignment
|
|
209
|
+
|| isSixToFour
|
|
210
|
+
|| isDocumentation
|
|
211
|
+
|| (first & 0xfe00) === 0xfc00
|
|
212
|
+
|| (first & 0xffc0) === 0xfe80;
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
export const createPinnedAddressLookup = (address) => (_hostname, options, callback) =>
|
|
216
|
+
options?.all
|
|
217
|
+
? callback(null, [{ address, family: net.isIP(address) }])
|
|
218
|
+
: callback(null, address, net.isIP(address));
|
|
219
|
+
|
|
220
|
+
export async function resolveSafeExternalUrl(rawUrl) {
|
|
221
|
+
let parsed;
|
|
222
|
+
try {
|
|
223
|
+
parsed = new URL(String(rawUrl));
|
|
224
|
+
} catch {
|
|
225
|
+
throw Object.assign(new Error('外部URLが不正です。'), { statusCode: 400 });
|
|
226
|
+
}
|
|
227
|
+
if (parsed.protocol !== 'https:' || parsed.username || parsed.password) {
|
|
228
|
+
throw Object.assign(new Error('外部URLはHTTPS形式で、認証情報を含めないでください。'), { statusCode: 400 });
|
|
229
|
+
}
|
|
230
|
+
const hostname = parsed.hostname.replace(/^\[|\]$/g, '').toLowerCase();
|
|
231
|
+
if (
|
|
232
|
+
!hostname ||
|
|
233
|
+
hostname === 'localhost' ||
|
|
234
|
+
hostname.endsWith('.localhost') ||
|
|
235
|
+
hostname.endsWith('.local') ||
|
|
236
|
+
hostname.endsWith('.internal') ||
|
|
237
|
+
hostname === 'metadata.google.internal' ||
|
|
238
|
+
hostname === 'instance-data.ec2.internal' ||
|
|
239
|
+
hostname === 'host.docker.internal'
|
|
240
|
+
) {
|
|
241
|
+
throw Object.assign(new Error('内部ネットワーク向けのURLは利用できません。'), { statusCode: 400 });
|
|
242
|
+
}
|
|
243
|
+
let addresses;
|
|
244
|
+
try {
|
|
245
|
+
addresses = net.isIP(hostname) ? [{ address: hostname }] : await dns.lookup(hostname, { all: true, verbatim: true });
|
|
246
|
+
} catch {
|
|
247
|
+
throw Object.assign(new Error('外部URLのホストを解決できません。'), { statusCode: 400 });
|
|
248
|
+
}
|
|
249
|
+
if (!addresses.length || addresses.some(({ address }) => isUnsafeAddress(address))) {
|
|
250
|
+
throw Object.assign(new Error('内部ネットワーク向けのURLは利用できません。'), { statusCode: 400 });
|
|
251
|
+
}
|
|
252
|
+
return { parsed, address: addresses[0].address };
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
export async function assertSafeExternalUrl(rawUrl) {
|
|
256
|
+
const { parsed } = await resolveSafeExternalUrl(rawUrl);
|
|
257
|
+
return parsed;
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
export function encryptCredentials(value, key) {
|
|
261
|
+
const iv = randomBytes(12);
|
|
262
|
+
const cipher = createCipheriv('aes-256-gcm', key, iv);
|
|
263
|
+
const encrypted = Buffer.concat([cipher.update(JSON.stringify(value), 'utf8'), cipher.final()]);
|
|
264
|
+
return `v1.${iv.toString('base64url')}.${cipher.getAuthTag().toString('base64url')}.${encrypted.toString('base64url')}`;
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
export function decryptCredentials(value, key) {
|
|
268
|
+
const [version, ivEncoded, tagEncoded, payloadEncoded] = String(value || '').split('.');
|
|
269
|
+
if (version !== 'v1' || !ivEncoded || !tagEncoded || !payloadEncoded) throw new Error('暗号化データが不正です。');
|
|
270
|
+
const decipher = createDecipheriv('aes-256-gcm', key, Buffer.from(ivEncoded, 'base64url'));
|
|
271
|
+
decipher.setAuthTag(Buffer.from(tagEncoded, 'base64url'));
|
|
272
|
+
const payload = Buffer.concat([decipher.update(Buffer.from(payloadEncoded, 'base64url')), decipher.final()]);
|
|
273
|
+
return JSON.parse(payload.toString('utf8'));
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
export function requestExternal(rawUrl, options = {}) {
|
|
277
|
+
return resolveSafeExternalUrl(rawUrl).then(({ parsed, address }) => new Promise((resolve, reject) => {
|
|
278
|
+
const transport = parsed.protocol === 'https:' ? https : http;
|
|
279
|
+
const headers = { ...(options.headers || {}) };
|
|
280
|
+
const body = options.body instanceof URLSearchParams ? options.body.toString() : options.body;
|
|
281
|
+
if (body && !Object.keys(headers).some((key) => key.toLowerCase() === 'content-length')) {
|
|
282
|
+
headers['Content-Length'] = Buffer.byteLength(body);
|
|
283
|
+
}
|
|
284
|
+
const request = transport.request({
|
|
285
|
+
protocol: parsed.protocol,
|
|
286
|
+
hostname: parsed.hostname.replace(/^\[|\]$/g, ''),
|
|
287
|
+
port: parsed.port || undefined,
|
|
288
|
+
path: `${parsed.pathname}${parsed.search}`,
|
|
289
|
+
method: options.method || 'GET',
|
|
290
|
+
headers,
|
|
291
|
+
lookup: createPinnedAddressLookup(address),
|
|
292
|
+
servername: net.isIP(parsed.hostname.replace(/^\[|\]$/g, '')) ? undefined : parsed.hostname,
|
|
293
|
+
signal: options.signal,
|
|
294
|
+
}, (response) => {
|
|
295
|
+
const chunks = [];
|
|
296
|
+
let total = 0;
|
|
297
|
+
response.on('data', (chunk) => {
|
|
298
|
+
total += chunk.length;
|
|
299
|
+
if (total > 2 * 1024 * 1024) {
|
|
300
|
+
request.destroy(new Error('外部APIレスポンスが大きすぎます。'));
|
|
301
|
+
return;
|
|
302
|
+
}
|
|
303
|
+
chunks.push(chunk);
|
|
304
|
+
});
|
|
305
|
+
response.on('end', () => {
|
|
306
|
+
const raw = Buffer.concat(chunks).toString('utf8');
|
|
307
|
+
resolve({ status: response.statusCode || 0, ok: (response.statusCode || 0) >= 200 && (response.statusCode || 0) < 300, text: async () => raw });
|
|
308
|
+
});
|
|
309
|
+
response.on('error', reject);
|
|
310
|
+
});
|
|
311
|
+
request.on('error', reject);
|
|
312
|
+
if (body) request.write(body);
|
|
313
|
+
request.end();
|
|
314
|
+
}));
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
async function fetchJson(url, options = {}) {
|
|
318
|
+
const requester = options.requestImpl || requestExternal;
|
|
319
|
+
const response = await requester(url, { ...options, signal: AbortSignal.timeout(30000) });
|
|
320
|
+
const raw = await response.text();
|
|
321
|
+
let body = null;
|
|
322
|
+
try { body = raw ? JSON.parse(raw) : {}; } catch { body = { raw: raw.slice(0, 500) }; }
|
|
323
|
+
if (!response.ok) {
|
|
324
|
+
const message = body?.error?.message || body?.error || body?.message || `外部APIエラー (${response.status})`;
|
|
325
|
+
throw new Error(String(message).slice(0, 300));
|
|
326
|
+
}
|
|
327
|
+
return body;
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
async function formRequest(url, params, options = {}) {
|
|
331
|
+
return fetchJson(url, {
|
|
332
|
+
...options,
|
|
333
|
+
method: options.method || 'POST',
|
|
334
|
+
headers: { 'Content-Type': 'application/x-www-form-urlencoded', ...(options.headers || {}) },
|
|
335
|
+
body: new URLSearchParams(Object.entries(params).map(([key, value]) => [key, String(value)])),
|
|
336
|
+
});
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
export async function postToService(account, text, options = {}) {
|
|
340
|
+
if (options.dryRun) {
|
|
341
|
+
return { remoteId: `dry_${account.service}_${randomUUID()}`, simulated: true };
|
|
342
|
+
}
|
|
343
|
+
const credentials = account.credentials;
|
|
344
|
+
const reqOpts = options.requestImpl ? { requestImpl: options.requestImpl } : {};
|
|
345
|
+
switch (account.service) {
|
|
346
|
+
case 'x': {
|
|
347
|
+
const payload = { text };
|
|
348
|
+
if (options.replyToId) {
|
|
349
|
+
payload.reply = { in_reply_to_tweet_id: options.replyToId };
|
|
350
|
+
}
|
|
351
|
+
const body = await fetchJson('https://api.x.com/2/tweets', {
|
|
352
|
+
...reqOpts,
|
|
353
|
+
method: 'POST',
|
|
354
|
+
headers: { Authorization: `Bearer ${credentials.accessToken}`, 'Content-Type': 'application/json' },
|
|
355
|
+
body: JSON.stringify(payload),
|
|
356
|
+
});
|
|
357
|
+
return { remoteId: body.data?.id || null };
|
|
358
|
+
}
|
|
359
|
+
case 'mastodon': {
|
|
360
|
+
const instanceUrl = normalizeInstanceUrl(account.instanceUrl);
|
|
361
|
+
const form = { status: text };
|
|
362
|
+
if (options.replyToId) {
|
|
363
|
+
form.in_reply_to_id = options.replyToId;
|
|
364
|
+
}
|
|
365
|
+
const body = await formRequest(`${instanceUrl}/api/v1/statuses`, form, { ...reqOpts, headers: { Authorization: `Bearer ${credentials.accessToken}` } });
|
|
366
|
+
return { remoteId: body.id || null };
|
|
367
|
+
}
|
|
368
|
+
case 'misskey': {
|
|
369
|
+
const instanceUrl = normalizeInstanceUrl(account.instanceUrl);
|
|
370
|
+
const payload = { i: credentials.accessToken, text };
|
|
371
|
+
if (options.replyToId) {
|
|
372
|
+
payload.replyId = options.replyToId;
|
|
373
|
+
}
|
|
374
|
+
const body = await fetchJson(`${instanceUrl}/api/notes/create`, {
|
|
375
|
+
...reqOpts,
|
|
376
|
+
method: 'POST',
|
|
377
|
+
headers: { 'Content-Type': 'application/json' },
|
|
378
|
+
body: JSON.stringify(payload),
|
|
379
|
+
});
|
|
380
|
+
return { remoteId: body.createdNote?.id || null };
|
|
381
|
+
}
|
|
382
|
+
case 'bluesky': {
|
|
383
|
+
const session = await fetchJson('https://bsky.social/xrpc/com.atproto.server.createSession', {
|
|
384
|
+
...reqOpts,
|
|
385
|
+
method: 'POST',
|
|
386
|
+
headers: { 'Content-Type': 'application/json' },
|
|
387
|
+
body: JSON.stringify({ identifier: credentials.username, password: credentials.appPassword }),
|
|
388
|
+
});
|
|
389
|
+
const record = {
|
|
390
|
+
'$type': 'app.bsky.feed.post',
|
|
391
|
+
text,
|
|
392
|
+
createdAt: new Date().toISOString(),
|
|
393
|
+
};
|
|
394
|
+
if (options.replyToId) {
|
|
395
|
+
record.reply = {
|
|
396
|
+
root: { uri: options.replyRoot || options.replyToId, cid: options.replyRootCid || 'bafyreie546fudph4' },
|
|
397
|
+
parent: { uri: options.replyToId, cid: options.replyParentCid || 'bafyreie546fudph4' },
|
|
398
|
+
};
|
|
399
|
+
}
|
|
400
|
+
const postRecord = await fetchJson('https://bsky.social/xrpc/com.atproto.repo.createRecord', {
|
|
401
|
+
...reqOpts,
|
|
402
|
+
method: 'POST',
|
|
403
|
+
headers: { Authorization: `Bearer ${session.accessJwt}`, 'Content-Type': 'application/json' },
|
|
404
|
+
body: JSON.stringify({
|
|
405
|
+
repo: session.did || credentials.username,
|
|
406
|
+
collection: 'app.bsky.feed.post',
|
|
407
|
+
record,
|
|
408
|
+
}),
|
|
409
|
+
});
|
|
410
|
+
return { remoteId: postRecord.uri || null };
|
|
411
|
+
}
|
|
412
|
+
case 'threads': {
|
|
413
|
+
const form = { media_type: 'TEXT', text, access_token: credentials.accessToken };
|
|
414
|
+
if (options.replyToId) {
|
|
415
|
+
form.reply_to_id = options.replyToId;
|
|
416
|
+
}
|
|
417
|
+
const container = await formRequest('https://graph.threads.com/v1.0/me/threads', form, reqOpts);
|
|
418
|
+
const published = await formRequest('https://graph.threads.com/v1.0/me/threads_publish', { creation_id: container.id, access_token: credentials.accessToken }, reqOpts);
|
|
419
|
+
return { remoteId: published.id || container.id || null };
|
|
420
|
+
}
|
|
421
|
+
default:
|
|
422
|
+
throw new Error('投稿サービスが不正です。');
|
|
423
|
+
}
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
export async function postThreadToService(account, posts, options = {}) {
|
|
427
|
+
const results = [];
|
|
428
|
+
let lastRemoteId = null;
|
|
429
|
+
let blueskyRootUri = null;
|
|
430
|
+
|
|
431
|
+
for (let i = 0; i < posts.length; i++) {
|
|
432
|
+
const text = posts[i];
|
|
433
|
+
const postOptions = {
|
|
434
|
+
...options,
|
|
435
|
+
...(lastRemoteId ? { replyToId: lastRemoteId } : {}),
|
|
436
|
+
...(blueskyRootUri ? { replyRoot: blueskyRootUri } : {}),
|
|
437
|
+
};
|
|
438
|
+
const res = await postToService(account, text, postOptions);
|
|
439
|
+
results.push(res);
|
|
440
|
+
lastRemoteId = res.remoteId;
|
|
441
|
+
if (account.service === 'bluesky' && i === 0 && res.remoteId) {
|
|
442
|
+
blueskyRootUri = res.remoteId;
|
|
443
|
+
}
|
|
444
|
+
}
|
|
445
|
+
return results;
|
|
446
|
+
}
|
|
447
|
+
|
|
448
|
+
export async function deleteFromService(account, remoteId, options = {}) {
|
|
449
|
+
if (options.dryRun || String(remoteId).startsWith('dry_')) return { simulated: true };
|
|
450
|
+
const credentials = account.credentials;
|
|
451
|
+
const reqOpts = options.requestImpl ? { requestImpl: options.requestImpl } : {};
|
|
452
|
+
switch (account.service) {
|
|
453
|
+
case 'x':
|
|
454
|
+
await fetchJson(`https://api.x.com/2/tweets/${encodeURIComponent(remoteId)}`, { ...reqOpts, method: 'DELETE', headers: { Authorization: `Bearer ${credentials.accessToken}` } });
|
|
455
|
+
return {};
|
|
456
|
+
case 'mastodon': {
|
|
457
|
+
const instanceUrl = normalizeInstanceUrl(account.instanceUrl);
|
|
458
|
+
await fetchJson(`${instanceUrl}/api/v1/statuses/${encodeURIComponent(remoteId)}`, { ...reqOpts, method: 'DELETE', headers: { Authorization: `Bearer ${credentials.accessToken}` } });
|
|
459
|
+
return {};
|
|
460
|
+
}
|
|
461
|
+
case 'misskey': {
|
|
462
|
+
const instanceUrl = normalizeInstanceUrl(account.instanceUrl);
|
|
463
|
+
await fetchJson(`${instanceUrl}/api/notes/delete`, { ...reqOpts, method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ i: credentials.accessToken, noteId: remoteId }) });
|
|
464
|
+
return {};
|
|
465
|
+
}
|
|
466
|
+
case 'bluesky': {
|
|
467
|
+
const [repo, collection, rkey] = String(remoteId).replace(/^at:\/\//, '').split('/');
|
|
468
|
+
if (!repo || !rkey) throw new Error('Bluesky投稿IDを解釈できません。');
|
|
469
|
+
const session = await fetchJson('https://bsky.social/xrpc/com.atproto.server.createSession', { ...reqOpts, method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ identifier: credentials.username, password: credentials.appPassword }) });
|
|
470
|
+
await fetchJson('https://bsky.social/xrpc/com.atproto.repo.deleteRecord', { ...reqOpts, method: 'POST', headers: { Authorization: `Bearer ${session.accessJwt}`, 'Content-Type': 'application/json' }, body: JSON.stringify({ repo, collection: collection || 'app.bsky.feed.post', rkey }) });
|
|
471
|
+
return {};
|
|
472
|
+
}
|
|
473
|
+
case 'threads':
|
|
474
|
+
await fetchJson(`https://graph.threads.com/v1.0/${encodeURIComponent(remoteId)}?access_token=${encodeURIComponent(credentials.accessToken)}`, { ...reqOpts, method: 'DELETE' });
|
|
475
|
+
return {};
|
|
476
|
+
default:
|
|
477
|
+
throw new Error('削除サービスが不正です。');
|
|
478
|
+
}
|
|
479
|
+
}
|