opensoma 0.4.0 → 0.5.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 +1 -1
- package/dist/src/commands/auth.d.ts +1 -1
- package/dist/src/commands/auth.d.ts.map +1 -1
- package/dist/src/commands/auth.js +94 -52
- package/dist/src/commands/auth.js.map +1 -1
- package/dist/src/constants.d.ts +40 -0
- package/dist/src/constants.d.ts.map +1 -1
- package/dist/src/constants.js +42 -0
- package/dist/src/constants.js.map +1 -1
- package/dist/src/formatters.d.ts.map +1 -1
- package/dist/src/formatters.js +42 -16
- package/dist/src/formatters.js.map +1 -1
- package/dist/src/index.d.ts +3 -0
- package/dist/src/index.d.ts.map +1 -1
- package/dist/src/index.js +2 -0
- package/dist/src/index.js.map +1 -1
- package/dist/src/shared/utils/toz.d.ts +23 -0
- package/dist/src/shared/utils/toz.d.ts.map +1 -0
- package/dist/src/shared/utils/toz.js +72 -0
- package/dist/src/shared/utils/toz.js.map +1 -0
- package/dist/src/token-extractor.d.ts +9 -1
- package/dist/src/token-extractor.d.ts.map +1 -1
- package/dist/src/token-extractor.js +54 -10
- package/dist/src/token-extractor.js.map +1 -1
- package/dist/src/toz-formatters.d.ts +9 -0
- package/dist/src/toz-formatters.d.ts.map +1 -0
- package/dist/src/toz-formatters.js +151 -0
- package/dist/src/toz-formatters.js.map +1 -0
- package/dist/src/toz-http.d.ts +27 -0
- package/dist/src/toz-http.d.ts.map +1 -0
- package/dist/src/toz-http.js +154 -0
- package/dist/src/toz-http.js.map +1 -0
- package/dist/src/types.d.ts +52 -0
- package/dist/src/types.d.ts.map +1 -1
- package/dist/src/types.js +46 -0
- package/dist/src/types.js.map +1 -1
- package/package.json +1 -1
- package/src/__fixtures__/toz/toz_all_branches.json +211 -0
- package/src/__fixtures__/toz/toz_booking.html +2190 -0
- package/src/__fixtures__/toz/toz_boothes.json +59 -0
- package/src/__fixtures__/toz/toz_duration.json +25 -0
- package/src/__fixtures__/toz/toz_mypage_response.html +388 -0
- package/src/__fixtures__/toz/toz_page.html +211 -0
- package/src/commands/auth.ts +107 -50
- package/src/constants.ts +50 -0
- package/src/formatters.ts +44 -16
- package/src/index.ts +3 -0
- package/src/shared/utils/toz.test.ts +133 -0
- package/src/shared/utils/toz.ts +100 -0
- package/src/token-extractor.test.ts +30 -5
- package/src/token-extractor.ts +65 -13
- package/src/toz-formatters.test.ts +197 -0
- package/src/toz-formatters.ts +211 -0
- package/src/toz-http.test.ts +157 -0
- package/src/toz-http.ts +188 -0
- package/src/types.ts +58 -0
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
import { describe, expect, it } from 'bun:test'
|
|
2
|
+
|
|
3
|
+
import {
|
|
4
|
+
assertDurationInRange,
|
|
5
|
+
buildBranchIdsParam,
|
|
6
|
+
buildStartTimeParam,
|
|
7
|
+
formatDuration,
|
|
8
|
+
formatPhone,
|
|
9
|
+
formatStartTime,
|
|
10
|
+
parseDurationKey,
|
|
11
|
+
parseEmail,
|
|
12
|
+
parsePhone,
|
|
13
|
+
} from './toz'
|
|
14
|
+
|
|
15
|
+
describe('formatDuration', () => {
|
|
16
|
+
it('formats hours+minutes as HHMM', () => {
|
|
17
|
+
expect(formatDuration(120)).toBe('0200')
|
|
18
|
+
expect(formatDuration(150)).toBe('0230')
|
|
19
|
+
expect(formatDuration(180)).toBe('0300')
|
|
20
|
+
expect(formatDuration(90)).toBe('0130')
|
|
21
|
+
})
|
|
22
|
+
|
|
23
|
+
it('throws on invalid input', () => {
|
|
24
|
+
expect(() => formatDuration(0)).toThrow()
|
|
25
|
+
expect(() => formatDuration(-30)).toThrow()
|
|
26
|
+
expect(() => formatDuration(1.5)).toThrow()
|
|
27
|
+
})
|
|
28
|
+
})
|
|
29
|
+
|
|
30
|
+
describe('parseDurationKey', () => {
|
|
31
|
+
it('parses HHMM keys to minutes', () => {
|
|
32
|
+
expect(parseDurationKey('0200')).toBe(120)
|
|
33
|
+
expect(parseDurationKey('0230')).toBe(150)
|
|
34
|
+
expect(parseDurationKey('1300')).toBe(780)
|
|
35
|
+
})
|
|
36
|
+
|
|
37
|
+
it('throws on invalid key', () => {
|
|
38
|
+
expect(() => parseDurationKey('0')).toThrow()
|
|
39
|
+
expect(() => parseDurationKey('abcd')).toThrow()
|
|
40
|
+
expect(() => parseDurationKey('20000')).toThrow()
|
|
41
|
+
})
|
|
42
|
+
})
|
|
43
|
+
|
|
44
|
+
describe('formatStartTime / buildStartTimeParam', () => {
|
|
45
|
+
it('splits HH:MM', () => {
|
|
46
|
+
expect(formatStartTime('10:00')).toEqual({ hour: '10', minute: '00' })
|
|
47
|
+
expect(formatStartTime('9:30')).toEqual({ hour: '09', minute: '30' })
|
|
48
|
+
})
|
|
49
|
+
|
|
50
|
+
it('joins to 4-digit param', () => {
|
|
51
|
+
expect(buildStartTimeParam('10:00')).toBe('1000')
|
|
52
|
+
expect(buildStartTimeParam('9:05')).toBe('0905')
|
|
53
|
+
})
|
|
54
|
+
|
|
55
|
+
it('rejects invalid time', () => {
|
|
56
|
+
expect(() => formatStartTime('1000')).toThrow()
|
|
57
|
+
expect(() => formatStartTime('25:00')).not.toThrow()
|
|
58
|
+
})
|
|
59
|
+
})
|
|
60
|
+
|
|
61
|
+
describe('buildBranchIdsParam', () => {
|
|
62
|
+
it('joins with trailing comma', () => {
|
|
63
|
+
expect(buildBranchIdsParam([27])).toBe('27,')
|
|
64
|
+
expect(buildBranchIdsParam([27, 145])).toBe('27,145,')
|
|
65
|
+
expect(buildBranchIdsParam([27, 145, 19, 20, 15, 139, 134, 30, 149])).toBe('27,145,19,20,15,139,134,30,149,')
|
|
66
|
+
})
|
|
67
|
+
|
|
68
|
+
it('rejects empty list', () => {
|
|
69
|
+
expect(() => buildBranchIdsParam([])).toThrow()
|
|
70
|
+
})
|
|
71
|
+
})
|
|
72
|
+
|
|
73
|
+
describe('parsePhone', () => {
|
|
74
|
+
it('parses standard 010-XXXX-YYYY', () => {
|
|
75
|
+
expect(parsePhone('010-1234-5678')).toEqual({ phone1: '010', phone2: '1234', phone3: '5678' })
|
|
76
|
+
})
|
|
77
|
+
|
|
78
|
+
it('parses 010 with no dashes', () => {
|
|
79
|
+
expect(parsePhone('01012345678')).toEqual({ phone1: '010', phone2: '1234', phone3: '5678' })
|
|
80
|
+
})
|
|
81
|
+
|
|
82
|
+
it('parses old 011-XXX-YYYY', () => {
|
|
83
|
+
expect(parsePhone('011-123-4567')).toEqual({ phone1: '011', phone2: '123', phone3: '4567' })
|
|
84
|
+
})
|
|
85
|
+
|
|
86
|
+
it('rejects unsupported prefix', () => {
|
|
87
|
+
expect(() => parsePhone('012-1234-5678')).toThrow()
|
|
88
|
+
})
|
|
89
|
+
|
|
90
|
+
it('rejects invalid length', () => {
|
|
91
|
+
expect(() => parsePhone('010-12-34')).toThrow()
|
|
92
|
+
})
|
|
93
|
+
})
|
|
94
|
+
|
|
95
|
+
describe('formatPhone', () => {
|
|
96
|
+
it('joins to dashed format', () => {
|
|
97
|
+
expect(formatPhone({ phone1: '010', phone2: '1234', phone3: '5678' })).toBe('010-1234-5678')
|
|
98
|
+
})
|
|
99
|
+
})
|
|
100
|
+
|
|
101
|
+
describe('parseEmail', () => {
|
|
102
|
+
it('uses select option for known domain', () => {
|
|
103
|
+
expect(parseEmail('user@gmail.com')).toEqual({ email1: 'user', email2: 'gmail.com', email3: '' })
|
|
104
|
+
expect(parseEmail('me@naver.com')).toEqual({ email1: 'me', email2: 'naver.com', email3: '' })
|
|
105
|
+
})
|
|
106
|
+
|
|
107
|
+
it('uses 직접입력 for custom domain', () => {
|
|
108
|
+
expect(parseEmail('user@customdomain.io')).toEqual({
|
|
109
|
+
email1: 'user',
|
|
110
|
+
email2: '직접입력',
|
|
111
|
+
email3: 'customdomain.io',
|
|
112
|
+
})
|
|
113
|
+
})
|
|
114
|
+
|
|
115
|
+
it('rejects malformed email', () => {
|
|
116
|
+
expect(() => parseEmail('userdomain.com')).toThrow()
|
|
117
|
+
expect(() => parseEmail('@domain.com')).toThrow()
|
|
118
|
+
expect(() => parseEmail('user@')).toThrow()
|
|
119
|
+
})
|
|
120
|
+
})
|
|
121
|
+
|
|
122
|
+
describe('assertDurationInRange', () => {
|
|
123
|
+
it('accepts 120-180', () => {
|
|
124
|
+
expect(() => assertDurationInRange(120)).not.toThrow()
|
|
125
|
+
expect(() => assertDurationInRange(150)).not.toThrow()
|
|
126
|
+
expect(() => assertDurationInRange(180)).not.toThrow()
|
|
127
|
+
})
|
|
128
|
+
|
|
129
|
+
it('rejects out-of-range', () => {
|
|
130
|
+
expect(() => assertDurationInRange(60)).toThrow()
|
|
131
|
+
expect(() => assertDurationInRange(210)).toThrow()
|
|
132
|
+
})
|
|
133
|
+
})
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
import {
|
|
2
|
+
TOZ_EMAIL_DOMAIN_CUSTOM,
|
|
3
|
+
TOZ_EMAIL_DOMAINS,
|
|
4
|
+
TOZ_MAX_DURATION_MINUTES,
|
|
5
|
+
TOZ_MIN_DURATION_MINUTES,
|
|
6
|
+
TOZ_PHONE_PREFIXES,
|
|
7
|
+
} from '../../constants'
|
|
8
|
+
|
|
9
|
+
export interface ParsedPhone {
|
|
10
|
+
phone1: string
|
|
11
|
+
phone2: string
|
|
12
|
+
phone3: string
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export interface ParsedEmail {
|
|
16
|
+
email1: string
|
|
17
|
+
email2: string
|
|
18
|
+
email3: string
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export function formatDuration(minutes: number): string {
|
|
22
|
+
if (!Number.isInteger(minutes) || minutes <= 0) {
|
|
23
|
+
throw new Error(`Invalid duration minutes: ${minutes}`)
|
|
24
|
+
}
|
|
25
|
+
const hh = Math.floor(minutes / 60)
|
|
26
|
+
const mm = minutes % 60
|
|
27
|
+
return `${String(hh).padStart(2, '0')}${String(mm).padStart(2, '0')}`
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export function parseDurationKey(key: string): number {
|
|
31
|
+
if (!/^\d{4}$/.test(key)) {
|
|
32
|
+
throw new Error(`Invalid duration key: ${key}`)
|
|
33
|
+
}
|
|
34
|
+
const hh = Number.parseInt(key.slice(0, 2), 10)
|
|
35
|
+
const mm = Number.parseInt(key.slice(2, 4), 10)
|
|
36
|
+
return hh * 60 + mm
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export function formatStartTime(time: string): { hour: string; minute: string } {
|
|
40
|
+
const match = /^(\d{1,2}):(\d{2})$/.exec(time)
|
|
41
|
+
if (!match) {
|
|
42
|
+
throw new Error(`Invalid time: ${time} (expected HH:MM)`)
|
|
43
|
+
}
|
|
44
|
+
return { hour: match[1].padStart(2, '0'), minute: match[2] }
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export function buildStartTimeParam(time: string): string {
|
|
48
|
+
const { hour, minute } = formatStartTime(time)
|
|
49
|
+
return `${hour}${minute}`
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export function buildBranchIdsParam(branchIds: readonly number[]): string {
|
|
53
|
+
if (branchIds.length === 0) {
|
|
54
|
+
throw new Error('At least one branchId is required')
|
|
55
|
+
}
|
|
56
|
+
return `${branchIds.join(',')},`
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export function parsePhone(phone: string): ParsedPhone {
|
|
60
|
+
const digits = phone.replace(/[^0-9]/g, '')
|
|
61
|
+
const prefix = TOZ_PHONE_PREFIXES.find((p) => digits.startsWith(p))
|
|
62
|
+
if (!prefix) {
|
|
63
|
+
throw new Error(`Unsupported phone prefix in: ${phone}`)
|
|
64
|
+
}
|
|
65
|
+
const rest = digits.slice(prefix.length)
|
|
66
|
+
if (rest.length !== 7 && rest.length !== 8) {
|
|
67
|
+
throw new Error(`Invalid phone number: ${phone}`)
|
|
68
|
+
}
|
|
69
|
+
const split = rest.length - 4
|
|
70
|
+
return {
|
|
71
|
+
phone1: prefix,
|
|
72
|
+
phone2: rest.slice(0, split),
|
|
73
|
+
phone3: rest.slice(split),
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
export function formatPhone(parsed: ParsedPhone): string {
|
|
78
|
+
return `${parsed.phone1}-${parsed.phone2}-${parsed.phone3}`
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
export function parseEmail(email: string): ParsedEmail {
|
|
82
|
+
const at = email.indexOf('@')
|
|
83
|
+
if (at <= 0 || at === email.length - 1) {
|
|
84
|
+
throw new Error(`Invalid email: ${email}`)
|
|
85
|
+
}
|
|
86
|
+
const local = email.slice(0, at)
|
|
87
|
+
const domain = email.slice(at + 1)
|
|
88
|
+
const known = (TOZ_EMAIL_DOMAINS as readonly string[]).includes(domain)
|
|
89
|
+
return known
|
|
90
|
+
? { email1: local, email2: domain, email3: '' }
|
|
91
|
+
: { email1: local, email2: TOZ_EMAIL_DOMAIN_CUSTOM, email3: domain }
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
export function assertDurationInRange(minutes: number): void {
|
|
95
|
+
if (minutes < TOZ_MIN_DURATION_MINUTES || minutes > TOZ_MAX_DURATION_MINUTES) {
|
|
96
|
+
throw new Error(
|
|
97
|
+
`SW마에스트로 partnership requires duration between ${TOZ_MIN_DURATION_MINUTES / 60}h and ${TOZ_MAX_DURATION_MINUTES / 60}h (got ${minutes} minutes)`,
|
|
98
|
+
)
|
|
99
|
+
}
|
|
100
|
+
}
|
|
@@ -104,6 +104,25 @@ describe('TokenExtractor', () => {
|
|
|
104
104
|
])
|
|
105
105
|
})
|
|
106
106
|
|
|
107
|
+
test('extracts plaintext cookie from opensoma.dev host', async () => {
|
|
108
|
+
const home = await makeTempDir()
|
|
109
|
+
const dbPath = join(home, '.config', 'google-chrome', 'Default', 'Cookies')
|
|
110
|
+
createCookieDbWithPlaintext(dbPath, 'opensoma-dev-session', 0, 'opensoma.dev')
|
|
111
|
+
|
|
112
|
+
const extractor = new TokenExtractor('linux', home)
|
|
113
|
+
expect(await extractor.extract()).toEqual({ sessionCookie: 'opensoma-dev-session' })
|
|
114
|
+
})
|
|
115
|
+
|
|
116
|
+
test('decrypts encrypted cookie from opensoma.dev host on Linux', async () => {
|
|
117
|
+
const home = await makeTempDir()
|
|
118
|
+
const dbPath = join(home, '.config', 'google-chrome', 'Default', 'Cookies')
|
|
119
|
+
const encrypted = encryptLinuxCookie('opensoma-dev-encrypted')
|
|
120
|
+
createCookieDbWithEncrypted(dbPath, encrypted, 'opensoma.dev')
|
|
121
|
+
|
|
122
|
+
const extractor = new TokenExtractor('linux', home)
|
|
123
|
+
expect(await extractor.extract()).toEqual({ sessionCookie: 'opensoma-dev-encrypted' })
|
|
124
|
+
})
|
|
125
|
+
|
|
107
126
|
test('decrypts encrypted cookie on Linux', async () => {
|
|
108
127
|
const home = await makeTempDir()
|
|
109
128
|
const dbPath = join(home, '.config', 'google-chrome', 'Default', 'Cookies')
|
|
@@ -148,26 +167,32 @@ function createCookieFile(filePath: string): void {
|
|
|
148
167
|
writeFileSync(filePath, '')
|
|
149
168
|
}
|
|
150
169
|
|
|
151
|
-
function createCookieDbWithPlaintext(
|
|
170
|
+
function createCookieDbWithPlaintext(
|
|
171
|
+
filePath: string,
|
|
172
|
+
value: string,
|
|
173
|
+
lastAccessUtc = 0,
|
|
174
|
+
hostKey = 'swmaestro.ai',
|
|
175
|
+
): void {
|
|
152
176
|
mkdirSync(dirname(filePath), { recursive: true })
|
|
153
177
|
const db = new Database(filePath)
|
|
154
178
|
db.run(
|
|
155
179
|
'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)',
|
|
156
180
|
)
|
|
157
181
|
db.run(
|
|
158
|
-
"INSERT INTO cookies (host_key, name, value, encrypted_value, last_access_utc) VALUES (
|
|
159
|
-
[value, lastAccessUtc],
|
|
182
|
+
"INSERT INTO cookies (host_key, name, value, encrypted_value, last_access_utc) VALUES (?, 'JSESSIONID', ?, '', ?)",
|
|
183
|
+
[hostKey, value, lastAccessUtc],
|
|
160
184
|
)
|
|
161
185
|
db.close()
|
|
162
186
|
}
|
|
163
187
|
|
|
164
|
-
function createCookieDbWithEncrypted(filePath: string, encrypted: Buffer): void {
|
|
188
|
+
function createCookieDbWithEncrypted(filePath: string, encrypted: Buffer, hostKey = 'swmaestro.ai'): void {
|
|
165
189
|
mkdirSync(dirname(filePath), { recursive: true })
|
|
166
190
|
const db = new Database(filePath)
|
|
167
191
|
db.run(
|
|
168
192
|
'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)',
|
|
169
193
|
)
|
|
170
|
-
db.run("INSERT INTO cookies (host_key, name, value, encrypted_value) VALUES (
|
|
194
|
+
db.run("INSERT INTO cookies (host_key, name, value, encrypted_value) VALUES (?, 'JSESSIONID', '', ?)", [
|
|
195
|
+
hostKey,
|
|
171
196
|
encrypted,
|
|
172
197
|
])
|
|
173
198
|
db.close()
|
package/src/token-extractor.ts
CHANGED
|
@@ -8,7 +8,7 @@ import { basename, dirname, join } from 'node:path'
|
|
|
8
8
|
const require = createRequire(import.meta.url)
|
|
9
9
|
|
|
10
10
|
const COOKIE_QUERY =
|
|
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"
|
|
11
|
+
"SELECT encrypted_value, last_access_utc, value FROM cookies WHERE (host_key LIKE '%swmaestro.ai' OR host_key LIKE '%opensoma.dev') AND name = 'JSESSIONID' ORDER BY last_access_utc DESC LIMIT 1"
|
|
12
12
|
const CHROMIUM_SALT = 'saltysalt'
|
|
13
13
|
const CHROMIUM_IV = Buffer.alloc(16, 0x20)
|
|
14
14
|
const PROFILE_DIR_PATTERN = /^Profile\s+\d+$/
|
|
@@ -124,11 +124,36 @@ function queryCookieDb(dbPath: string): CookieRow | undefined {
|
|
|
124
124
|
}
|
|
125
125
|
}
|
|
126
126
|
|
|
127
|
+
export type TokenExtractorOptions = {
|
|
128
|
+
platform?: NodeJS.Platform
|
|
129
|
+
homeDirectory?: string
|
|
130
|
+
debug?: boolean
|
|
131
|
+
}
|
|
132
|
+
|
|
127
133
|
export class TokenExtractor {
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
134
|
+
private readonly platform: NodeJS.Platform
|
|
135
|
+
private readonly homeDirectory: string
|
|
136
|
+
private readonly debugEnabled: boolean
|
|
137
|
+
|
|
138
|
+
constructor(options?: TokenExtractorOptions)
|
|
139
|
+
constructor(platform?: NodeJS.Platform, homeDirectory?: string, debug?: boolean)
|
|
140
|
+
constructor(platformOrOptions?: NodeJS.Platform | TokenExtractorOptions, homeDirectory?: string, debug?: boolean) {
|
|
141
|
+
if (typeof platformOrOptions === 'object' && platformOrOptions !== null) {
|
|
142
|
+
this.platform = platformOrOptions.platform ?? process.platform
|
|
143
|
+
this.homeDirectory = platformOrOptions.homeDirectory ?? homedir()
|
|
144
|
+
this.debugEnabled = platformOrOptions.debug ?? false
|
|
145
|
+
} else {
|
|
146
|
+
this.platform = platformOrOptions ?? process.platform
|
|
147
|
+
this.homeDirectory = homeDirectory ?? homedir()
|
|
148
|
+
this.debugEnabled = debug ?? false
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
private log(...args: unknown[]): void {
|
|
153
|
+
if (!this.debugEnabled) return
|
|
154
|
+
const message = args.map((a) => (typeof a === 'string' ? a : JSON.stringify(a))).join(' ')
|
|
155
|
+
process.stderr.write(`[extract] ${message}\n`)
|
|
156
|
+
}
|
|
132
157
|
|
|
133
158
|
async extract(): Promise<{ sessionCookie: string } | null> {
|
|
134
159
|
const candidates = await this.extractCandidates()
|
|
@@ -143,28 +168,35 @@ export class TokenExtractor {
|
|
|
143
168
|
|
|
144
169
|
async extractCandidates(): Promise<ExtractedSessionCandidate[]> {
|
|
145
170
|
const candidates = new Map<string, ExtractedSessionCandidate>()
|
|
171
|
+
const cookieDatabases = this.findCookieDatabases()
|
|
172
|
+
this.log(`Found ${cookieDatabases.length} cookie database(s)`)
|
|
146
173
|
|
|
147
|
-
for (const databasePath of
|
|
174
|
+
for (const databasePath of cookieDatabases) {
|
|
148
175
|
const browser = this.getBrowserByPath(databasePath)
|
|
149
176
|
if (!browser) {
|
|
177
|
+
this.log(`Skipping ${databasePath} (no matching browser config)`)
|
|
150
178
|
continue
|
|
151
179
|
}
|
|
152
180
|
|
|
153
181
|
const profile = basename(dirname(databasePath))
|
|
182
|
+
this.log(`Processing ${browser.name} / ${profile}`)
|
|
154
183
|
|
|
155
184
|
const tempDirectory = mkdtempSync(join(tmpdir(), 'opensoma-cookie-db-'))
|
|
156
185
|
const tempDatabasePath = join(tempDirectory, 'Cookies')
|
|
157
186
|
|
|
158
187
|
try {
|
|
159
188
|
copySqliteDatabase(databasePath, tempDatabasePath)
|
|
189
|
+
this.log(` Copied DB to ${tempDatabasePath}`)
|
|
160
190
|
|
|
161
191
|
const row = queryCookieDb(tempDatabasePath)
|
|
162
192
|
if (!row) {
|
|
193
|
+
this.log(' No JSESSIONID cookie found')
|
|
163
194
|
continue
|
|
164
195
|
}
|
|
165
196
|
|
|
166
197
|
const plaintextValue = normalizeCookieText(row.value)
|
|
167
198
|
if (plaintextValue) {
|
|
199
|
+
this.log(` Found plaintext cookie (${plaintextValue.length} chars)`)
|
|
168
200
|
this.addCandidate(candidates, {
|
|
169
201
|
browser: browser.name,
|
|
170
202
|
lastAccessUtc: this.normalizeLastAccessUtc(row.last_access_utc),
|
|
@@ -176,29 +208,40 @@ export class TokenExtractor {
|
|
|
176
208
|
|
|
177
209
|
const encryptedValue = normalizeCookieBytes(row.encrypted_value)
|
|
178
210
|
if (!encryptedValue || encryptedValue.length === 0) {
|
|
211
|
+
this.log(' No plaintext or encrypted value')
|
|
179
212
|
continue
|
|
180
213
|
}
|
|
181
214
|
|
|
215
|
+
this.log(` Decrypting encrypted cookie (${encryptedValue.length} bytes)...`)
|
|
182
216
|
const decryptedValue = await this.decryptCookie(encryptedValue, browser.name)
|
|
183
217
|
if (decryptedValue) {
|
|
218
|
+
this.log(` Decrypted successfully (${decryptedValue.length} chars)`)
|
|
184
219
|
this.addCandidate(candidates, {
|
|
185
220
|
browser: browser.name,
|
|
186
221
|
lastAccessUtc: this.normalizeLastAccessUtc(row.last_access_utc),
|
|
187
222
|
profile,
|
|
188
223
|
sessionCookie: decryptedValue,
|
|
189
224
|
})
|
|
225
|
+
} else {
|
|
226
|
+
this.log(' Decryption returned empty value')
|
|
190
227
|
}
|
|
191
|
-
} catch {
|
|
228
|
+
} catch (error) {
|
|
229
|
+
this.log(` Error: ${error instanceof Error ? error.message : String(error)}`)
|
|
192
230
|
} finally {
|
|
193
231
|
rmSync(tempDirectory, { recursive: true, force: true })
|
|
194
232
|
}
|
|
195
233
|
}
|
|
196
234
|
|
|
235
|
+
this.log(`Total unique candidates: ${candidates.size}`)
|
|
197
236
|
return [...candidates.values()].sort((left, right) => right.lastAccessUtc - left.lastAccessUtc)
|
|
198
237
|
}
|
|
199
238
|
|
|
200
239
|
findCookieDatabases(): string[] {
|
|
201
|
-
|
|
240
|
+
const results = BROWSERS.flatMap((browser) => this.findBrowserCookieDatabases(browser))
|
|
241
|
+
if (results.length === 0) {
|
|
242
|
+
this.log('No cookie databases found. Searched browsers:', BROWSERS.map((b) => b.name).join(', '))
|
|
243
|
+
}
|
|
244
|
+
return results
|
|
202
245
|
}
|
|
203
246
|
|
|
204
247
|
private async decryptCookie(encryptedValue: Buffer, browserName: string): Promise<string> {
|
|
@@ -207,14 +250,18 @@ export class TokenExtractor {
|
|
|
207
250
|
}
|
|
208
251
|
|
|
209
252
|
if (this.platform === 'linux') {
|
|
253
|
+
this.log(' Using Linux PBKDF2 decryption')
|
|
210
254
|
return this.decryptChromiumValue(encryptedValue, pbkdf2Sync('peanuts', CHROMIUM_SALT, 1, 16, 'sha1'))
|
|
211
255
|
}
|
|
212
256
|
|
|
213
257
|
if (this.platform === 'darwin') {
|
|
258
|
+
this.log(` Fetching macOS Keychain key for ${browserName}...`)
|
|
214
259
|
const key = await this.getMacOSEncryptionKey(browserName)
|
|
260
|
+
this.log(' Keychain key obtained')
|
|
215
261
|
return this.decryptChromiumValue(encryptedValue, key)
|
|
216
262
|
}
|
|
217
263
|
|
|
264
|
+
this.log(` Unsupported platform for decryption: ${this.platform}`)
|
|
218
265
|
return ''
|
|
219
266
|
}
|
|
220
267
|
|
|
@@ -224,11 +271,10 @@ export class TokenExtractor {
|
|
|
224
271
|
throw new Error(`Unsupported browser: ${browserName}`)
|
|
225
272
|
}
|
|
226
273
|
|
|
227
|
-
const
|
|
228
|
-
|
|
229
|
-
{ encoding: 'utf8' },
|
|
230
|
-
).trimEnd()
|
|
274
|
+
const command = `security find-generic-password -s ${JSON.stringify(browser.keychainService)} -a ${JSON.stringify(browser.keychainAccount)} -w`
|
|
275
|
+
this.log(` Keychain command: ${command}`)
|
|
231
276
|
|
|
277
|
+
const password = execSync(command, { encoding: 'utf8' }).trimEnd()
|
|
232
278
|
return pbkdf2Sync(password, CHROMIUM_SALT, 1003, 16, 'sha1')
|
|
233
279
|
}
|
|
234
280
|
|
|
@@ -266,14 +312,20 @@ export class TokenExtractor {
|
|
|
266
312
|
private findBrowserCookieDatabases(browser: BrowserConfig): string[] {
|
|
267
313
|
const browserRoot = this.getBrowserRoot(browser)
|
|
268
314
|
if (!browserRoot || !existsSync(browserRoot)) {
|
|
315
|
+
this.log(`${browser.name}: not found at ${browserRoot ?? '(unsupported platform)'}`)
|
|
269
316
|
return []
|
|
270
317
|
}
|
|
271
318
|
|
|
272
|
-
|
|
319
|
+
const profiles = readdirSync(browserRoot, { withFileTypes: true })
|
|
273
320
|
.filter((entry) => entry.isDirectory() && this.isSupportedProfileDirectory(entry.name))
|
|
274
321
|
.sort((left, right) => left.name.localeCompare(right.name))
|
|
322
|
+
|
|
323
|
+
const databases = profiles
|
|
275
324
|
.map((entry) => join(browserRoot, entry.name, 'Cookies'))
|
|
276
325
|
.filter((databasePath) => existsSync(databasePath))
|
|
326
|
+
|
|
327
|
+
this.log(`${browser.name}: ${profiles.length} profile(s), ${databases.length} cookie DB(s)`)
|
|
328
|
+
return databases
|
|
277
329
|
}
|
|
278
330
|
|
|
279
331
|
private getBrowserRoot(browser: BrowserConfig): string | null {
|
|
@@ -0,0 +1,197 @@
|
|
|
1
|
+
import { describe, expect, it } from 'bun:test'
|
|
2
|
+
import { readFileSync } from 'node:fs'
|
|
3
|
+
import { join } from 'node:path'
|
|
4
|
+
|
|
5
|
+
import {
|
|
6
|
+
isReservationConfirmSuccess,
|
|
7
|
+
parseBookingPageBranches,
|
|
8
|
+
parseBookingPageMeetings,
|
|
9
|
+
parseMypageReservations,
|
|
10
|
+
parseTozBoothes,
|
|
11
|
+
parseTozDurations,
|
|
12
|
+
parseTozReserved,
|
|
13
|
+
} from './toz-formatters'
|
|
14
|
+
|
|
15
|
+
const FIXTURES_DIR = join(__dirname, '__fixtures__', 'toz')
|
|
16
|
+
|
|
17
|
+
function readFixture(name: string): string {
|
|
18
|
+
return readFileSync(join(FIXTURES_DIR, name), 'utf8')
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
function readJsonFixture<T>(name: string): T {
|
|
22
|
+
return JSON.parse(readFixture(name)) as T
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
describe('parseBookingPageBranches', () => {
|
|
26
|
+
it('extracts all 9 SW마에스트로 partner branches', () => {
|
|
27
|
+
const html = readFixture('toz_booking.html')
|
|
28
|
+
const branches = parseBookingPageBranches(html)
|
|
29
|
+
|
|
30
|
+
expect(branches).toEqual([
|
|
31
|
+
{ id: 27, name: '강남토즈타워점' },
|
|
32
|
+
{ id: 145, name: '강남컨퍼런스센터' },
|
|
33
|
+
{ id: 19, name: '양재점' },
|
|
34
|
+
{ id: 20, name: '건대점' },
|
|
35
|
+
{ id: 15, name: '선릉점' },
|
|
36
|
+
{ id: 139, name: '마이스 역삼센터' },
|
|
37
|
+
{ id: 134, name: '마이스 광화문센터' },
|
|
38
|
+
{ id: 30, name: '신촌비즈센터' },
|
|
39
|
+
{ id: 149, name: '홍대점' },
|
|
40
|
+
])
|
|
41
|
+
})
|
|
42
|
+
})
|
|
43
|
+
|
|
44
|
+
describe('parseBookingPageMeetings', () => {
|
|
45
|
+
it('extracts meeting options excluding placeholder and 새모임', () => {
|
|
46
|
+
const html = readFixture('toz_booking.html')
|
|
47
|
+
const meetings = parseBookingPageMeetings(html)
|
|
48
|
+
|
|
49
|
+
expect(meetings.length).toBeGreaterThan(50)
|
|
50
|
+
expect(meetings[0]).toEqual({ id: 2305094, name: '멘토특강_김현동 님' })
|
|
51
|
+
expect(meetings.find((m) => m.id === 2322942)).toEqual({
|
|
52
|
+
id: 2322942,
|
|
53
|
+
name: '자유멘토링_SW마에스트로',
|
|
54
|
+
})
|
|
55
|
+
|
|
56
|
+
expect(meetings.find((m) => m.name === '[새 모임]')).toBeUndefined()
|
|
57
|
+
for (const meeting of meetings) {
|
|
58
|
+
expect(Number.isInteger(meeting.id)).toBe(true)
|
|
59
|
+
expect(meeting.id).toBeGreaterThan(0)
|
|
60
|
+
}
|
|
61
|
+
})
|
|
62
|
+
})
|
|
63
|
+
|
|
64
|
+
describe('parseTozDurations', () => {
|
|
65
|
+
it('parses duration JSON with computed minutes', () => {
|
|
66
|
+
const json = readJsonFixture('toz_duration.json')
|
|
67
|
+
const durations = parseTozDurations(json)
|
|
68
|
+
|
|
69
|
+
expect(durations[0]).toEqual({ key: '0200', value: '2시간', minutes: 120 })
|
|
70
|
+
expect(durations[1]).toEqual({ key: '0230', value: '2시간 30분', minutes: 150 })
|
|
71
|
+
expect(durations[2]).toEqual({ key: '0300', value: '3시간', minutes: 180 })
|
|
72
|
+
})
|
|
73
|
+
|
|
74
|
+
it('returns [] for non-array', () => {
|
|
75
|
+
expect(parseTozDurations(null)).toEqual([])
|
|
76
|
+
expect(parseTozDurations({})).toEqual([])
|
|
77
|
+
})
|
|
78
|
+
})
|
|
79
|
+
|
|
80
|
+
describe('parseTozBoothes', () => {
|
|
81
|
+
it('parses booth JSON array', () => {
|
|
82
|
+
const json = readJsonFixture('toz_boothes.json')
|
|
83
|
+
const boothes = parseTozBoothes(json)
|
|
84
|
+
|
|
85
|
+
expect(boothes).toHaveLength(3)
|
|
86
|
+
expect(boothes[0]).toEqual({
|
|
87
|
+
id: 740,
|
|
88
|
+
name: '304 _ A',
|
|
89
|
+
branchName: '토즈타워점',
|
|
90
|
+
branchTel: '02-3454-0116',
|
|
91
|
+
minUseUserCount: 1,
|
|
92
|
+
enableMaxUserCount: 2,
|
|
93
|
+
boothGroupName: '2인부스 A타입',
|
|
94
|
+
boothGroupUrl: 'https://moim.toz.co.kr/branchDetail?branch_id=28&url=&path=',
|
|
95
|
+
boothMemoForUser: '15,000원(기본2시간) / 기업, 학생할인 적용',
|
|
96
|
+
isLargeBooth: false,
|
|
97
|
+
})
|
|
98
|
+
})
|
|
99
|
+
|
|
100
|
+
it('throws on non-SUCCESS error response', () => {
|
|
101
|
+
const error = [{ resultMsg: '예약 가능한 부스가 없습니다.' }]
|
|
102
|
+
expect(() => parseTozBoothes(error)).toThrow('예약 가능한 부스가 없습니다.')
|
|
103
|
+
})
|
|
104
|
+
|
|
105
|
+
it('returns [] for empty', () => {
|
|
106
|
+
expect(parseTozBoothes([])).toEqual([])
|
|
107
|
+
expect(parseTozBoothes(null)).toEqual([])
|
|
108
|
+
})
|
|
109
|
+
})
|
|
110
|
+
|
|
111
|
+
describe('parseTozReserved', () => {
|
|
112
|
+
it('parses successful booth reservation response', () => {
|
|
113
|
+
const reserved = parseTozReserved({
|
|
114
|
+
result: 'SUCCESS',
|
|
115
|
+
resultMsg: 'abc-123',
|
|
116
|
+
branchName: '토즈타워점',
|
|
117
|
+
branchTel: '02-3454-0116',
|
|
118
|
+
boothGroupName: '2인부스 A타입',
|
|
119
|
+
boothIsLarge: false,
|
|
120
|
+
entitys: [],
|
|
121
|
+
})
|
|
122
|
+
|
|
123
|
+
expect(reserved).toEqual({
|
|
124
|
+
reservationId: 'abc-123',
|
|
125
|
+
branchName: '토즈타워점',
|
|
126
|
+
branchTel: '02-3454-0116',
|
|
127
|
+
boothGroupName: '2인부스 A타입',
|
|
128
|
+
isLargeBooth: false,
|
|
129
|
+
})
|
|
130
|
+
})
|
|
131
|
+
|
|
132
|
+
it('throws on failure response', () => {
|
|
133
|
+
expect(() => parseTozReserved({ result: 'FAIL', resultMsg: '이미 예약된 부스입니다.' })).toThrow(
|
|
134
|
+
'이미 예약된 부스입니다.',
|
|
135
|
+
)
|
|
136
|
+
})
|
|
137
|
+
})
|
|
138
|
+
|
|
139
|
+
describe('isReservationConfirmSuccess', () => {
|
|
140
|
+
it('detects standard success', () => {
|
|
141
|
+
expect(isReservationConfirmSuccess('예약 되었습니다.')).toBe(true)
|
|
142
|
+
})
|
|
143
|
+
|
|
144
|
+
it('detects large-booth success', () => {
|
|
145
|
+
expect(isReservationConfirmSuccess('대형부스 예약 신청되었습니다. 지점에서 확인 전화를 드릴 예정입니다.')).toBe(
|
|
146
|
+
true,
|
|
147
|
+
)
|
|
148
|
+
})
|
|
149
|
+
|
|
150
|
+
it('rejects error messages', () => {
|
|
151
|
+
expect(isReservationConfirmSuccess('인증번호가 올바르지 않습니다.')).toBe(false)
|
|
152
|
+
})
|
|
153
|
+
})
|
|
154
|
+
|
|
155
|
+
describe('parseMypageReservations', () => {
|
|
156
|
+
it('returns empty array when no reservations', () => {
|
|
157
|
+
const html = readFixture('toz_mypage_response.html')
|
|
158
|
+
expect(parseMypageReservations(html)).toEqual([])
|
|
159
|
+
})
|
|
160
|
+
|
|
161
|
+
it('parses synthetic reservation row', () => {
|
|
162
|
+
const html = `
|
|
163
|
+
<html><body>
|
|
164
|
+
<table class="reservation"><thead><tr><th>NO</th></tr></thead><tbody>
|
|
165
|
+
<tr><td colspan="7">예약 요청 정보가 없습니다.</td></tr>
|
|
166
|
+
</tbody></table>
|
|
167
|
+
<table class="reservation"><thead></thead><tbody>
|
|
168
|
+
<tr>
|
|
169
|
+
<td>1</td>
|
|
170
|
+
<td>자유멘토링_홍길동</td>
|
|
171
|
+
<td>2026-04-21</td>
|
|
172
|
+
<td>14:00 ~ 16:00</td>
|
|
173
|
+
<td>토즈타워점</td>
|
|
174
|
+
<td>304 _ A</td>
|
|
175
|
+
<td>2026-04-17 18:45</td>
|
|
176
|
+
<td>예약완료 <a href="javascript:destroyReservation(987654)">취소</a></td>
|
|
177
|
+
</tr>
|
|
178
|
+
</tbody></table>
|
|
179
|
+
</body></html>
|
|
180
|
+
`
|
|
181
|
+
|
|
182
|
+
expect(parseMypageReservations(html)).toEqual([
|
|
183
|
+
{
|
|
184
|
+
no: 1,
|
|
185
|
+
reservationId: 987654,
|
|
186
|
+
meetingName: '자유멘토링_홍길동',
|
|
187
|
+
date: '2026-04-21',
|
|
188
|
+
startTime: '14:00',
|
|
189
|
+
endTime: '16:00',
|
|
190
|
+
branchName: '토즈타워점',
|
|
191
|
+
boothName: '304 _ A',
|
|
192
|
+
reservedAt: '2026-04-17 18:45',
|
|
193
|
+
status: '예약완료 취소',
|
|
194
|
+
},
|
|
195
|
+
])
|
|
196
|
+
})
|
|
197
|
+
})
|