opensoma 0.1.2 → 0.2.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/dist/package.json +18 -2
- package/dist/src/client.d.ts +8 -0
- package/dist/src/client.d.ts.map +1 -1
- package/dist/src/client.js +123 -21
- package/dist/src/client.js.map +1 -1
- package/dist/src/commands/auth.d.ts +8 -0
- package/dist/src/commands/auth.d.ts.map +1 -1
- package/dist/src/commands/auth.js +35 -23
- package/dist/src/commands/auth.js.map +1 -1
- package/dist/src/commands/dashboard.d.ts.map +1 -1
- package/dist/src/commands/dashboard.js +1 -1
- package/dist/src/commands/dashboard.js.map +1 -1
- package/dist/src/commands/event.d.ts.map +1 -1
- package/dist/src/commands/event.js.map +1 -1
- package/dist/src/commands/helpers.d.ts.map +1 -1
- package/dist/src/commands/helpers.js +12 -2
- package/dist/src/commands/helpers.js.map +1 -1
- package/dist/src/commands/member.d.ts.map +1 -1
- package/dist/src/commands/member.js.map +1 -1
- package/dist/src/commands/mentoring.d.ts.map +1 -1
- package/dist/src/commands/mentoring.js +14 -5
- package/dist/src/commands/mentoring.js.map +1 -1
- package/dist/src/commands/notice.d.ts.map +1 -1
- package/dist/src/commands/notice.js.map +1 -1
- package/dist/src/commands/room.d.ts.map +1 -1
- package/dist/src/commands/room.js.map +1 -1
- package/dist/src/commands/team.d.ts.map +1 -1
- package/dist/src/commands/team.js.map +1 -1
- package/dist/src/errors.d.ts +8 -0
- package/dist/src/errors.d.ts.map +1 -0
- package/dist/src/errors.js +11 -0
- package/dist/src/errors.js.map +1 -0
- package/dist/src/formatters.d.ts.map +1 -1
- package/dist/src/formatters.js +54 -7
- package/dist/src/formatters.js.map +1 -1
- package/dist/src/http.d.ts +5 -0
- package/dist/src/http.d.ts.map +1 -1
- package/dist/src/http.js +140 -6
- package/dist/src/http.js.map +1 -1
- package/dist/src/index.d.ts +1 -0
- package/dist/src/index.d.ts.map +1 -1
- package/dist/src/index.js +1 -0
- package/dist/src/index.js.map +1 -1
- package/dist/src/shared/utils/mentoring-params.d.ts.map +1 -1
- package/dist/src/shared/utils/mentoring-params.js +4 -1
- package/dist/src/shared/utils/mentoring-params.js.map +1 -1
- package/dist/src/shared/utils/swmaestro.d.ts.map +1 -1
- package/dist/src/shared/utils/swmaestro.js.map +1 -1
- package/dist/src/token-extractor.d.ts +12 -0
- package/dist/src/token-extractor.d.ts.map +1 -1
- package/dist/src/token-extractor.js +83 -18
- package/dist/src/token-extractor.js.map +1 -1
- package/dist/src/types.d.ts +17 -0
- package/dist/src/types.d.ts.map +1 -1
- package/dist/src/types.js +6 -0
- package/dist/src/types.js.map +1 -1
- package/package.json +17 -1
- package/src/client.test.ts +112 -12
- package/src/client.ts +136 -36
- package/src/commands/auth.test.ts +55 -0
- package/src/commands/auth.ts +57 -33
- package/src/commands/dashboard.ts +5 -6
- package/src/commands/event.ts +5 -6
- package/src/commands/helpers.ts +21 -4
- package/src/commands/member.ts +4 -5
- package/src/commands/mentoring.ts +36 -19
- package/src/commands/notice.ts +4 -5
- package/src/commands/room.ts +4 -5
- package/src/commands/team.ts +4 -5
- package/src/credential-manager.test.ts +1 -1
- package/src/credential-manager.ts +1 -1
- package/src/errors.ts +10 -0
- package/src/formatters.test.ts +1 -1
- package/src/formatters.ts +91 -18
- package/src/http.test.ts +43 -7
- package/src/http.ts +174 -8
- package/src/index.ts +1 -0
- package/src/shared/utils/mentoring-params.test.ts +9 -4
- package/src/shared/utils/mentoring-params.ts +6 -3
- package/src/shared/utils/swmaestro.ts +2 -2
- package/src/token-extractor.test.ts +84 -8
- package/src/token-extractor.ts +118 -22
- package/src/types.test.ts +4 -2
- package/src/types.ts +6 -0
- package/.claude-plugin/README.md +0 -145
- package/.claude-plugin/plugin.json +0 -23
- package/.github/workflows/release.yml +0 -86
- package/.oxfmtrc.json +0 -9
- package/.oxlintrc.json +0 -4
- package/AGENTS.md +0 -78
- package/README.md +0 -252
- package/bun.lock +0 -297
- package/bunfig.toml +0 -2
- package/e2e/.gitkeep +0 -0
- package/skills/opensoma/SKILL.md +0 -345
- package/skills/opensoma/references/common-patterns.md +0 -182
- package/skills/opensoma/references/output-format.md +0 -130
package/src/http.ts
CHANGED
|
@@ -1,9 +1,16 @@
|
|
|
1
|
-
import { BASE_URL, MENU_NO } from '
|
|
2
|
-
import {
|
|
1
|
+
import { BASE_URL, MENU_NO } from './constants'
|
|
2
|
+
import { AuthenticationError } from './errors'
|
|
3
|
+
import { parseCsrfToken } from './formatters'
|
|
4
|
+
|
|
5
|
+
const DEFAULT_USER_AGENT =
|
|
6
|
+
'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/147.0.0.0 Safari/537.36'
|
|
7
|
+
const DEFAULT_ACCEPT_LANGUAGE = 'ko,en-US;q=0.9,en;q=0.8'
|
|
3
8
|
|
|
4
9
|
interface RequestOptions {
|
|
5
10
|
sessionCookie?: string
|
|
11
|
+
cookies?: string
|
|
6
12
|
csrfToken?: string
|
|
13
|
+
verbose?: boolean
|
|
7
14
|
}
|
|
8
15
|
|
|
9
16
|
interface CheckLoginResponse {
|
|
@@ -23,11 +30,17 @@ interface HeadersWithCookieHelpers extends Omit<Headers, 'getSetCookie'> {
|
|
|
23
30
|
export class SomaHttp {
|
|
24
31
|
private cookies = new Map<string, string>()
|
|
25
32
|
private csrfToken: string | null
|
|
33
|
+
private verbose: boolean
|
|
26
34
|
|
|
27
35
|
constructor(options?: RequestOptions) {
|
|
28
36
|
this.csrfToken = options?.csrfToken ?? null
|
|
37
|
+
this.verbose = options?.verbose ?? false
|
|
29
38
|
|
|
30
|
-
if (options?.
|
|
39
|
+
if (options?.cookies) {
|
|
40
|
+
for (const cookie of options.cookies.split(';')) {
|
|
41
|
+
this.setCookie(cookie.trim())
|
|
42
|
+
}
|
|
43
|
+
} else if (options?.sessionCookie) {
|
|
31
44
|
this.setCookie(
|
|
32
45
|
options.sessionCookie.includes('=') ? options.sessionCookie : `JSESSIONID=${options.sessionCookie}`,
|
|
33
46
|
)
|
|
@@ -41,22 +54,148 @@ export class SomaHttp {
|
|
|
41
54
|
})
|
|
42
55
|
|
|
43
56
|
this.updateFromResponse(response)
|
|
44
|
-
|
|
57
|
+
const body = await response.text()
|
|
58
|
+
|
|
59
|
+
const errorInfo = this.extractErrorFromResponse(body, null, path)
|
|
60
|
+
if (errorInfo === '__AUTH_ERROR__') {
|
|
61
|
+
throw new AuthenticationError()
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
return body
|
|
45
65
|
}
|
|
46
66
|
|
|
47
67
|
async post(path: string, body: Record<string, string>): Promise<string> {
|
|
68
|
+
const url = this.buildUrl(path)
|
|
48
69
|
const formBody = new URLSearchParams(this.buildBody(body))
|
|
49
|
-
|
|
70
|
+
|
|
71
|
+
let response = await fetch(url, {
|
|
50
72
|
method: 'POST',
|
|
51
73
|
headers: {
|
|
52
74
|
...this.buildHeaders(),
|
|
53
75
|
'Content-Type': 'application/x-www-form-urlencoded',
|
|
54
76
|
},
|
|
55
77
|
body: formBody.toString(),
|
|
78
|
+
redirect: 'manual',
|
|
56
79
|
})
|
|
57
80
|
|
|
58
81
|
this.updateFromResponse(response)
|
|
59
|
-
|
|
82
|
+
|
|
83
|
+
if (response.status >= 300 && response.status < 400) {
|
|
84
|
+
const location = response.headers.get('location')
|
|
85
|
+
const intermediateBody = await response.clone().text()
|
|
86
|
+
const errorInfo = this.extractErrorFromResponse(intermediateBody, location, path)
|
|
87
|
+
if (errorInfo) {
|
|
88
|
+
if (errorInfo === '__AUTH_ERROR__') {
|
|
89
|
+
throw new AuthenticationError()
|
|
90
|
+
}
|
|
91
|
+
throw new Error(errorInfo)
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
let finalUrl = url
|
|
96
|
+
while (response.status >= 300 && response.status < 400) {
|
|
97
|
+
const location = response.headers.get('location')
|
|
98
|
+
if (!location) break
|
|
99
|
+
|
|
100
|
+
const redirectUrl = location.startsWith('http') ? location : new URL(location, `${BASE_URL}/`).toString()
|
|
101
|
+
finalUrl = redirectUrl
|
|
102
|
+
response = await fetch(redirectUrl, {
|
|
103
|
+
method: 'GET',
|
|
104
|
+
headers: this.buildHeaders(),
|
|
105
|
+
redirect: 'manual',
|
|
106
|
+
})
|
|
107
|
+
this.updateFromResponse(response)
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
if (!response.ok) {
|
|
111
|
+
throw new Error(`HTTP ${response.status}: ${response.statusText}`)
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
const finalBody = await response.text()
|
|
115
|
+
this.log('POST', path, '-> Final URL:', finalUrl)
|
|
116
|
+
this.log('POST', path, '-> Response body (first 200 chars):', finalBody.slice(0, 200))
|
|
117
|
+
|
|
118
|
+
// Use final URL path for auth check, not original path (login flow redirects to main page)
|
|
119
|
+
const finalPath = new URL(finalUrl).pathname
|
|
120
|
+
const errorInfo = this.extractErrorFromResponse(finalBody, null, finalPath)
|
|
121
|
+
if (errorInfo) {
|
|
122
|
+
this.log('POST', path, '-> Error detected:', errorInfo)
|
|
123
|
+
if (errorInfo === '__AUTH_ERROR__') {
|
|
124
|
+
throw new AuthenticationError()
|
|
125
|
+
}
|
|
126
|
+
throw new Error(errorInfo)
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
if (finalPath.includes('insertForm') || finalPath.includes('error') || finalPath.includes('fail')) {
|
|
130
|
+
this.log('POST', path, '-> Suspicious final URL:', finalUrl)
|
|
131
|
+
throw new Error('멘토링 등록에 실패했습니다.')
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
return finalBody
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
private extractErrorFromResponse(body: string, location: string | null, path?: string): string | null {
|
|
138
|
+
this.log(
|
|
139
|
+
'extractErrorFromResponse',
|
|
140
|
+
path,
|
|
141
|
+
'body length:',
|
|
142
|
+
body.length,
|
|
143
|
+
'title:',
|
|
144
|
+
body.match(/<title>([^<]*)<\/title>/)?.[1],
|
|
145
|
+
)
|
|
146
|
+
|
|
147
|
+
const alertMatch = body.match(/<script>\s*alert\(['"](.+?)['"]\)\s*<\/script>/)
|
|
148
|
+
if (alertMatch) {
|
|
149
|
+
this.log('Found alert match:', alertMatch[1])
|
|
150
|
+
return alertMatch[1]
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
const titleMatch = body.match(/<title>([^<]*)<\/title>/i)
|
|
154
|
+
const pageTitle = titleMatch?.[1] ?? ''
|
|
155
|
+
|
|
156
|
+
// Check for login page - server returns login page HTML (with login form) when session is invalid
|
|
157
|
+
// The login page has both the SW마에스트로 title AND a login form with username/password inputs
|
|
158
|
+
// Skip this check during login flow (forLogin = GET for CSRF, toLogin = POST credentials)
|
|
159
|
+
const isLoginPath = path?.includes('/member/user/forLogin') || path?.includes('/member/user/toLogin')
|
|
160
|
+
const hasUsername = body.includes('name="username"')
|
|
161
|
+
const hasPassword = body.includes('name="password"')
|
|
162
|
+
if (
|
|
163
|
+
!isLoginPath &&
|
|
164
|
+
hasUsername &&
|
|
165
|
+
hasPassword &&
|
|
166
|
+
(pageTitle.includes('AI·SW마에스트로') || pageTitle.includes('SW마에스트로'))
|
|
167
|
+
) {
|
|
168
|
+
return '__AUTH_ERROR__'
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
const errorTitleMatch = body.match(/<title>(에러안내|오류|Error)[^<]*<\/title>/i)
|
|
172
|
+
if (errorTitleMatch) {
|
|
173
|
+
this.log('Found error title match')
|
|
174
|
+
const msgVarMatch = body.match(/var\s+msg\s*=\s*['"](.+?)['"];/)
|
|
175
|
+
if (msgVarMatch) {
|
|
176
|
+
this.log('Found msg var:', msgVarMatch[1].slice(0, 100))
|
|
177
|
+
return msgVarMatch[1]
|
|
178
|
+
}
|
|
179
|
+
const errorPatterns = [
|
|
180
|
+
'등록에 실패',
|
|
181
|
+
'저장에 실패',
|
|
182
|
+
'오류가 발생',
|
|
183
|
+
'실패하였습니다',
|
|
184
|
+
'잘못된 접근',
|
|
185
|
+
'권한이 없습니다',
|
|
186
|
+
'SQLException',
|
|
187
|
+
'Error updating database',
|
|
188
|
+
]
|
|
189
|
+
for (const pattern of errorPatterns) {
|
|
190
|
+
if (body.includes(pattern)) {
|
|
191
|
+
this.log('Found error pattern:', pattern)
|
|
192
|
+
return '멘토링 등록에 실패했습니다: ' + pattern
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
return '에러가 발생했습니다'
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
return null
|
|
60
199
|
}
|
|
61
200
|
|
|
62
201
|
async postJson<T>(path: string, body: Record<string, string>): Promise<T> {
|
|
@@ -78,13 +217,30 @@ export class SomaHttp {
|
|
|
78
217
|
async login(username: string, password: string): Promise<void> {
|
|
79
218
|
const csrfToken = await this.extractCsrfToken()
|
|
80
219
|
|
|
81
|
-
await this.post('/member/user/toLogin.do', {
|
|
220
|
+
const html = await this.post('/member/user/toLogin.do', {
|
|
82
221
|
username,
|
|
83
222
|
password,
|
|
84
223
|
csrfToken,
|
|
85
224
|
loginFlag: '',
|
|
86
225
|
menuNo: MENU_NO.LOGIN,
|
|
87
226
|
})
|
|
227
|
+
|
|
228
|
+
// SWMaestro returns an intermediate form that auto-submits via JS:
|
|
229
|
+
// <form action="/sw/login.do"><input name="password" value="bcrypt_hash"/>...
|
|
230
|
+
// We need to parse and submit this form to complete authentication.
|
|
231
|
+
const actionMatch = html.match(/action=["']([^"']+)["']/)
|
|
232
|
+
const fields: Record<string, string> = {}
|
|
233
|
+
|
|
234
|
+
// Match name="..." or name='...' followed by value="..." or value='...'
|
|
235
|
+
// Uses backreferences (\1 and \3) to match the same quote type for closing
|
|
236
|
+
for (const match of html.matchAll(/name=(['"])([^'"]+)\1\s+value=(['"])(.*?)\3/g)) {
|
|
237
|
+
fields[match[2]] = match[4]
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
if (actionMatch?.[1] && Object.keys(fields).length > 0) {
|
|
241
|
+
const action = actionMatch[1].replace(/^\/sw/, '')
|
|
242
|
+
await this.post(action, fields)
|
|
243
|
+
}
|
|
88
244
|
}
|
|
89
245
|
|
|
90
246
|
async checkLogin(): Promise<UserIdentity | null> {
|
|
@@ -141,7 +297,11 @@ export class SomaHttp {
|
|
|
141
297
|
|
|
142
298
|
private buildHeaders(): Record<string, string> {
|
|
143
299
|
const cookieHeader = this.serializeCookies()
|
|
144
|
-
return
|
|
300
|
+
return {
|
|
301
|
+
'Accept-Language': DEFAULT_ACCEPT_LANGUAGE,
|
|
302
|
+
'User-Agent': DEFAULT_USER_AGENT,
|
|
303
|
+
...(cookieHeader ? { cookie: cookieHeader } : {}),
|
|
304
|
+
}
|
|
145
305
|
}
|
|
146
306
|
|
|
147
307
|
private buildBody(body: Record<string, string>): Record<string, string> {
|
|
@@ -193,4 +353,10 @@ export class SomaHttp {
|
|
|
193
353
|
private serializeCookies(): string {
|
|
194
354
|
return [...this.cookies.entries()].map(([name, value]) => `${name}=${value}`).join('; ')
|
|
195
355
|
}
|
|
356
|
+
|
|
357
|
+
private log(...args: unknown[]): void {
|
|
358
|
+
if (this.verbose) {
|
|
359
|
+
console.log('[opensoma]', ...args)
|
|
360
|
+
}
|
|
361
|
+
}
|
|
196
362
|
}
|
package/src/index.ts
CHANGED
|
@@ -1,7 +1,6 @@
|
|
|
1
1
|
import { describe, expect, test } from 'bun:test'
|
|
2
2
|
|
|
3
|
-
import { MENU_NO, REPORT_CD } from '
|
|
4
|
-
|
|
3
|
+
import { MENU_NO, REPORT_CD } from '../../constants'
|
|
5
4
|
import { buildMentoringListParams, parseSearchQuery } from './mentoring-params'
|
|
6
5
|
|
|
7
6
|
describe('parseSearchQuery', () => {
|
|
@@ -40,7 +39,10 @@ describe('buildMentoringListParams', () => {
|
|
|
40
39
|
})
|
|
41
40
|
|
|
42
41
|
test('status maps open to A, closed to C', () => {
|
|
43
|
-
expect(buildMentoringListParams({ status: 'open' })).toEqual({
|
|
42
|
+
expect(buildMentoringListParams({ status: 'open' })).toEqual({
|
|
43
|
+
menuNo: MENU_NO.MENTORING,
|
|
44
|
+
searchStatMentolec: 'A',
|
|
45
|
+
})
|
|
44
46
|
expect(buildMentoringListParams({ status: 'closed' })).toEqual({
|
|
45
47
|
menuNo: MENU_NO.MENTORING,
|
|
46
48
|
searchStatMentolec: 'C',
|
|
@@ -107,6 +109,9 @@ describe('buildMentoringListParams', () => {
|
|
|
107
109
|
})
|
|
108
110
|
|
|
109
111
|
test('page sets pageIndex', () => {
|
|
110
|
-
expect(buildMentoringListParams({ page: 3 })).toEqual({
|
|
112
|
+
expect(buildMentoringListParams({ page: 3 })).toEqual({
|
|
113
|
+
menuNo: MENU_NO.MENTORING,
|
|
114
|
+
pageIndex: '3',
|
|
115
|
+
})
|
|
111
116
|
})
|
|
112
117
|
})
|
|
@@ -1,8 +1,11 @@
|
|
|
1
|
-
import { MENU_NO, REPORT_CD } from '
|
|
2
|
-
import type { UserIdentity } from '
|
|
1
|
+
import { MENU_NO, REPORT_CD } from '../../constants'
|
|
2
|
+
import type { UserIdentity } from '../../http'
|
|
3
3
|
|
|
4
4
|
const STATUS_MAP: Record<string, string> = { open: 'A', closed: 'C' }
|
|
5
|
-
const TYPE_MAP: Record<string, string> = {
|
|
5
|
+
const TYPE_MAP: Record<string, string> = {
|
|
6
|
+
public: REPORT_CD.PUBLIC_MENTORING,
|
|
7
|
+
lecture: REPORT_CD.MENTOR_LECTURE,
|
|
8
|
+
}
|
|
6
9
|
const SEARCH_FIELD_MAP: Record<string, string> = { title: '1', author: '2', content: '3' }
|
|
7
10
|
|
|
8
11
|
export interface MentoringSearchQuery {
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { parse } from 'node-html-parser'
|
|
2
2
|
|
|
3
|
-
import { MENU_NO, REPORT_CD, ROOM_IDS, TIME_SLOTS } from '
|
|
4
|
-
import { ApplicationHistoryItemSchema, type ApplicationHistoryItem } from '
|
|
3
|
+
import { MENU_NO, REPORT_CD, ROOM_IDS, TIME_SLOTS } from '../../constants'
|
|
4
|
+
import { ApplicationHistoryItemSchema, type ApplicationHistoryItem } from '../../types'
|
|
5
5
|
|
|
6
6
|
export function toReportCd(type: 'public' | 'lecture'): string {
|
|
7
7
|
return type === 'lecture' ? REPORT_CD.MENTOR_LECTURE : REPORT_CD.PUBLIC_MENTORING
|
|
@@ -1,10 +1,11 @@
|
|
|
1
|
+
import { Database } from 'bun:sqlite'
|
|
1
2
|
import { afterEach, describe, expect, test } from 'bun:test'
|
|
3
|
+
import { execSync } from 'node:child_process'
|
|
2
4
|
import { createCipheriv, pbkdf2Sync } from 'node:crypto'
|
|
3
5
|
import { mkdirSync, rmSync, writeFileSync } from 'node:fs'
|
|
4
6
|
import { mkdtemp } from 'node:fs/promises'
|
|
5
7
|
import { tmpdir } from 'node:os'
|
|
6
8
|
import { dirname, join } from 'node:path'
|
|
7
|
-
import { Database } from 'bun:sqlite'
|
|
8
9
|
|
|
9
10
|
import { BROWSERS, TokenExtractor } from './token-extractor'
|
|
10
11
|
|
|
@@ -33,9 +34,7 @@ describe('TokenExtractor', () => {
|
|
|
33
34
|
|
|
34
35
|
expect(paths).toHaveLength(BROWSERS.length)
|
|
35
36
|
for (const browser of BROWSERS) {
|
|
36
|
-
expect(paths).toContainEqual(
|
|
37
|
-
join(home, 'Library', 'Application Support', browser.macPath, 'Default', 'Cookies'),
|
|
38
|
-
)
|
|
37
|
+
expect(paths).toContainEqual(join(home, 'Library', 'Application Support', browser.macPath, 'Default', 'Cookies'))
|
|
39
38
|
}
|
|
40
39
|
})
|
|
41
40
|
|
|
@@ -66,6 +65,45 @@ describe('TokenExtractor', () => {
|
|
|
66
65
|
expect(await extractor.extract()).toEqual({ sessionCookie: 'my-session-id' })
|
|
67
66
|
})
|
|
68
67
|
|
|
68
|
+
test('finds cookie databases across numbered browser profiles', async () => {
|
|
69
|
+
const home = await makeTempDir()
|
|
70
|
+
createCookieFile(join(home, '.config', 'google-chrome', 'Default', 'Cookies'))
|
|
71
|
+
createCookieFile(join(home, '.config', 'google-chrome', 'Profile 1', 'Cookies'))
|
|
72
|
+
createCookieFile(join(home, '.config', 'google-chrome', 'Profile 2', 'Cookies'))
|
|
73
|
+
|
|
74
|
+
const extractor = new TokenExtractor('linux', home)
|
|
75
|
+
|
|
76
|
+
expect(extractor.findCookieDatabases()).toEqual([
|
|
77
|
+
join(home, '.config', 'google-chrome', 'Default', 'Cookies'),
|
|
78
|
+
join(home, '.config', 'google-chrome', 'Profile 1', 'Cookies'),
|
|
79
|
+
join(home, '.config', 'google-chrome', 'Profile 2', 'Cookies'),
|
|
80
|
+
])
|
|
81
|
+
})
|
|
82
|
+
|
|
83
|
+
test('extractCandidates keeps the newest unique cookie across profiles', async () => {
|
|
84
|
+
const home = await makeTempDir()
|
|
85
|
+
createCookieDbWithPlaintext(join(home, '.config', 'google-chrome', 'Default', 'Cookies'), 'stale-session', 10)
|
|
86
|
+
createCookieDbWithPlaintext(join(home, '.config', 'google-chrome', 'Profile 1', 'Cookies'), 'valid-session', 20)
|
|
87
|
+
createCookieDbWithPlaintext(join(home, '.config', 'google-chrome', 'Profile 2', 'Cookies'), 'stale-session', 30)
|
|
88
|
+
|
|
89
|
+
const extractor = new TokenExtractor('linux', home)
|
|
90
|
+
|
|
91
|
+
await expect(extractor.extractCandidates()).resolves.toEqual([
|
|
92
|
+
{
|
|
93
|
+
browser: 'Chrome',
|
|
94
|
+
lastAccessUtc: 30,
|
|
95
|
+
profile: 'Profile 2',
|
|
96
|
+
sessionCookie: 'stale-session',
|
|
97
|
+
},
|
|
98
|
+
{
|
|
99
|
+
browser: 'Chrome',
|
|
100
|
+
lastAccessUtc: 20,
|
|
101
|
+
profile: 'Profile 1',
|
|
102
|
+
sessionCookie: 'valid-session',
|
|
103
|
+
},
|
|
104
|
+
])
|
|
105
|
+
})
|
|
106
|
+
|
|
69
107
|
test('decrypts encrypted cookie on Linux', async () => {
|
|
70
108
|
const home = await makeTempDir()
|
|
71
109
|
const dbPath = join(home, '.config', 'google-chrome', 'Default', 'Cookies')
|
|
@@ -75,6 +113,28 @@ describe('TokenExtractor', () => {
|
|
|
75
113
|
const extractor = new TokenExtractor('linux', home)
|
|
76
114
|
expect(await extractor.extract()).toEqual({ sessionCookie: 'decrypted-session' })
|
|
77
115
|
})
|
|
116
|
+
|
|
117
|
+
test('extracts plaintext cookie under Node.js (compiled ESM)', async () => {
|
|
118
|
+
execSync('bun run build', { cwd: join(import.meta.dir, '..'), stdio: 'pipe' })
|
|
119
|
+
const home = await makeTempDir()
|
|
120
|
+
const dbPath = join(home, '.config', 'google-chrome', 'Default', 'Cookies')
|
|
121
|
+
createCookieDbWithPlaintext(dbPath, 'node-test-session')
|
|
122
|
+
|
|
123
|
+
const result = runNodeExtractor(home)
|
|
124
|
+
|
|
125
|
+
expect(JSON.parse(result.trim())).toEqual({ sessionCookie: 'node-test-session' })
|
|
126
|
+
})
|
|
127
|
+
|
|
128
|
+
test('extracts encrypted cookie under Node.js (compiled ESM)', async () => {
|
|
129
|
+
execSync('bun run build', { cwd: join(import.meta.dir, '..'), stdio: 'pipe' })
|
|
130
|
+
const home = await makeTempDir()
|
|
131
|
+
const dbPath = join(home, '.config', 'google-chrome', 'Default', 'Cookies')
|
|
132
|
+
createCookieDbWithEncrypted(dbPath, encryptLinuxCookie('node-encrypted-session'))
|
|
133
|
+
|
|
134
|
+
const result = runNodeExtractor(home)
|
|
135
|
+
|
|
136
|
+
expect(JSON.parse(result.trim())).toEqual({ sessionCookie: 'node-encrypted-session' })
|
|
137
|
+
})
|
|
78
138
|
})
|
|
79
139
|
|
|
80
140
|
async function makeTempDir(): Promise<string> {
|
|
@@ -88,15 +148,16 @@ function createCookieFile(filePath: string): void {
|
|
|
88
148
|
writeFileSync(filePath, '')
|
|
89
149
|
}
|
|
90
150
|
|
|
91
|
-
function createCookieDbWithPlaintext(filePath: string, value: string): void {
|
|
151
|
+
function createCookieDbWithPlaintext(filePath: string, value: string, lastAccessUtc = 0): void {
|
|
92
152
|
mkdirSync(dirname(filePath), { recursive: true })
|
|
93
153
|
const db = new Database(filePath)
|
|
94
154
|
db.run(
|
|
95
155
|
'CREATE TABLE cookies (host_key TEXT, name TEXT, value TEXT, encrypted_value BLOB, creation_utc INTEGER, expires_utc INTEGER, is_httponly INTEGER, has_expires INTEGER, is_persistent INTEGER, priority INTEGER, samesite INTEGER, source_scheme INTEGER, is_secure INTEGER, path TEXT, last_access_utc INTEGER, last_update_utc INTEGER, source_port INTEGER, source_type INTEGER)',
|
|
96
156
|
)
|
|
97
|
-
db.run(
|
|
98
|
-
value,
|
|
99
|
-
|
|
157
|
+
db.run(
|
|
158
|
+
"INSERT INTO cookies (host_key, name, value, encrypted_value, last_access_utc) VALUES ('swmaestro.ai', 'JSESSIONID', ?, '', ?)",
|
|
159
|
+
[value, lastAccessUtc],
|
|
160
|
+
)
|
|
100
161
|
db.close()
|
|
101
162
|
}
|
|
102
163
|
|
|
@@ -112,6 +173,21 @@ function createCookieDbWithEncrypted(filePath: string, encrypted: Buffer): void
|
|
|
112
173
|
db.close()
|
|
113
174
|
}
|
|
114
175
|
|
|
176
|
+
function runNodeExtractor(home: string): string {
|
|
177
|
+
const projectRoot = join(import.meta.dir, '..')
|
|
178
|
+
const scriptPath = join(home, 'test-node-extract.mjs')
|
|
179
|
+
writeFileSync(
|
|
180
|
+
scriptPath,
|
|
181
|
+
[
|
|
182
|
+
`import { TokenExtractor } from ${JSON.stringify(join(projectRoot, 'dist', 'src', 'token-extractor.js'))};`,
|
|
183
|
+
`const ext = new TokenExtractor('linux', ${JSON.stringify(home)});`,
|
|
184
|
+
`const result = await ext.extract();`,
|
|
185
|
+
`console.log(JSON.stringify(result));`,
|
|
186
|
+
].join('\n'),
|
|
187
|
+
)
|
|
188
|
+
return execSync(`node ${scriptPath}`, { encoding: 'utf-8', timeout: 15_000 })
|
|
189
|
+
}
|
|
190
|
+
|
|
115
191
|
function encryptLinuxCookie(value: string): Buffer {
|
|
116
192
|
const key = pbkdf2Sync('peanuts', CHROMIUM_SALT, 1, 16, 'sha1')
|
|
117
193
|
const cipher = createCipheriv('aes-128-cbc', key, CHROMIUM_IV)
|
package/src/token-extractor.ts
CHANGED
|
@@ -1,13 +1,17 @@
|
|
|
1
1
|
import { execSync } from 'node:child_process'
|
|
2
2
|
import { createDecipheriv, pbkdf2Sync } from 'node:crypto'
|
|
3
|
-
import { copyFileSync, existsSync, mkdtempSync, rmSync } from 'node:fs'
|
|
3
|
+
import { copyFileSync, existsSync, mkdtempSync, readdirSync, rmSync } from 'node:fs'
|
|
4
|
+
import { createRequire } from 'node:module'
|
|
4
5
|
import { homedir, tmpdir } from 'node:os'
|
|
5
|
-
import { join } from 'node:path'
|
|
6
|
+
import { basename, dirname, join } from 'node:path'
|
|
7
|
+
|
|
8
|
+
const require = createRequire(import.meta.url)
|
|
6
9
|
|
|
7
10
|
const COOKIE_QUERY =
|
|
8
|
-
"SELECT encrypted_value, value FROM cookies WHERE host_key LIKE '%swmaestro.ai' AND name = 'JSESSIONID' ORDER BY last_access_utc DESC LIMIT 1"
|
|
11
|
+
"SELECT encrypted_value, last_access_utc, value FROM cookies WHERE host_key LIKE '%swmaestro.ai' AND name = 'JSESSIONID' ORDER BY last_access_utc DESC LIMIT 1"
|
|
9
12
|
const CHROMIUM_SALT = 'saltysalt'
|
|
10
13
|
const CHROMIUM_IV = Buffer.alloc(16, 0x20)
|
|
14
|
+
const PROFILE_DIR_PATTERN = /^Profile\s+\d+$/
|
|
11
15
|
|
|
12
16
|
type BrowserConfig = {
|
|
13
17
|
name: string
|
|
@@ -19,9 +23,17 @@ type BrowserConfig = {
|
|
|
19
23
|
|
|
20
24
|
type CookieRow = {
|
|
21
25
|
encrypted_value: Uint8Array | Buffer | null
|
|
26
|
+
last_access_utc?: number | bigint | null
|
|
22
27
|
value: string | null
|
|
23
28
|
}
|
|
24
29
|
|
|
30
|
+
export interface ExtractedSessionCandidate {
|
|
31
|
+
browser: string
|
|
32
|
+
lastAccessUtc: number
|
|
33
|
+
profile: string
|
|
34
|
+
sessionCookie: string
|
|
35
|
+
}
|
|
36
|
+
|
|
25
37
|
export const BROWSERS: BrowserConfig[] = [
|
|
26
38
|
{
|
|
27
39
|
name: 'Chrome',
|
|
@@ -79,12 +91,36 @@ function queryCookieDb(dbPath: string): CookieRow | undefined {
|
|
|
79
91
|
}
|
|
80
92
|
}
|
|
81
93
|
|
|
82
|
-
const Database = require('better-sqlite3')
|
|
83
|
-
const db = new Database(dbPath, { readonly: true })
|
|
84
94
|
try {
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
95
|
+
const { DatabaseSync } = require('node:sqlite') as {
|
|
96
|
+
DatabaseSync: new (
|
|
97
|
+
path: string,
|
|
98
|
+
options?: { readonly?: boolean },
|
|
99
|
+
) => {
|
|
100
|
+
close: () => void
|
|
101
|
+
prepare: (query: string) => { get: () => CookieRow | undefined }
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
const db = new DatabaseSync(dbPath, { readonly: true })
|
|
105
|
+
try {
|
|
106
|
+
return db.prepare(COOKIE_QUERY).get() ?? undefined
|
|
107
|
+
} finally {
|
|
108
|
+
db.close()
|
|
109
|
+
}
|
|
110
|
+
} catch {
|
|
111
|
+
const Database = require('better-sqlite3') as new (
|
|
112
|
+
path: string,
|
|
113
|
+
options?: { readonly?: boolean },
|
|
114
|
+
) => {
|
|
115
|
+
close: () => void
|
|
116
|
+
prepare: (query: string) => { get: () => CookieRow | undefined }
|
|
117
|
+
}
|
|
118
|
+
const db = new Database(dbPath, { readonly: true })
|
|
119
|
+
try {
|
|
120
|
+
return db.prepare(COOKIE_QUERY).get() ?? undefined
|
|
121
|
+
} finally {
|
|
122
|
+
db.close()
|
|
123
|
+
}
|
|
88
124
|
}
|
|
89
125
|
}
|
|
90
126
|
|
|
@@ -95,12 +131,27 @@ export class TokenExtractor {
|
|
|
95
131
|
) {}
|
|
96
132
|
|
|
97
133
|
async extract(): Promise<{ sessionCookie: string } | null> {
|
|
134
|
+
const candidates = await this.extractCandidates()
|
|
135
|
+
const firstCandidate = candidates[0]
|
|
136
|
+
|
|
137
|
+
if (!firstCandidate) {
|
|
138
|
+
return null
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
return { sessionCookie: firstCandidate.sessionCookie }
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
async extractCandidates(): Promise<ExtractedSessionCandidate[]> {
|
|
145
|
+
const candidates = new Map<string, ExtractedSessionCandidate>()
|
|
146
|
+
|
|
98
147
|
for (const databasePath of this.findCookieDatabases()) {
|
|
99
148
|
const browser = this.getBrowserByPath(databasePath)
|
|
100
149
|
if (!browser) {
|
|
101
150
|
continue
|
|
102
151
|
}
|
|
103
152
|
|
|
153
|
+
const profile = basename(dirname(databasePath))
|
|
154
|
+
|
|
104
155
|
const tempDirectory = mkdtempSync(join(tmpdir(), 'opensoma-cookie-db-'))
|
|
105
156
|
const tempDatabasePath = join(tempDirectory, 'Cookies')
|
|
106
157
|
|
|
@@ -114,7 +165,13 @@ export class TokenExtractor {
|
|
|
114
165
|
|
|
115
166
|
const plaintextValue = typeof row.value === 'string' ? row.value.trim() : ''
|
|
116
167
|
if (plaintextValue) {
|
|
117
|
-
|
|
168
|
+
this.addCandidate(candidates, {
|
|
169
|
+
browser: browser.name,
|
|
170
|
+
lastAccessUtc: this.normalizeLastAccessUtc(row.last_access_utc),
|
|
171
|
+
profile,
|
|
172
|
+
sessionCookie: plaintextValue,
|
|
173
|
+
})
|
|
174
|
+
continue
|
|
118
175
|
}
|
|
119
176
|
|
|
120
177
|
if (!row.encrypted_value || row.encrypted_value.length === 0) {
|
|
@@ -123,29 +180,24 @@ export class TokenExtractor {
|
|
|
123
180
|
|
|
124
181
|
const decryptedValue = await this.decryptCookie(Buffer.from(row.encrypted_value), browser.name)
|
|
125
182
|
if (decryptedValue) {
|
|
126
|
-
|
|
183
|
+
this.addCandidate(candidates, {
|
|
184
|
+
browser: browser.name,
|
|
185
|
+
lastAccessUtc: this.normalizeLastAccessUtc(row.last_access_utc),
|
|
186
|
+
profile,
|
|
187
|
+
sessionCookie: decryptedValue,
|
|
188
|
+
})
|
|
127
189
|
}
|
|
128
190
|
} catch {
|
|
129
|
-
continue
|
|
130
191
|
} finally {
|
|
131
192
|
rmSync(tempDirectory, { recursive: true, force: true })
|
|
132
193
|
}
|
|
133
194
|
}
|
|
134
195
|
|
|
135
|
-
return
|
|
196
|
+
return [...candidates.values()].sort((left, right) => right.lastAccessUtc - left.lastAccessUtc)
|
|
136
197
|
}
|
|
137
198
|
|
|
138
199
|
findCookieDatabases(): string[] {
|
|
139
|
-
|
|
140
|
-
this.platform === 'darwin'
|
|
141
|
-
? BROWSERS.map((browser) =>
|
|
142
|
-
join(this.homeDirectory, 'Library', 'Application Support', browser.macPath, 'Default', 'Cookies'),
|
|
143
|
-
)
|
|
144
|
-
: this.platform === 'linux'
|
|
145
|
-
? BROWSERS.map((browser) => join(this.homeDirectory, '.config', browser.linuxPath, 'Default', 'Cookies'))
|
|
146
|
-
: []
|
|
147
|
-
|
|
148
|
-
return browserPaths.filter((databasePath) => existsSync(databasePath))
|
|
200
|
+
return BROWSERS.flatMap((browser) => this.findBrowserCookieDatabases(browser))
|
|
149
201
|
}
|
|
150
202
|
|
|
151
203
|
private async decryptCookie(encryptedValue: Buffer, browserName: string): Promise<string> {
|
|
@@ -202,4 +254,48 @@ export class TokenExtractor {
|
|
|
202
254
|
(browser) => databasePath.includes(`${browser.macPath}/`) || databasePath.includes(`${browser.linuxPath}/`),
|
|
203
255
|
)
|
|
204
256
|
}
|
|
257
|
+
|
|
258
|
+
private addCandidate(candidates: Map<string, ExtractedSessionCandidate>, candidate: ExtractedSessionCandidate): void {
|
|
259
|
+
const existing = candidates.get(candidate.sessionCookie)
|
|
260
|
+
if (!existing || existing.lastAccessUtc < candidate.lastAccessUtc) {
|
|
261
|
+
candidates.set(candidate.sessionCookie, candidate)
|
|
262
|
+
}
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
private findBrowserCookieDatabases(browser: BrowserConfig): string[] {
|
|
266
|
+
const browserRoot = this.getBrowserRoot(browser)
|
|
267
|
+
if (!browserRoot || !existsSync(browserRoot)) {
|
|
268
|
+
return []
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
return readdirSync(browserRoot, { withFileTypes: true })
|
|
272
|
+
.filter((entry) => entry.isDirectory() && this.isSupportedProfileDirectory(entry.name))
|
|
273
|
+
.sort((left, right) => left.name.localeCompare(right.name))
|
|
274
|
+
.map((entry) => join(browserRoot, entry.name, 'Cookies'))
|
|
275
|
+
.filter((databasePath) => existsSync(databasePath))
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
private getBrowserRoot(browser: BrowserConfig): string | null {
|
|
279
|
+
if (this.platform === 'darwin') {
|
|
280
|
+
return join(this.homeDirectory, 'Library', 'Application Support', browser.macPath)
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
if (this.platform === 'linux') {
|
|
284
|
+
return join(this.homeDirectory, '.config', browser.linuxPath)
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
return null
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
private isSupportedProfileDirectory(profileName: string): boolean {
|
|
291
|
+
return profileName === 'Default' || PROFILE_DIR_PATTERN.test(profileName)
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
private normalizeLastAccessUtc(lastAccessUtc: number | bigint | null | undefined): number {
|
|
295
|
+
if (typeof lastAccessUtc === 'bigint') {
|
|
296
|
+
return Number(lastAccessUtc)
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
return typeof lastAccessUtc === 'number' ? lastAccessUtc : 0
|
|
300
|
+
}
|
|
205
301
|
}
|
package/src/types.test.ts
CHANGED
|
@@ -13,7 +13,7 @@ import {
|
|
|
13
13
|
PaginationSchema,
|
|
14
14
|
RoomCardSchema,
|
|
15
15
|
TeamInfoSchema,
|
|
16
|
-
} from '
|
|
16
|
+
} from './types'
|
|
17
17
|
|
|
18
18
|
describe('schemas', () => {
|
|
19
19
|
test('accept valid values', () => {
|
|
@@ -161,7 +161,9 @@ describe('schemas', () => {
|
|
|
161
161
|
expect(() => RoomCardSchema.parse({ itemId: '17' })).toThrow()
|
|
162
162
|
expect(() => DashboardSchema.parse({ name: '전수열' })).toThrow()
|
|
163
163
|
expect(() => NoticeListItemSchema.parse({ id: 1, title: '공지' })).toThrow()
|
|
164
|
-
expect(() =>
|
|
164
|
+
expect(() =>
|
|
165
|
+
NoticeDetailSchema.parse({ id: 1, title: '공지', author: '관리자', createdAt: '2026-04-01' }),
|
|
166
|
+
).toThrow()
|
|
165
167
|
expect(() => TeamInfoSchema.parse({ teams: [{ name: '김개발' }], currentTeams: 1, maxTeams: 100 })).toThrow()
|
|
166
168
|
expect(() => MemberInfoSchema.parse({ email: 1, name: '전수열' })).toThrow()
|
|
167
169
|
expect(() => EventListItemSchema.parse({ id: 1, title: '행사', status: '접수중' })).toThrow()
|