vigor-roblox 1.0.2 → 1.0.4
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/index.d.ts +15 -1
- package/dist/index.js +168 -1
- package/dist/index.mjs +168 -1
- package/package.json +1 -1
package/dist/index.d.ts
CHANGED
|
@@ -183,6 +183,15 @@ interface RobloxServerLocation {
|
|
|
183
183
|
isp: string;
|
|
184
184
|
timezone: string;
|
|
185
185
|
}
|
|
186
|
+
interface RobloxFriendEntry {
|
|
187
|
+
id: RobloxUserId;
|
|
188
|
+
name: RobloxUserName;
|
|
189
|
+
displayName: RobloxDisplayName;
|
|
190
|
+
hasVerifiedBadge?: boolean;
|
|
191
|
+
isOnline?: boolean;
|
|
192
|
+
isDeleted?: boolean;
|
|
193
|
+
friendFrom?: string | null;
|
|
194
|
+
}
|
|
186
195
|
interface CreateRobloxApiOptions {
|
|
187
196
|
cache: RobloxApiCache;
|
|
188
197
|
cookies: RobloxCookie[];
|
|
@@ -232,13 +241,18 @@ interface RobloxApi {
|
|
|
232
241
|
placeId: RobloxPlaceId;
|
|
233
242
|
jobIds: RobloxJobId[];
|
|
234
243
|
}) => Promise<RobloxServerLocation[]>;
|
|
244
|
+
friends: (userId: RobloxUserId) => Promise<RobloxFriendEntry[]>;
|
|
245
|
+
sendFriendRequest: (targetUserId: RobloxUserId) => Promise<void>;
|
|
246
|
+
unfriend: (targetUserId: RobloxUserId) => Promise<void>;
|
|
235
247
|
_internal: {
|
|
236
248
|
gamejoinApi: VigorFetchInstance;
|
|
237
249
|
gamesApi: VigorFetchInstance;
|
|
238
250
|
apisRoblox: VigorFetchInstance;
|
|
251
|
+
friendsApi: VigorFetchInstance;
|
|
252
|
+
presenceApi: VigorFetchInstance;
|
|
239
253
|
};
|
|
240
254
|
}
|
|
241
255
|
declare function createRobloxApi({ cache, cookies: cookiesList, ipgeolocationKey, }: CreateRobloxApiOptions): RobloxApi;
|
|
242
256
|
|
|
243
257
|
export { RobloxAuthError, RobloxRateLimitError, RobloxRequestError, createRobloxApi };
|
|
244
|
-
export type { CreateRobloxApiOptions, RobloxApi, RobloxApiCache, RobloxAssetId, RobloxAuthenticatedUser, RobloxCookie, RobloxDisplayName, RobloxJobId, RobloxPlaceId, RobloxPlaceInfo, RobloxPresenceEntry, RobloxServerEntry, RobloxServerEntryWithLocation, RobloxServerLocation, RobloxServersResult, RobloxThumbnail, RobloxThumbnailRaw, RobloxThumbnailTarget, RobloxUniverseId, RobloxUser, RobloxUserAgeBracket, RobloxUserBirthdate, RobloxUserCountryCode, RobloxUserDescription, RobloxUserGender, RobloxUserId, RobloxUserName, RobloxUserRoles, RobloxUserSimple, VigorFetchInstance };
|
|
258
|
+
export type { CreateRobloxApiOptions, RobloxApi, RobloxApiCache, RobloxAssetId, RobloxAuthenticatedUser, RobloxCookie, RobloxDisplayName, RobloxFriendEntry, RobloxJobId, RobloxPlaceId, RobloxPlaceInfo, RobloxPresenceEntry, RobloxServerEntry, RobloxServerEntryWithLocation, RobloxServerLocation, RobloxServersResult, RobloxThumbnail, RobloxThumbnailRaw, RobloxThumbnailTarget, RobloxUniverseId, RobloxUser, RobloxUserAgeBracket, RobloxUserBirthdate, RobloxUserCountryCode, RobloxUserDescription, RobloxUserGender, RobloxUserId, RobloxUserName, RobloxUserRoles, RobloxUserSimple, VigorFetchInstance };
|
package/dist/index.js
CHANGED
|
@@ -1838,10 +1838,85 @@ function partition(arr, pred) {
|
|
|
1838
1838
|
return { pass, fail };
|
|
1839
1839
|
}
|
|
1840
1840
|
// ----------------------------------------------------------------
|
|
1841
|
+
// CSRF Token Manager
|
|
1842
|
+
// ----------------------------------------------------------------
|
|
1843
|
+
class CsrfTokenManager {
|
|
1844
|
+
// cookie → token 매핑 (쿠키별로 독립 토큰 관리)
|
|
1845
|
+
tokenMap = new Map();
|
|
1846
|
+
// 쿠키별 진행 중인 갱신 Promise (중복 갱신 방지)
|
|
1847
|
+
pendingMap = new Map();
|
|
1848
|
+
/**
|
|
1849
|
+
* 저장된 토큰을 반환합니다. 없으면 null.
|
|
1850
|
+
*/
|
|
1851
|
+
get(cookie) {
|
|
1852
|
+
return this.tokenMap.get(cookie) ?? null;
|
|
1853
|
+
}
|
|
1854
|
+
/**
|
|
1855
|
+
* 토큰을 수동으로 저장합니다.
|
|
1856
|
+
* (403 응답 헤더의 x-csrf-token 값을 받아서 저장할 때 사용)
|
|
1857
|
+
*/
|
|
1858
|
+
set(cookie, token) {
|
|
1859
|
+
this.tokenMap.set(cookie, token);
|
|
1860
|
+
}
|
|
1861
|
+
/**
|
|
1862
|
+
* 토큰을 무효화합니다.
|
|
1863
|
+
* (재시도 전 갱신이 필요할 때 호출)
|
|
1864
|
+
*/
|
|
1865
|
+
invalidate(cookie) {
|
|
1866
|
+
this.tokenMap.delete(cookie);
|
|
1867
|
+
}
|
|
1868
|
+
/**
|
|
1869
|
+
* Roblox의 CSRF 토큰 갱신 방식:
|
|
1870
|
+
* POST /v1/logout 등 아무 인증 엔드포인트에 빈 body로 요청하면
|
|
1871
|
+
* 403 응답과 함께 x-csrf-token 헤더로 새 토큰을 내려줍니다.
|
|
1872
|
+
*
|
|
1873
|
+
* 동일 쿠키에 대해 동시에 여러 갱신 요청이 오면
|
|
1874
|
+
* 하나만 실제로 요청하고 나머지는 그 결과를 공유합니다.
|
|
1875
|
+
*/
|
|
1876
|
+
async refresh(cookie) {
|
|
1877
|
+
const existing = this.pendingMap.get(cookie);
|
|
1878
|
+
if (existing)
|
|
1879
|
+
return existing;
|
|
1880
|
+
const pending = (async () => {
|
|
1881
|
+
try {
|
|
1882
|
+
// Roblox는 POST에 body가 없어도 403 + x-csrf-token을 내려줌
|
|
1883
|
+
const response = await fetch('https://auth.roblox.com/v2/logout', {
|
|
1884
|
+
method: 'POST',
|
|
1885
|
+
headers: {
|
|
1886
|
+
'Cookie': `.ROBLOSECURITY=${cookie}`,
|
|
1887
|
+
'User-Agent': 'Roblox/WinInet',
|
|
1888
|
+
'Content-Length': '0',
|
|
1889
|
+
},
|
|
1890
|
+
});
|
|
1891
|
+
const token = response.headers.get('x-csrf-token');
|
|
1892
|
+
if (!token)
|
|
1893
|
+
throw new Error('CSRF token not found in response headers');
|
|
1894
|
+
this.tokenMap.set(cookie, token);
|
|
1895
|
+
return token;
|
|
1896
|
+
}
|
|
1897
|
+
finally {
|
|
1898
|
+
this.pendingMap.delete(cookie);
|
|
1899
|
+
}
|
|
1900
|
+
})();
|
|
1901
|
+
this.pendingMap.set(cookie, pending);
|
|
1902
|
+
return pending;
|
|
1903
|
+
}
|
|
1904
|
+
/**
|
|
1905
|
+
* 저장된 토큰을 반환하되, 없으면 자동으로 갱신 후 반환합니다.
|
|
1906
|
+
*/
|
|
1907
|
+
async getOrRefresh(cookie) {
|
|
1908
|
+
const cached = this.get(cookie);
|
|
1909
|
+
if (cached)
|
|
1910
|
+
return cached;
|
|
1911
|
+
return this.refresh(cookie);
|
|
1912
|
+
}
|
|
1913
|
+
}
|
|
1914
|
+
// ----------------------------------------------------------------
|
|
1841
1915
|
// Factory
|
|
1842
1916
|
// ----------------------------------------------------------------
|
|
1843
1917
|
function createRobloxApi({ cache, cookies: cookiesList, ipgeolocationKey, }) {
|
|
1844
1918
|
const cookiePool = cookiesList.map(cookie => ({ cookie, lastUsed: 0 }));
|
|
1919
|
+
const csrfManager = new CsrfTokenManager();
|
|
1845
1920
|
function pickCookie() {
|
|
1846
1921
|
const entry = cookiePool.reduce((a, b) => a.lastUsed < b.lastUsed ? a : b);
|
|
1847
1922
|
entry.lastUsed = Date.now();
|
|
@@ -1854,6 +1929,47 @@ function createRobloxApi({ cache, cookies: cookiesList, ipgeolocationKey, }) {
|
|
|
1854
1929
|
});
|
|
1855
1930
|
}
|
|
1856
1931
|
const dataInterceptor = pickKey('data');
|
|
1932
|
+
// ----------------------------------------------------------------
|
|
1933
|
+
// CSRF 인터셉터 팩토리
|
|
1934
|
+
// ----------------------------------------------------------------
|
|
1935
|
+
// cookie를 인자로 받아 해당 쿠키 전용 CSRF 인터셉터를 생성합니다.
|
|
1936
|
+
// 동작 방식:
|
|
1937
|
+
// before → 캐시된 토큰이 있으면 X-CSRF-Token 헤더에 주입
|
|
1938
|
+
// onError → 403 응답이고 헤더에 x-csrf-token이 있으면
|
|
1939
|
+
// 토큰을 갱신하고 요청을 재시작(restart)
|
|
1940
|
+
function makeCsrfInterceptor(getCookie) {
|
|
1941
|
+
return vigor.builder.fetch.interceptors()
|
|
1942
|
+
.before(async (ctx, api) => {
|
|
1943
|
+
const cookie = getCookie();
|
|
1944
|
+
const token = await csrfManager.getOrRefresh(cookie);
|
|
1945
|
+
api.setHeaders({
|
|
1946
|
+
...ctx.options.headers,
|
|
1947
|
+
'X-CSRF-Token': token,
|
|
1948
|
+
});
|
|
1949
|
+
})
|
|
1950
|
+
.onError(async (ctx, api) => {
|
|
1951
|
+
// VigorFetchError + FETCH_FAILED + status 403 여부 확인
|
|
1952
|
+
const cause = ctx.error;
|
|
1953
|
+
if (cause instanceof VigorFetchError &&
|
|
1954
|
+
cause.code === 'FETCH_FAILED' &&
|
|
1955
|
+
cause.data?.status === 403) {
|
|
1956
|
+
const response = cause.data.response;
|
|
1957
|
+
const newToken = response.headers.get('x-csrf-token');
|
|
1958
|
+
const cookie = getCookie();
|
|
1959
|
+
if (newToken) {
|
|
1960
|
+
// 새 토큰 저장 후 재시도
|
|
1961
|
+
csrfManager.set(cookie, newToken);
|
|
1962
|
+
api.restart?.();
|
|
1963
|
+
}
|
|
1964
|
+
else {
|
|
1965
|
+
// 헤더에 토큰이 없으면 기존 토큰 무효화 후 강제 갱신 후 재시도
|
|
1966
|
+
csrfManager.invalidate(cookie);
|
|
1967
|
+
await csrfManager.refresh(cookie);
|
|
1968
|
+
api.restart?.();
|
|
1969
|
+
}
|
|
1970
|
+
}
|
|
1971
|
+
});
|
|
1972
|
+
}
|
|
1857
1973
|
const cookieInterceptor = vigor.builder.fetch.interceptors()
|
|
1858
1974
|
.before((ctx, api) => {
|
|
1859
1975
|
api.setHeaders({
|
|
@@ -1868,6 +1984,9 @@ function createRobloxApi({ cache, cookies: cookiesList, ipgeolocationKey, }) {
|
|
|
1868
1984
|
'User-Agent': 'Roblox/WinInet',
|
|
1869
1985
|
});
|
|
1870
1986
|
});
|
|
1987
|
+
// CSRF가 필요한 POST 엔드포인트에 붙일 공용 인터셉터
|
|
1988
|
+
// pickCookie()로 현재 선택된 쿠키를 기준으로 토큰 관리
|
|
1989
|
+
const csrfInterceptor = makeCsrfInterceptor(pickCookie);
|
|
1871
1990
|
const usersApi = vigor.fetch('https://users.roblox.com/v1')
|
|
1872
1991
|
.interceptors(cookieInterceptor)
|
|
1873
1992
|
.interceptors(winInetInterceptor)
|
|
@@ -1904,6 +2023,15 @@ function createRobloxApi({ cache, cookies: cookiesList, ipgeolocationKey, }) {
|
|
|
1904
2023
|
.retryConfig(c => c
|
|
1905
2024
|
.settings(s => s.attempt(4))
|
|
1906
2025
|
.algorithms(a => a.backoff().initial(500).multiplier(2)));
|
|
2026
|
+
// friends.roblox.com — CSRF 인터셉터 포함
|
|
2027
|
+
// sendFriendRequest / unfriend 는 POST라 CSRF 토큰 필요
|
|
2028
|
+
const friendsApi = vigor.fetch('https://friends.roblox.com/v1')
|
|
2029
|
+
.interceptors(cookieInterceptor)
|
|
2030
|
+
.interceptors(winInetInterceptor)
|
|
2031
|
+
.interceptors(csrfInterceptor) // ← CSRF 추가
|
|
2032
|
+
.retryConfig(c => c
|
|
2033
|
+
.settings(s => s.attempt(5))
|
|
2034
|
+
.algorithms(a => a.backoff().initial(500).multiplier(2)));
|
|
1907
2035
|
async function withCache(opts) {
|
|
1908
2036
|
const { type, keys, ttlMs, getKey, fetchMissing, fallback } = opts;
|
|
1909
2037
|
const cached = await cache.select(type, keys);
|
|
@@ -2398,6 +2526,42 @@ function createRobloxApi({ cache, cookies: cookiesList, ipgeolocationKey, }) {
|
|
|
2398
2526
|
]);
|
|
2399
2527
|
return jobIds.flatMap(id => { const loc = resultMap.get(id); return loc ? [loc] : []; });
|
|
2400
2528
|
}
|
|
2529
|
+
// ----------------------------------------------------------------
|
|
2530
|
+
// Friends
|
|
2531
|
+
// ----------------------------------------------------------------
|
|
2532
|
+
async function friends(userId) {
|
|
2533
|
+
try {
|
|
2534
|
+
return await friendsApi
|
|
2535
|
+
.path('users', userId, 'friends')
|
|
2536
|
+
.interceptors(dataInterceptor)
|
|
2537
|
+
.request();
|
|
2538
|
+
}
|
|
2539
|
+
catch (cause) {
|
|
2540
|
+
throw wrapVigorError(cause);
|
|
2541
|
+
}
|
|
2542
|
+
}
|
|
2543
|
+
async function sendFriendRequest(targetUserId) {
|
|
2544
|
+
try {
|
|
2545
|
+
await friendsApi
|
|
2546
|
+
.path('users', targetUserId, 'request-friendship')
|
|
2547
|
+
.body({})
|
|
2548
|
+
.request();
|
|
2549
|
+
}
|
|
2550
|
+
catch (cause) {
|
|
2551
|
+
throw wrapVigorError(cause);
|
|
2552
|
+
}
|
|
2553
|
+
}
|
|
2554
|
+
async function unfriend(targetUserId) {
|
|
2555
|
+
try {
|
|
2556
|
+
await friendsApi
|
|
2557
|
+
.path('users', targetUserId, 'unfriend')
|
|
2558
|
+
.body({})
|
|
2559
|
+
.request();
|
|
2560
|
+
}
|
|
2561
|
+
catch (cause) {
|
|
2562
|
+
throw wrapVigorError(cause);
|
|
2563
|
+
}
|
|
2564
|
+
}
|
|
2401
2565
|
return {
|
|
2402
2566
|
authenticated,
|
|
2403
2567
|
usersSimple,
|
|
@@ -2413,7 +2577,10 @@ function createRobloxApi({ cache, cookies: cookiesList, ipgeolocationKey, }) {
|
|
|
2413
2577
|
usersWithImg,
|
|
2414
2578
|
track,
|
|
2415
2579
|
serversRegion,
|
|
2416
|
-
|
|
2580
|
+
friends,
|
|
2581
|
+
sendFriendRequest,
|
|
2582
|
+
unfriend,
|
|
2583
|
+
_internal: { gamejoinApi, gamesApi, apisRoblox, friendsApi, presenceApi },
|
|
2417
2584
|
};
|
|
2418
2585
|
}
|
|
2419
2586
|
|
package/dist/index.mjs
CHANGED
|
@@ -1836,10 +1836,85 @@ function partition(arr, pred) {
|
|
|
1836
1836
|
return { pass, fail };
|
|
1837
1837
|
}
|
|
1838
1838
|
// ----------------------------------------------------------------
|
|
1839
|
+
// CSRF Token Manager
|
|
1840
|
+
// ----------------------------------------------------------------
|
|
1841
|
+
class CsrfTokenManager {
|
|
1842
|
+
// cookie → token 매핑 (쿠키별로 독립 토큰 관리)
|
|
1843
|
+
tokenMap = new Map();
|
|
1844
|
+
// 쿠키별 진행 중인 갱신 Promise (중복 갱신 방지)
|
|
1845
|
+
pendingMap = new Map();
|
|
1846
|
+
/**
|
|
1847
|
+
* 저장된 토큰을 반환합니다. 없으면 null.
|
|
1848
|
+
*/
|
|
1849
|
+
get(cookie) {
|
|
1850
|
+
return this.tokenMap.get(cookie) ?? null;
|
|
1851
|
+
}
|
|
1852
|
+
/**
|
|
1853
|
+
* 토큰을 수동으로 저장합니다.
|
|
1854
|
+
* (403 응답 헤더의 x-csrf-token 값을 받아서 저장할 때 사용)
|
|
1855
|
+
*/
|
|
1856
|
+
set(cookie, token) {
|
|
1857
|
+
this.tokenMap.set(cookie, token);
|
|
1858
|
+
}
|
|
1859
|
+
/**
|
|
1860
|
+
* 토큰을 무효화합니다.
|
|
1861
|
+
* (재시도 전 갱신이 필요할 때 호출)
|
|
1862
|
+
*/
|
|
1863
|
+
invalidate(cookie) {
|
|
1864
|
+
this.tokenMap.delete(cookie);
|
|
1865
|
+
}
|
|
1866
|
+
/**
|
|
1867
|
+
* Roblox의 CSRF 토큰 갱신 방식:
|
|
1868
|
+
* POST /v1/logout 등 아무 인증 엔드포인트에 빈 body로 요청하면
|
|
1869
|
+
* 403 응답과 함께 x-csrf-token 헤더로 새 토큰을 내려줍니다.
|
|
1870
|
+
*
|
|
1871
|
+
* 동일 쿠키에 대해 동시에 여러 갱신 요청이 오면
|
|
1872
|
+
* 하나만 실제로 요청하고 나머지는 그 결과를 공유합니다.
|
|
1873
|
+
*/
|
|
1874
|
+
async refresh(cookie) {
|
|
1875
|
+
const existing = this.pendingMap.get(cookie);
|
|
1876
|
+
if (existing)
|
|
1877
|
+
return existing;
|
|
1878
|
+
const pending = (async () => {
|
|
1879
|
+
try {
|
|
1880
|
+
// Roblox는 POST에 body가 없어도 403 + x-csrf-token을 내려줌
|
|
1881
|
+
const response = await fetch('https://auth.roblox.com/v2/logout', {
|
|
1882
|
+
method: 'POST',
|
|
1883
|
+
headers: {
|
|
1884
|
+
'Cookie': `.ROBLOSECURITY=${cookie}`,
|
|
1885
|
+
'User-Agent': 'Roblox/WinInet',
|
|
1886
|
+
'Content-Length': '0',
|
|
1887
|
+
},
|
|
1888
|
+
});
|
|
1889
|
+
const token = response.headers.get('x-csrf-token');
|
|
1890
|
+
if (!token)
|
|
1891
|
+
throw new Error('CSRF token not found in response headers');
|
|
1892
|
+
this.tokenMap.set(cookie, token);
|
|
1893
|
+
return token;
|
|
1894
|
+
}
|
|
1895
|
+
finally {
|
|
1896
|
+
this.pendingMap.delete(cookie);
|
|
1897
|
+
}
|
|
1898
|
+
})();
|
|
1899
|
+
this.pendingMap.set(cookie, pending);
|
|
1900
|
+
return pending;
|
|
1901
|
+
}
|
|
1902
|
+
/**
|
|
1903
|
+
* 저장된 토큰을 반환하되, 없으면 자동으로 갱신 후 반환합니다.
|
|
1904
|
+
*/
|
|
1905
|
+
async getOrRefresh(cookie) {
|
|
1906
|
+
const cached = this.get(cookie);
|
|
1907
|
+
if (cached)
|
|
1908
|
+
return cached;
|
|
1909
|
+
return this.refresh(cookie);
|
|
1910
|
+
}
|
|
1911
|
+
}
|
|
1912
|
+
// ----------------------------------------------------------------
|
|
1839
1913
|
// Factory
|
|
1840
1914
|
// ----------------------------------------------------------------
|
|
1841
1915
|
function createRobloxApi({ cache, cookies: cookiesList, ipgeolocationKey, }) {
|
|
1842
1916
|
const cookiePool = cookiesList.map(cookie => ({ cookie, lastUsed: 0 }));
|
|
1917
|
+
const csrfManager = new CsrfTokenManager();
|
|
1843
1918
|
function pickCookie() {
|
|
1844
1919
|
const entry = cookiePool.reduce((a, b) => a.lastUsed < b.lastUsed ? a : b);
|
|
1845
1920
|
entry.lastUsed = Date.now();
|
|
@@ -1852,6 +1927,47 @@ function createRobloxApi({ cache, cookies: cookiesList, ipgeolocationKey, }) {
|
|
|
1852
1927
|
});
|
|
1853
1928
|
}
|
|
1854
1929
|
const dataInterceptor = pickKey('data');
|
|
1930
|
+
// ----------------------------------------------------------------
|
|
1931
|
+
// CSRF 인터셉터 팩토리
|
|
1932
|
+
// ----------------------------------------------------------------
|
|
1933
|
+
// cookie를 인자로 받아 해당 쿠키 전용 CSRF 인터셉터를 생성합니다.
|
|
1934
|
+
// 동작 방식:
|
|
1935
|
+
// before → 캐시된 토큰이 있으면 X-CSRF-Token 헤더에 주입
|
|
1936
|
+
// onError → 403 응답이고 헤더에 x-csrf-token이 있으면
|
|
1937
|
+
// 토큰을 갱신하고 요청을 재시작(restart)
|
|
1938
|
+
function makeCsrfInterceptor(getCookie) {
|
|
1939
|
+
return vigor.builder.fetch.interceptors()
|
|
1940
|
+
.before(async (ctx, api) => {
|
|
1941
|
+
const cookie = getCookie();
|
|
1942
|
+
const token = await csrfManager.getOrRefresh(cookie);
|
|
1943
|
+
api.setHeaders({
|
|
1944
|
+
...ctx.options.headers,
|
|
1945
|
+
'X-CSRF-Token': token,
|
|
1946
|
+
});
|
|
1947
|
+
})
|
|
1948
|
+
.onError(async (ctx, api) => {
|
|
1949
|
+
// VigorFetchError + FETCH_FAILED + status 403 여부 확인
|
|
1950
|
+
const cause = ctx.error;
|
|
1951
|
+
if (cause instanceof VigorFetchError &&
|
|
1952
|
+
cause.code === 'FETCH_FAILED' &&
|
|
1953
|
+
cause.data?.status === 403) {
|
|
1954
|
+
const response = cause.data.response;
|
|
1955
|
+
const newToken = response.headers.get('x-csrf-token');
|
|
1956
|
+
const cookie = getCookie();
|
|
1957
|
+
if (newToken) {
|
|
1958
|
+
// 새 토큰 저장 후 재시도
|
|
1959
|
+
csrfManager.set(cookie, newToken);
|
|
1960
|
+
api.restart?.();
|
|
1961
|
+
}
|
|
1962
|
+
else {
|
|
1963
|
+
// 헤더에 토큰이 없으면 기존 토큰 무효화 후 강제 갱신 후 재시도
|
|
1964
|
+
csrfManager.invalidate(cookie);
|
|
1965
|
+
await csrfManager.refresh(cookie);
|
|
1966
|
+
api.restart?.();
|
|
1967
|
+
}
|
|
1968
|
+
}
|
|
1969
|
+
});
|
|
1970
|
+
}
|
|
1855
1971
|
const cookieInterceptor = vigor.builder.fetch.interceptors()
|
|
1856
1972
|
.before((ctx, api) => {
|
|
1857
1973
|
api.setHeaders({
|
|
@@ -1866,6 +1982,9 @@ function createRobloxApi({ cache, cookies: cookiesList, ipgeolocationKey, }) {
|
|
|
1866
1982
|
'User-Agent': 'Roblox/WinInet',
|
|
1867
1983
|
});
|
|
1868
1984
|
});
|
|
1985
|
+
// CSRF가 필요한 POST 엔드포인트에 붙일 공용 인터셉터
|
|
1986
|
+
// pickCookie()로 현재 선택된 쿠키를 기준으로 토큰 관리
|
|
1987
|
+
const csrfInterceptor = makeCsrfInterceptor(pickCookie);
|
|
1869
1988
|
const usersApi = vigor.fetch('https://users.roblox.com/v1')
|
|
1870
1989
|
.interceptors(cookieInterceptor)
|
|
1871
1990
|
.interceptors(winInetInterceptor)
|
|
@@ -1902,6 +2021,15 @@ function createRobloxApi({ cache, cookies: cookiesList, ipgeolocationKey, }) {
|
|
|
1902
2021
|
.retryConfig(c => c
|
|
1903
2022
|
.settings(s => s.attempt(4))
|
|
1904
2023
|
.algorithms(a => a.backoff().initial(500).multiplier(2)));
|
|
2024
|
+
// friends.roblox.com — CSRF 인터셉터 포함
|
|
2025
|
+
// sendFriendRequest / unfriend 는 POST라 CSRF 토큰 필요
|
|
2026
|
+
const friendsApi = vigor.fetch('https://friends.roblox.com/v1')
|
|
2027
|
+
.interceptors(cookieInterceptor)
|
|
2028
|
+
.interceptors(winInetInterceptor)
|
|
2029
|
+
.interceptors(csrfInterceptor) // ← CSRF 추가
|
|
2030
|
+
.retryConfig(c => c
|
|
2031
|
+
.settings(s => s.attempt(5))
|
|
2032
|
+
.algorithms(a => a.backoff().initial(500).multiplier(2)));
|
|
1905
2033
|
async function withCache(opts) {
|
|
1906
2034
|
const { type, keys, ttlMs, getKey, fetchMissing, fallback } = opts;
|
|
1907
2035
|
const cached = await cache.select(type, keys);
|
|
@@ -2396,6 +2524,42 @@ function createRobloxApi({ cache, cookies: cookiesList, ipgeolocationKey, }) {
|
|
|
2396
2524
|
]);
|
|
2397
2525
|
return jobIds.flatMap(id => { const loc = resultMap.get(id); return loc ? [loc] : []; });
|
|
2398
2526
|
}
|
|
2527
|
+
// ----------------------------------------------------------------
|
|
2528
|
+
// Friends
|
|
2529
|
+
// ----------------------------------------------------------------
|
|
2530
|
+
async function friends(userId) {
|
|
2531
|
+
try {
|
|
2532
|
+
return await friendsApi
|
|
2533
|
+
.path('users', userId, 'friends')
|
|
2534
|
+
.interceptors(dataInterceptor)
|
|
2535
|
+
.request();
|
|
2536
|
+
}
|
|
2537
|
+
catch (cause) {
|
|
2538
|
+
throw wrapVigorError(cause);
|
|
2539
|
+
}
|
|
2540
|
+
}
|
|
2541
|
+
async function sendFriendRequest(targetUserId) {
|
|
2542
|
+
try {
|
|
2543
|
+
await friendsApi
|
|
2544
|
+
.path('users', targetUserId, 'request-friendship')
|
|
2545
|
+
.body({})
|
|
2546
|
+
.request();
|
|
2547
|
+
}
|
|
2548
|
+
catch (cause) {
|
|
2549
|
+
throw wrapVigorError(cause);
|
|
2550
|
+
}
|
|
2551
|
+
}
|
|
2552
|
+
async function unfriend(targetUserId) {
|
|
2553
|
+
try {
|
|
2554
|
+
await friendsApi
|
|
2555
|
+
.path('users', targetUserId, 'unfriend')
|
|
2556
|
+
.body({})
|
|
2557
|
+
.request();
|
|
2558
|
+
}
|
|
2559
|
+
catch (cause) {
|
|
2560
|
+
throw wrapVigorError(cause);
|
|
2561
|
+
}
|
|
2562
|
+
}
|
|
2399
2563
|
return {
|
|
2400
2564
|
authenticated,
|
|
2401
2565
|
usersSimple,
|
|
@@ -2411,7 +2575,10 @@ function createRobloxApi({ cache, cookies: cookiesList, ipgeolocationKey, }) {
|
|
|
2411
2575
|
usersWithImg,
|
|
2412
2576
|
track,
|
|
2413
2577
|
serversRegion,
|
|
2414
|
-
|
|
2578
|
+
friends,
|
|
2579
|
+
sendFriendRequest,
|
|
2580
|
+
unfriend,
|
|
2581
|
+
_internal: { gamejoinApi, gamesApi, apisRoblox, friendsApi, presenceApi },
|
|
2415
2582
|
};
|
|
2416
2583
|
}
|
|
2417
2584
|
|