@odvi/create-dtt-framework 0.1.12 → 0.1.14
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/package.json +1 -1
- package/template/.env.example +13 -0
- package/template/docs/framework/environment-variables.md +44 -0
- package/template/docs/framework/integrations.md +78 -0
- package/template/package.json +3 -1
- package/template/src/config/env.ts +5 -0
- package/template/src/features/health-check/config.ts +8 -0
- package/template/src/lib/aws-s3/client.ts +103 -0
- package/template/src/lib/framework.ts +47 -0
- package/template/src/lib/nextbank/client.ts +49 -24
- package/template/src/lib/snowflake/client.ts +37 -0
- package/template/src/lib/synology/client.ts +239 -0
- package/template/src/server/api/routes/health/index.ts +4 -0
- package/template/src/server/api/routes/health/synology.ts +69 -0
package/package.json
CHANGED
package/template/.env.example
CHANGED
|
@@ -104,6 +104,19 @@ AWS_ACCESS_SECRET_KEY=wJalr...
|
|
|
104
104
|
# AWS Region where your S3 buckets are located (e.g., us-east-1)
|
|
105
105
|
AWS_REGION=us-east-1
|
|
106
106
|
|
|
107
|
+
|
|
108
|
+
# ========================================
|
|
109
|
+
# Synology (Optional)
|
|
110
|
+
# ========================================
|
|
111
|
+
# Synology DSM Host URL
|
|
112
|
+
SYNOLOGY_HOST=http://synology:5000
|
|
113
|
+
|
|
114
|
+
# Synology Username
|
|
115
|
+
SYNOLOGY_USERNAME=admin
|
|
116
|
+
|
|
117
|
+
# Synology Password
|
|
118
|
+
SYNOLOGY_PASSWORD=secret
|
|
119
|
+
|
|
107
120
|
# ========================================
|
|
108
121
|
# NextBank API (Placeholder)
|
|
109
122
|
# ========================================
|
|
@@ -110,6 +110,19 @@ AWS_ACCESS_SECRET_KEY=wJalr...
|
|
|
110
110
|
# AWS Region (e.g., us-east-1, ap-southeast-1)
|
|
111
111
|
AWS_REGION=us-east-1
|
|
112
112
|
|
|
113
|
+
# ============================================
|
|
114
|
+
# Synology (Optional)
|
|
115
|
+
# ============================================
|
|
116
|
+
|
|
117
|
+
# Synology DSM Host URL
|
|
118
|
+
SYNOLOGY_HOST=http://synology:5000
|
|
119
|
+
|
|
120
|
+
# Synology Username
|
|
121
|
+
SYNOLOGY_USERNAME=admin
|
|
122
|
+
|
|
123
|
+
# Synology Password
|
|
124
|
+
SYNOLOGY_PASSWORD=secret
|
|
125
|
+
|
|
113
126
|
# ============================================
|
|
114
127
|
# NextBank (Placeholder - Optional)
|
|
115
128
|
# ============================================
|
|
@@ -470,6 +483,37 @@ AWS_REGION=us-east-1
|
|
|
470
483
|
|
|
471
484
|
---
|
|
472
485
|
|
|
486
|
+
### Synology Variables (Optional)
|
|
487
|
+
|
|
488
|
+
#### SYNOLOGY_HOST
|
|
489
|
+
|
|
490
|
+
**Purpose:** URL of your Synology DSM
|
|
491
|
+
|
|
492
|
+
**Example:**
|
|
493
|
+
```bash
|
|
494
|
+
SYNOLOGY_HOST=http://192.168.1.100:5000
|
|
495
|
+
```
|
|
496
|
+
|
|
497
|
+
#### SYNOLOGY_USERNAME
|
|
498
|
+
|
|
499
|
+
**Purpose:** Username for Synology authentication
|
|
500
|
+
|
|
501
|
+
**Example:**
|
|
502
|
+
```bash
|
|
503
|
+
SYNOLOGY_USERNAME=admin
|
|
504
|
+
```
|
|
505
|
+
|
|
506
|
+
#### SYNOLOGY_PASSWORD
|
|
507
|
+
|
|
508
|
+
**Purpose:** Password for Synology authentication
|
|
509
|
+
|
|
510
|
+
**Example:**
|
|
511
|
+
```bash
|
|
512
|
+
SYNOLOGY_PASSWORD=secret
|
|
513
|
+
```
|
|
514
|
+
|
|
515
|
+
---
|
|
516
|
+
|
|
473
517
|
### NextBank Variables (Placeholder)
|
|
474
518
|
|
|
475
519
|
**Note:** NextBank is a placeholder integration. These variables are optional and not currently used.
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
# Reusable Integrations
|
|
2
|
+
|
|
3
|
+
The DTT Framework provides a unified interface to access all configured integrations through a single `framework` instance. This allows you to easily use third-party services in your application logic without manually managing connections or clients.
|
|
4
|
+
|
|
5
|
+
## Usage
|
|
6
|
+
|
|
7
|
+
You can import the `framework` instance from `@/lib/framework`.
|
|
8
|
+
|
|
9
|
+
```typescript
|
|
10
|
+
import { framework } from '@/lib/framework'
|
|
11
|
+
|
|
12
|
+
async function myFunction() {
|
|
13
|
+
// 1. Clerk Authentication
|
|
14
|
+
const user = await framework.clerk.users.getUser('user_id')
|
|
15
|
+
|
|
16
|
+
// 2. Supabase (Admin Access)
|
|
17
|
+
const { data } = await framework.supabase
|
|
18
|
+
.from('my_table')
|
|
19
|
+
.select('*')
|
|
20
|
+
|
|
21
|
+
// 3. Snowflake Query
|
|
22
|
+
const rows = await framework.snowflake.query('SELECT * FROM PUBLIC.USERS LIMIT 10')
|
|
23
|
+
|
|
24
|
+
// 4. NextBank API
|
|
25
|
+
const account = await framework.nextbank.get('/accounts/123')
|
|
26
|
+
await framework.nextbank.post('/transfers', { amount: 100 })
|
|
27
|
+
|
|
28
|
+
// 5. Synology File Operations
|
|
29
|
+
const files = await framework.synology.listFiles('/home/user')
|
|
30
|
+
const fileContent = await framework.synology.getFile('/home/user/doc.pdf')
|
|
31
|
+
|
|
32
|
+
// 6. AWS S3
|
|
33
|
+
await framework.s3.uploadFile('my-bucket', 'uploads/hello.txt', 'Hello World')
|
|
34
|
+
}
|
|
35
|
+
```
|
|
36
|
+
|
|
37
|
+
## Integration Details
|
|
38
|
+
|
|
39
|
+
### Clerk (`framework.clerk`)
|
|
40
|
+
Exposes the `@clerk/nextjs/server` client. Use this for server-side user management, organization handling, and session verification.
|
|
41
|
+
|
|
42
|
+
### Supabase (`framework.supabase`)
|
|
43
|
+
Exposes the Supabase Admin client (using `SUPABASE_SERVICE_ROLE_KEY`).
|
|
44
|
+
**Note:** This client has full admin privileges. Be careful when using it in client-facing code (it should only be used server-side).
|
|
45
|
+
|
|
46
|
+
### Snowflake (`framework.snowflake`)
|
|
47
|
+
Provides a simplified `query` method that handles connection pooling, execution, and cleanup automatically.
|
|
48
|
+
- `query(sqlText, binds?)`: Executes a SQL query.
|
|
49
|
+
|
|
50
|
+
### NextBank (`framework.nextbank`)
|
|
51
|
+
A wrapper around the NextBank API that automatically handles:
|
|
52
|
+
- Base URL injection
|
|
53
|
+
- Authentication headers
|
|
54
|
+
- JSON parsing
|
|
55
|
+
- Error handling
|
|
56
|
+
|
|
57
|
+
Methods:
|
|
58
|
+
- `get(endpoint)`
|
|
59
|
+
- `post(endpoint, body)`
|
|
60
|
+
- `put(endpoint, body)`
|
|
61
|
+
- `delete(endpoint)`
|
|
62
|
+
|
|
63
|
+
### Synology (`framework.synology`)
|
|
64
|
+
Provides methods to interact with Synology File Station.
|
|
65
|
+
- `listFiles(folderPath)`
|
|
66
|
+
- `getFile(filePath)`
|
|
67
|
+
- `deleteFile(filePath)`
|
|
68
|
+
|
|
69
|
+
### AWS S3 (`framework.s3`)
|
|
70
|
+
Wraps the AWS SDK S3 Client.
|
|
71
|
+
- `listBuckets()`
|
|
72
|
+
- `listFiles(bucket, prefix?)`
|
|
73
|
+
- `uploadFile(bucket, key, body, contentType?)`
|
|
74
|
+
- `getFile(bucket, key)`
|
|
75
|
+
- `deleteFile(bucket, key)`
|
|
76
|
+
- `getPresignedUrl(bucket, key, expiresIn?)`
|
|
77
|
+
- `getClient()`: Returns the raw AWS S3 Client for advanced operations.
|
|
78
|
+
|
package/template/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@odvi/dtt-framework",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.14",
|
|
4
4
|
"private": false,
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
@@ -64,6 +64,7 @@
|
|
|
64
64
|
},
|
|
65
65
|
"dependencies": {
|
|
66
66
|
"@aws-sdk/client-s3": "^3.958.0",
|
|
67
|
+
"@aws-sdk/s3-request-presigner": "^3.958.0",
|
|
67
68
|
"@clerk/nextjs": "^6.36.5",
|
|
68
69
|
"@radix-ui/react-collapsible": "^1.1.12",
|
|
69
70
|
"@radix-ui/react-dialog": "^1.1.15",
|
|
@@ -80,6 +81,7 @@
|
|
|
80
81
|
"gray-matter": "^4.0.3",
|
|
81
82
|
"highlight.js": "^11.11.1",
|
|
82
83
|
"hono": "^4.11.3",
|
|
84
|
+
"https-proxy-agent": "^7.0.6",
|
|
83
85
|
"lucide-react": "^0.562.0",
|
|
84
86
|
"next": "^15.2.3",
|
|
85
87
|
"postgres": "^3.4.4",
|
|
@@ -35,6 +35,11 @@ const envSchema = z.object({
|
|
|
35
35
|
AWS_ACCESS_KEY_ID: z.string().optional(),
|
|
36
36
|
AWS_ACCESS_SECRET_KEY: z.string().optional(),
|
|
37
37
|
AWS_REGION: z.string().optional(),
|
|
38
|
+
|
|
39
|
+
// Synology (optional)
|
|
40
|
+
SYNOLOGY_HOST: z.string().url().optional(),
|
|
41
|
+
SYNOLOGY_USERNAME: z.string().optional(),
|
|
42
|
+
SYNOLOGY_PASSWORD: z.string().optional(),
|
|
38
43
|
})
|
|
39
44
|
|
|
40
45
|
export const env = envSchema.parse(process.env)
|
|
@@ -52,6 +52,14 @@ export const SERVICES = [
|
|
|
52
52
|
{ name: 'Ping API', endpoint: '/nextbank/ping' },
|
|
53
53
|
],
|
|
54
54
|
},
|
|
55
|
+
{
|
|
56
|
+
name: 'Synology',
|
|
57
|
+
icon: 'server',
|
|
58
|
+
checks: [
|
|
59
|
+
{ name: 'Ping Host', endpoint: '/synology/ping' },
|
|
60
|
+
{ name: 'Authentication', endpoint: '/synology/auth' },
|
|
61
|
+
],
|
|
62
|
+
},
|
|
55
63
|
{
|
|
56
64
|
name: 'AWS S3',
|
|
57
65
|
icon: 'cloud',
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
import {
|
|
2
|
+
S3Client,
|
|
3
|
+
ListBucketsCommand,
|
|
4
|
+
PutObjectCommand,
|
|
5
|
+
GetObjectCommand,
|
|
6
|
+
DeleteObjectCommand,
|
|
7
|
+
ListObjectsV2Command
|
|
8
|
+
} from '@aws-sdk/client-s3'
|
|
9
|
+
import { getSignedUrl } from '@aws-sdk/s3-request-presigner'
|
|
10
|
+
import { env } from '@/config/env'
|
|
11
|
+
|
|
12
|
+
export class S3IntegrationClient {
|
|
13
|
+
private client: S3Client
|
|
14
|
+
private region: string
|
|
15
|
+
|
|
16
|
+
constructor() {
|
|
17
|
+
this.region = env.AWS_REGION ?? 'us-east-1'
|
|
18
|
+
|
|
19
|
+
// Initialize with credentials from env
|
|
20
|
+
this.client = new S3Client({
|
|
21
|
+
region: this.region,
|
|
22
|
+
credentials: {
|
|
23
|
+
accessKeyId: env.AWS_ACCESS_KEY_ID ?? '',
|
|
24
|
+
secretAccessKey: env.AWS_ACCESS_SECRET_KEY ?? '',
|
|
25
|
+
},
|
|
26
|
+
})
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Get the underlying S3 Client
|
|
31
|
+
*/
|
|
32
|
+
getClient(): S3Client {
|
|
33
|
+
return this.client
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* List all buckets
|
|
38
|
+
*/
|
|
39
|
+
async listBuckets() {
|
|
40
|
+
const command = new ListBucketsCommand({})
|
|
41
|
+
return this.client.send(command)
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* List files in a bucket
|
|
46
|
+
*/
|
|
47
|
+
async listFiles(bucket: string, prefix?: string) {
|
|
48
|
+
const command = new ListObjectsV2Command({
|
|
49
|
+
Bucket: bucket,
|
|
50
|
+
Prefix: prefix,
|
|
51
|
+
})
|
|
52
|
+
return this.client.send(command)
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Upload a file to S3
|
|
57
|
+
*/
|
|
58
|
+
async uploadFile(bucket: string, key: string, body: Buffer | Uint8Array | Blob | string, contentType?: string) {
|
|
59
|
+
const command = new PutObjectCommand({
|
|
60
|
+
Bucket: bucket,
|
|
61
|
+
Key: key,
|
|
62
|
+
Body: body,
|
|
63
|
+
ContentType: contentType,
|
|
64
|
+
})
|
|
65
|
+
return this.client.send(command)
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* Get a file from S3 (returns the raw output)
|
|
70
|
+
*/
|
|
71
|
+
async getFile(bucket: string, key: string) {
|
|
72
|
+
const command = new GetObjectCommand({
|
|
73
|
+
Bucket: bucket,
|
|
74
|
+
Key: key,
|
|
75
|
+
})
|
|
76
|
+
return this.client.send(command)
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* Generate a presigned URL for a file
|
|
81
|
+
*/
|
|
82
|
+
async getPresignedUrl(bucket: string, key: string, expiresIn = 3600) {
|
|
83
|
+
const command = new GetObjectCommand({
|
|
84
|
+
Bucket: bucket,
|
|
85
|
+
Key: key,
|
|
86
|
+
})
|
|
87
|
+
return getSignedUrl(this.client, command, { expiresIn })
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* Delete a file from S3
|
|
92
|
+
*/
|
|
93
|
+
async deleteFile(bucket: string, key: string) {
|
|
94
|
+
const command = new DeleteObjectCommand({
|
|
95
|
+
Bucket: bucket,
|
|
96
|
+
Key: key,
|
|
97
|
+
})
|
|
98
|
+
return this.client.send(command)
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
export const s3Client = new S3IntegrationClient()
|
|
103
|
+
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
import { clerkClient } from '@clerk/nextjs/server'
|
|
2
|
+
import { supabaseAdmin } from './supabase/admin'
|
|
3
|
+
import { nextbankClient } from './nextbank/client'
|
|
4
|
+
import { synologyClient } from './synology/client'
|
|
5
|
+
import { snowflakeClient } from './snowflake/client'
|
|
6
|
+
import { s3Client } from './aws-s3/client'
|
|
7
|
+
|
|
8
|
+
export class Framework {
|
|
9
|
+
/**
|
|
10
|
+
* Clerk Authentication SDK
|
|
11
|
+
* Use this to manage users, sessions, and organizations.
|
|
12
|
+
*/
|
|
13
|
+
public clerk = clerkClient
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Supabase Admin Client (Service Role)
|
|
17
|
+
* Use this for database and storage operations with full privileges.
|
|
18
|
+
*/
|
|
19
|
+
public supabase = supabaseAdmin
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Snowflake Client
|
|
23
|
+
* Use this to execute SQL queries against your Snowflake data warehouse.
|
|
24
|
+
*/
|
|
25
|
+
public snowflake = snowflakeClient
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* AWS S3 Client
|
|
29
|
+
* Use this to manage files in AWS S3 buckets.
|
|
30
|
+
*/
|
|
31
|
+
public s3 = s3Client
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* NextBank Integration
|
|
35
|
+
* Use this to interact with the NextBank API.
|
|
36
|
+
*/
|
|
37
|
+
public nextbank = nextbankClient
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Synology Integration
|
|
41
|
+
* Use this to manage files on a Synology NAS.
|
|
42
|
+
*/
|
|
43
|
+
public synology = synologyClient
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export const framework = new Framework()
|
|
47
|
+
|
|
@@ -17,22 +17,21 @@ class NextBankClient {
|
|
|
17
17
|
return { Authorization: `Basic ${token}` }
|
|
18
18
|
}
|
|
19
19
|
|
|
20
|
-
async
|
|
21
|
-
const url = `${this.apiUrl}/
|
|
22
|
-
console.log(`[NextBank] Pinging ${url}`)
|
|
20
|
+
private async request<T = any>(endpoint: string, options: RequestInit = {}): Promise<T> {
|
|
21
|
+
const url = `${this.apiUrl}${endpoint.startsWith('/') ? endpoint : `/${endpoint}`}`
|
|
23
22
|
|
|
23
|
+
const headers = {
|
|
24
|
+
...this.getAuthHeader(),
|
|
25
|
+
'Content-Type': 'application/json',
|
|
26
|
+
'Accept': 'application/json',
|
|
27
|
+
'User-Agent': 'dtt-framework-client',
|
|
28
|
+
...options.headers,
|
|
29
|
+
}
|
|
30
|
+
|
|
24
31
|
try {
|
|
25
32
|
const response = await fetch(url, {
|
|
26
|
-
|
|
27
|
-
headers
|
|
28
|
-
...this.getAuthHeader(),
|
|
29
|
-
'Content-Type': 'application/json',
|
|
30
|
-
'Accept': 'application/json',
|
|
31
|
-
'User-Agent': 'dtt-framework-health-check',
|
|
32
|
-
},
|
|
33
|
-
body: JSON.stringify({
|
|
34
|
-
fingerprint: fingerprint,
|
|
35
|
-
}),
|
|
33
|
+
...options,
|
|
34
|
+
headers,
|
|
36
35
|
})
|
|
37
36
|
|
|
38
37
|
const contentType = response.headers.get('content-type')
|
|
@@ -40,28 +39,54 @@ class NextBankClient {
|
|
|
40
39
|
if (!response.ok) {
|
|
41
40
|
if (contentType && contentType.includes('text/html')) {
|
|
42
41
|
const text = await response.text()
|
|
43
|
-
|
|
44
|
-
console.error('1. NEXTBANK_API is pointing to the wrong server (e.g. Next.js app instead of NextBank API)')
|
|
45
|
-
console.error('2. The endpoint /management/status does not exist or does not support POST')
|
|
46
|
-
console.error(`[NextBank] Response preview: ${text.substring(0, 150)}...`)
|
|
47
|
-
throw new Error(`NextBank API error: ${response.status} (HTML response)`)
|
|
42
|
+
throw new Error(`NextBank API error: ${response.status} (HTML response): ${text.substring(0, 100)}...`)
|
|
48
43
|
}
|
|
49
44
|
throw new Error(`NextBank API error: ${response.status} ${response.statusText}`)
|
|
50
45
|
}
|
|
51
46
|
|
|
52
47
|
if (contentType && contentType.includes('application/json')) {
|
|
53
48
|
return await response.json()
|
|
54
|
-
}
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
// If not JSON, return text if generic T is string, or try to return as is?
|
|
52
|
+
// For now, assume JSON or void.
|
|
53
|
+
const text = await response.text()
|
|
54
|
+
try {
|
|
55
|
+
return JSON.parse(text)
|
|
56
|
+
} catch {
|
|
57
|
+
return text as unknown as T
|
|
59
58
|
}
|
|
60
59
|
} catch (error) {
|
|
61
|
-
console.error(
|
|
60
|
+
console.error(`[NextBank] Request to ${endpoint} failed:`, error)
|
|
62
61
|
throw error
|
|
63
62
|
}
|
|
64
63
|
}
|
|
64
|
+
|
|
65
|
+
async get<T = any>(endpoint: string): Promise<T> {
|
|
66
|
+
return this.request<T>(endpoint, { method: 'GET' })
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
async post<T = any>(endpoint: string, body: any): Promise<T> {
|
|
70
|
+
return this.request<T>(endpoint, {
|
|
71
|
+
method: 'POST',
|
|
72
|
+
body: JSON.stringify(body)
|
|
73
|
+
})
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
async put<T = any>(endpoint: string, body: any): Promise<T> {
|
|
77
|
+
return this.request<T>(endpoint, {
|
|
78
|
+
method: 'PUT',
|
|
79
|
+
body: JSON.stringify(body)
|
|
80
|
+
})
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
async delete<T = any>(endpoint: string): Promise<T> {
|
|
84
|
+
return this.request<T>(endpoint, { method: 'DELETE' })
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
async ping(fingerprint: string): Promise<{ status: string; timestamp: string }> {
|
|
88
|
+
return this.post('/management/status', { fingerprint })
|
|
89
|
+
}
|
|
65
90
|
}
|
|
66
91
|
|
|
67
92
|
export const nextbankClient = new NextBankClient()
|
|
@@ -100,3 +100,40 @@ export async function destroyConnection(connection: Connection): Promise<void> {
|
|
|
100
100
|
})
|
|
101
101
|
})
|
|
102
102
|
}
|
|
103
|
+
|
|
104
|
+
export class SnowflakeClient {
|
|
105
|
+
async query<T = unknown>(sqlText: string, binds?: Bind[]): Promise<T[]> {
|
|
106
|
+
let connection: Connection | undefined
|
|
107
|
+
try {
|
|
108
|
+
connection = await createSnowflakeConnection()
|
|
109
|
+
|
|
110
|
+
// Connect
|
|
111
|
+
await new Promise<void>((resolve, reject) => {
|
|
112
|
+
connection!.connect((err) => {
|
|
113
|
+
if (err) reject(err)
|
|
114
|
+
else resolve()
|
|
115
|
+
})
|
|
116
|
+
})
|
|
117
|
+
|
|
118
|
+
// Execute
|
|
119
|
+
return await new Promise<T[]>((resolve, reject) => {
|
|
120
|
+
connection!.execute({
|
|
121
|
+
sqlText,
|
|
122
|
+
binds: binds as any,
|
|
123
|
+
complete: (err, stmt, rows) => {
|
|
124
|
+
if (err) reject(err)
|
|
125
|
+
else resolve((rows || []) as T[])
|
|
126
|
+
},
|
|
127
|
+
})
|
|
128
|
+
})
|
|
129
|
+
} finally {
|
|
130
|
+
if (connection) {
|
|
131
|
+
connection.destroy((err) => {
|
|
132
|
+
if (err) console.error('Error destroying Snowflake connection:', err)
|
|
133
|
+
})
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
export const snowflakeClient = new SnowflakeClient()
|
|
@@ -0,0 +1,239 @@
|
|
|
1
|
+
import { env } from '@/config/env'
|
|
2
|
+
import https from 'https'
|
|
3
|
+
|
|
4
|
+
class SynologyClient {
|
|
5
|
+
private host: string
|
|
6
|
+
private username: string
|
|
7
|
+
private password: string
|
|
8
|
+
private sessionID: string | null = null
|
|
9
|
+
|
|
10
|
+
constructor() {
|
|
11
|
+
this.host = env.SYNOLOGY_HOST ?? ''
|
|
12
|
+
this.username = env.SYNOLOGY_USERNAME ?? ''
|
|
13
|
+
this.password = env.SYNOLOGY_PASSWORD ?? ''
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
// Helper to perform fetch with custom agent for self-signed certs
|
|
17
|
+
private async fetchWithAgent(url: string, options: RequestInit = {}) {
|
|
18
|
+
// For local development with self-signed certs, especially with Next.js/Node fetch,
|
|
19
|
+
// explicitly ignoring certificate errors at the process level for this specific request
|
|
20
|
+
// context is sometimes the most reliable way if agents aren't being picked up.
|
|
21
|
+
|
|
22
|
+
// Create an agent that ignores self-signed certificate errors
|
|
23
|
+
const agent = new https.Agent({
|
|
24
|
+
rejectUnauthorized: false
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
// Note: In Next.js App Router (server components/actions), fetch is patched.
|
|
28
|
+
// Standard https.Agent might not work as expected with the patched fetch.
|
|
29
|
+
// However, for API routes (Pages router) or Route Handlers (App router), it runs in Node environment.
|
|
30
|
+
// If the error persists, it's often because the fetch implementation doesn't support the 'agent' option directly
|
|
31
|
+
// or is using Undici which needs a Dispatcher.
|
|
32
|
+
|
|
33
|
+
// Let's try a more robust approach:
|
|
34
|
+
// 1. Pass 'agent' for node-fetch compatibility
|
|
35
|
+
// 2. Pass 'dispatcher' for Undici compatibility (Node 18+ native fetch)
|
|
36
|
+
// 3. Set 'cache: no-store' to ensure we aren't hitting cached responses
|
|
37
|
+
|
|
38
|
+
// @ts-ignore
|
|
39
|
+
const fetchOptions: any = {
|
|
40
|
+
...options,
|
|
41
|
+
cache: 'no-store',
|
|
42
|
+
// 'agent' is for node-fetch / older polyfills
|
|
43
|
+
agent: agent,
|
|
44
|
+
};
|
|
45
|
+
|
|
46
|
+
// Attempt to handle Undici (Node 18+ native fetch) which Next.js uses
|
|
47
|
+
// We can't easily import 'Agent' from undici without adding it as dependency,
|
|
48
|
+
// but we can try to rely on the fact that sometimes just the node-fetch 'agent' isn't enough.
|
|
49
|
+
|
|
50
|
+
try {
|
|
51
|
+
return await fetch(url, fetchOptions);
|
|
52
|
+
} catch (err: any) {
|
|
53
|
+
// If fetch fails with a certificate error, it might be because 'agent' was ignored.
|
|
54
|
+
// As a fallback for development environments, we can temporarily disable TLS check
|
|
55
|
+
// WARNING: This is dangerous in production, but acceptable for "Synology local IP" scenarios
|
|
56
|
+
// if strictly scoped or if the user explicitly configured it.
|
|
57
|
+
|
|
58
|
+
if (err.message?.includes('certificate') || err.cause?.message?.includes('certificate') || err.cause?.code === 'DEPTH_ZERO_SELF_SIGNED_CERT') {
|
|
59
|
+
console.warn('[Synology] Certificate error detected. Retrying with permissive fetch...');
|
|
60
|
+
|
|
61
|
+
// This is a hacky workaround for environments where Agent isn't working for fetch
|
|
62
|
+
const originalReject = process.env.NODE_TLS_REJECT_UNAUTHORIZED;
|
|
63
|
+
process.env.NODE_TLS_REJECT_UNAUTHORIZED = '0';
|
|
64
|
+
|
|
65
|
+
try {
|
|
66
|
+
const retryResponse = await fetch(url, { ...options, cache: 'no-store' });
|
|
67
|
+
return retryResponse;
|
|
68
|
+
} finally {
|
|
69
|
+
// Restore original setting immediately
|
|
70
|
+
process.env.NODE_TLS_REJECT_UNAUTHORIZED = originalReject;
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
throw err;
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
async listFiles(folderPath: string) {
|
|
78
|
+
if (!this.sessionID) await this.login()
|
|
79
|
+
|
|
80
|
+
const params = new URLSearchParams({
|
|
81
|
+
api: 'SYNO.FileStation.List',
|
|
82
|
+
version: '2',
|
|
83
|
+
method: 'list',
|
|
84
|
+
folder_path: folderPath,
|
|
85
|
+
_sid: this.sessionID!
|
|
86
|
+
})
|
|
87
|
+
|
|
88
|
+
const url = `${this.host}/webapi/entry.cgi?${params.toString()}`
|
|
89
|
+
const response = await this.fetchWithAgent(url)
|
|
90
|
+
|
|
91
|
+
if (!response.ok) {
|
|
92
|
+
throw new Error(`Synology ListFiles error: ${response.status}`)
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
const data = await response.json()
|
|
96
|
+
if (!data.success) {
|
|
97
|
+
throw new Error(`Synology ListFiles failed: ${JSON.stringify(data)}`)
|
|
98
|
+
}
|
|
99
|
+
return data.data.files
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
async getFile(filePath: string) {
|
|
103
|
+
if (!this.sessionID) await this.login()
|
|
104
|
+
|
|
105
|
+
const params = new URLSearchParams({
|
|
106
|
+
api: 'SYNO.FileStation.Download',
|
|
107
|
+
version: '2',
|
|
108
|
+
method: 'download',
|
|
109
|
+
path: filePath,
|
|
110
|
+
mode: 'open',
|
|
111
|
+
_sid: this.sessionID!
|
|
112
|
+
})
|
|
113
|
+
|
|
114
|
+
const url = `${this.host}/webapi/entry.cgi?${params.toString()}`
|
|
115
|
+
const response = await this.fetchWithAgent(url)
|
|
116
|
+
|
|
117
|
+
if (!response.ok) {
|
|
118
|
+
throw new Error(`Synology GetFile error: ${response.status}`)
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
// Return array buffer or blob?
|
|
122
|
+
return response.arrayBuffer()
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
async deleteFile(filePath: string) {
|
|
126
|
+
if (!this.sessionID) await this.login()
|
|
127
|
+
|
|
128
|
+
const params = new URLSearchParams({
|
|
129
|
+
api: 'SYNO.FileStation.Delete',
|
|
130
|
+
version: '2',
|
|
131
|
+
method: 'delete',
|
|
132
|
+
path: filePath,
|
|
133
|
+
_sid: this.sessionID!
|
|
134
|
+
})
|
|
135
|
+
|
|
136
|
+
const url = `${this.host}/webapi/entry.cgi?${params.toString()}`
|
|
137
|
+
const response = await this.fetchWithAgent(url)
|
|
138
|
+
|
|
139
|
+
if (!response.ok) {
|
|
140
|
+
throw new Error(`Synology DeleteFile error: ${response.status}`)
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
const data = await response.json()
|
|
144
|
+
if (!data.success) {
|
|
145
|
+
throw new Error(`Synology DeleteFile failed: ${JSON.stringify(data)}`)
|
|
146
|
+
}
|
|
147
|
+
return data
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
async ping(): Promise<{ status: string; timestamp: string; version?: string; sid?: string }> {
|
|
151
|
+
if (!this.host) {
|
|
152
|
+
throw new Error('Synology host not configured')
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
const params = new URLSearchParams({
|
|
156
|
+
api: 'SYNO.API.Info',
|
|
157
|
+
version: '1',
|
|
158
|
+
method: 'query',
|
|
159
|
+
query: 'SYNO.API.Auth'
|
|
160
|
+
})
|
|
161
|
+
|
|
162
|
+
const infoUrl = `${this.host}/webapi/query.cgi?${params.toString()}`
|
|
163
|
+
console.log(`[Synology] Pinging ${infoUrl}`)
|
|
164
|
+
|
|
165
|
+
try {
|
|
166
|
+
const response = await this.fetchWithAgent(infoUrl)
|
|
167
|
+
|
|
168
|
+
if (!response.ok) {
|
|
169
|
+
throw new Error(`Synology API error: ${response.status} ${response.statusText}`)
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
const data = await response.json()
|
|
173
|
+
|
|
174
|
+
if (!data.success) {
|
|
175
|
+
throw new Error(`Synology API returned error: ${JSON.stringify(data)}`)
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
let sid: string | undefined
|
|
179
|
+
|
|
180
|
+
// If we have credentials, try to login to verify them
|
|
181
|
+
if (this.username && this.password) {
|
|
182
|
+
sid = await this.login()
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
return {
|
|
186
|
+
status: 'healthy',
|
|
187
|
+
timestamp: new Date().toISOString(),
|
|
188
|
+
version: data.data?.['SYNO.API.Auth']?.maxVersion?.toString(),
|
|
189
|
+
sid
|
|
190
|
+
}
|
|
191
|
+
} catch (error) {
|
|
192
|
+
console.error('[Synology] Ping failed:', error)
|
|
193
|
+
throw error
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
private async login(): Promise<string> {
|
|
198
|
+
const loginUrl = `${this.host}/webapi/auth.cgi`
|
|
199
|
+
|
|
200
|
+
const params = new URLSearchParams({
|
|
201
|
+
api: 'SYNO.API.Auth',
|
|
202
|
+
version: '6',
|
|
203
|
+
method: 'login',
|
|
204
|
+
account: this.username,
|
|
205
|
+
passwd: this.password,
|
|
206
|
+
session: 'FileStation',
|
|
207
|
+
format: 'sid'
|
|
208
|
+
})
|
|
209
|
+
|
|
210
|
+
console.log(`[Synology] Logging in...`)
|
|
211
|
+
|
|
212
|
+
try {
|
|
213
|
+
const response = await this.fetchWithAgent(`${loginUrl}?${params.toString()}`)
|
|
214
|
+
|
|
215
|
+
if (!response.ok) {
|
|
216
|
+
throw new Error(`Synology Auth error: ${response.status} ${response.statusText}`)
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
const data = await response.json()
|
|
220
|
+
|
|
221
|
+
if (!data.success) {
|
|
222
|
+
throw new Error(`Synology Auth failed: ${JSON.stringify(data)}`)
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
if (data.data?.sid) {
|
|
226
|
+
this.sessionID = data.data.sid
|
|
227
|
+
return this.sessionID!
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
throw new Error('No SID in response')
|
|
231
|
+
} catch (error) {
|
|
232
|
+
console.error('[Synology] Login error:', error)
|
|
233
|
+
this.sessionID = null
|
|
234
|
+
throw error
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
export const synologyClient = new SynologyClient()
|
|
@@ -5,6 +5,7 @@ import { storageHealthRoutes } from './storage'
|
|
|
5
5
|
import { edgeFunctionsHealthRoutes } from './edge-functions'
|
|
6
6
|
import { snowflakeHealthRoutes } from './snowflake'
|
|
7
7
|
import { nextbankHealthRoutes } from './nextbank'
|
|
8
|
+
import { synologyHealthRoutes } from './synology'
|
|
8
9
|
import { frameworkHealthRoutes } from './framework'
|
|
9
10
|
import { awsS3HealthRoutes } from './aws-s3'
|
|
10
11
|
|
|
@@ -16,6 +17,7 @@ healthRoutes.route('/storage', storageHealthRoutes)
|
|
|
16
17
|
healthRoutes.route('/edge', edgeFunctionsHealthRoutes)
|
|
17
18
|
healthRoutes.route('/snowflake', snowflakeHealthRoutes)
|
|
18
19
|
healthRoutes.route('/nextbank', nextbankHealthRoutes)
|
|
20
|
+
healthRoutes.route('/synology', synologyHealthRoutes)
|
|
19
21
|
healthRoutes.route('/framework', frameworkHealthRoutes)
|
|
20
22
|
healthRoutes.route('/aws-s3', awsS3HealthRoutes)
|
|
21
23
|
|
|
@@ -40,6 +42,8 @@ healthRoutes.get('/all', async (c) => {
|
|
|
40
42
|
{ name: 'Snowflake Query', url: `${baseUrl}/api/health/snowflake/query` },
|
|
41
43
|
{ name: 'NextBank Ping', url: `${baseUrl}/api/health/nextbank/ping` },
|
|
42
44
|
{ name: 'NextBank Auth', url: `${baseUrl}/api/health/nextbank/auth` },
|
|
45
|
+
{ name: 'Synology Ping', url: `${baseUrl}/api/health/synology/ping` },
|
|
46
|
+
{ name: 'Synology Auth', url: `${baseUrl}/api/health/synology/auth` },
|
|
43
47
|
{ name: 'AWS S3 Connect', url: `${baseUrl}/api/health/aws-s3/connect` },
|
|
44
48
|
]
|
|
45
49
|
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
import { Hono } from 'hono'
|
|
2
|
+
import { synologyClient } from '@/lib/synology/client'
|
|
3
|
+
import { env } from '@/config/env'
|
|
4
|
+
|
|
5
|
+
export const synologyHealthRoutes = new Hono()
|
|
6
|
+
|
|
7
|
+
synologyHealthRoutes.get('/ping', async (c) => {
|
|
8
|
+
const start = performance.now()
|
|
9
|
+
|
|
10
|
+
if (!env.SYNOLOGY_HOST) {
|
|
11
|
+
return c.json({
|
|
12
|
+
status: 'unconfigured',
|
|
13
|
+
responseTimeMs: Math.round(performance.now() - start),
|
|
14
|
+
message: 'Synology Host not configured',
|
|
15
|
+
})
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
try {
|
|
19
|
+
const result = await synologyClient.ping()
|
|
20
|
+
return c.json({
|
|
21
|
+
status: 'healthy',
|
|
22
|
+
responseTimeMs: Math.round(performance.now() - start),
|
|
23
|
+
message: 'Successfully connected to Synology',
|
|
24
|
+
data: result,
|
|
25
|
+
})
|
|
26
|
+
} catch (error) {
|
|
27
|
+
return c.json(
|
|
28
|
+
{
|
|
29
|
+
status: 'error',
|
|
30
|
+
responseTimeMs: Math.round(performance.now() - start),
|
|
31
|
+
error: error instanceof Error ? error.message : 'Ping failed',
|
|
32
|
+
},
|
|
33
|
+
500
|
|
34
|
+
)
|
|
35
|
+
}
|
|
36
|
+
})
|
|
37
|
+
|
|
38
|
+
synologyHealthRoutes.get('/auth', async (c) => {
|
|
39
|
+
const start = performance.now()
|
|
40
|
+
|
|
41
|
+
if (!env.SYNOLOGY_HOST || !env.SYNOLOGY_USERNAME || !env.SYNOLOGY_PASSWORD) {
|
|
42
|
+
return c.json({
|
|
43
|
+
status: 'unconfigured',
|
|
44
|
+
responseTimeMs: Math.round(performance.now() - start),
|
|
45
|
+
message: 'Synology credentials not configured',
|
|
46
|
+
})
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
try {
|
|
50
|
+
// The ping method already does a login check if credentials are provided
|
|
51
|
+
const result = await synologyClient.ping()
|
|
52
|
+
return c.json({
|
|
53
|
+
status: 'healthy',
|
|
54
|
+
responseTimeMs: Math.round(performance.now() - start),
|
|
55
|
+
message: 'Successfully authenticated with Synology',
|
|
56
|
+
data: result,
|
|
57
|
+
})
|
|
58
|
+
} catch (error) {
|
|
59
|
+
return c.json(
|
|
60
|
+
{
|
|
61
|
+
status: 'error',
|
|
62
|
+
responseTimeMs: Math.round(performance.now() - start),
|
|
63
|
+
error: error instanceof Error ? error.message : 'Auth failed',
|
|
64
|
+
},
|
|
65
|
+
500
|
|
66
|
+
)
|
|
67
|
+
}
|
|
68
|
+
})
|
|
69
|
+
|