nxapi/src/api/znc-proxy.ts
2022-11-16 12:13:48 +00:00

254 lines
8.7 KiB
TypeScript

import fetch, { Response } from 'node-fetch';
import createDebug from 'debug';
import { ActiveEvent, Announcements, CurrentUser, Event, Friend, Presence, PresencePermissions, User, WebService, WebServiceToken, CoralErrorResponse, CoralStatus, CoralSuccessResponse, FriendCodeUser, FriendCodeUrl } from './coral-types.js';
import { defineResponse, ErrorResponse, ResponseSymbol } from './util.js';
import CoralApi, { CoralAuthData, CorrelationIdSymbol, PartialCoralAuthData, ResponseDataSymbol, Result } from './coral.js';
import { NintendoAccountUser } from './na.js';
import { SavedToken } from '../common/auth/coral.js';
import { timeoutSignal } from '../util/misc.js';
import { getAdditionalUserAgents, getUserAgent } from '../util/useragent.js';
const debug = createDebug('nxapi:api:znc-proxy');
export default class ZncProxyApi implements CoralApi {
// Not used by ZncProxyApi
onTokenExpired: ((data: CoralErrorResponse, res: Response) => Promise<CoralAuthData | void>) | null = null;
/** @internal */
_renewToken: Promise<void> | null = null;
readonly znca_version = '';
readonly znca_useragent = '';
constructor(
private url: string,
// ZncApi uses the NSO token (valid for a few hours)
// ZncProxyApi uses the Nintendo Account session token (valid for two years)
public token: string,
public useragent = getAdditionalUserAgents()
) {}
async fetch<T = unknown>(url: string, method = 'GET', body?: string, headers?: object) {
const [signal, cancel] = timeoutSignal();
const response = await fetch(this.url + url, {
method,
headers: Object.assign({
'Authorization': 'na ' + this.token,
'User-Agent': getUserAgent(this.useragent),
}, headers),
body,
signal,
}).finally(cancel);
debug('fetch %s %s, response %s', method, url, response.status);
if (response.status !== 200 && response.status !== 204) {
throw new ErrorResponse('[zncproxy] Non-200/204 status code', response, await response.text());
}
const data = (response.status === 204 ? {} : await response.json()) as T;
return defineResponse(data, response);
}
async call<T = unknown>(url: string, parameter = {}): Promise<Result<T>> {
throw new Error('Not supported in ZncProxyApi');
}
async getAnnouncements() {
const result = await this.fetch<{announcements: Announcements}>('/announcements');
return createResult(result, result.announcements);
}
async getFriendList() {
const result = await this.fetch<{friends: Friend[]}>('/friends');
return createResult(result, result);
}
async addFavouriteFriend(nsaid: string) {
const result = await this.fetch('/friend/' + nsaid, 'POST', JSON.stringify({
isFavoriteFriend: true,
}));
return createResult(result, {});
}
async removeFavouriteFriend(nsaid: string) {
const result = await this.fetch('/friend/' + nsaid, 'POST', JSON.stringify({
isFavoriteFriend: false,
}));
return createResult(result, {});
}
async getWebServices() {
const result = await this.fetch<{webservices: WebService[]}>('/webservices');
return createResult(result, result.webservices);
}
async getActiveEvent() {
const result = await this.fetch<{activeevent: ActiveEvent}>('/activeevent');
return createResult(result, result.activeevent);
}
async getEvent(id: number) {
const result = await this.fetch<{event: Event}>('/event/' + id);
return createResult(result, result.event);
}
async getUser(id: number) {
const result = await this.fetch<{user: User}>('/user/' + id);
return createResult(result, result.user);
}
async getUserByFriendCode(friend_code: string, hash?: string) {
const result = await this.fetch<{user: FriendCodeUser}>('/friendcode/' + friend_code);
return createResult(result, result.user);
}
async sendFriendRequest(nsa_id: string): Promise<Result<{}>> {
throw new Error('Not supported in ZncProxyApi');
}
async getCurrentUser() {
const result = await this.fetch<{user: CurrentUser}>('/user');
return createResult(result, result.user);
}
async getFriendCodeUrl() {
const result = await this.fetch<{friendcode: FriendCodeUrl}>('/friendcode');
return createResult(result, result.friendcode);
}
async getCurrentUserPermissions() {
const user = await this.getCurrentUser();
return createResult(user, {
etag: user.etag,
permissions: user.permissions,
});
}
async updateCurrentUserPermissions(
to: PresencePermissions, from: PresencePermissions, etag: string
): Promise<Result<{}>> {
throw new Error('Not supported in ZncProxyApi');
}
async getWebServiceToken(id: number) {
const result = await this.fetch<{token: WebServiceToken}>('/webservice/' + id + '/token');
return createResult(result, result.token);
}
async getToken(token: string, user: NintendoAccountUser): ReturnType<CoralApi['getToken']> {
throw new Error('Not supported in ZncProxyApi');
}
async renewToken() {
const data = await this.fetch<SavedToken>('/auth');
data.proxy_url = this.url;
return data;
}
/** @private */
setTokenWithSavedToken(data: CoralAuthData | PartialCoralAuthData) {
throw new Error('Not supported in ZncProxyApi');
}
static async createWithSessionToken(url: string, token: string) {
const nso = new this(url, token);
const data = await nso.fetch<SavedToken>('/auth');
data.proxy_url = url;
return {nso, data};
}
}
function createResult<T extends {}, R>(data: R & {[ResponseSymbol]: Response}, result: T): Result<T> {
const coral_result: CoralSuccessResponse<T> = {
status: CoralStatus.OK as const,
result,
correlationId: '',
};
Object.defineProperty(result, ResponseSymbol, {enumerable: false, value: data[ResponseSymbol]});
Object.defineProperty(result, ResponseDataSymbol, {enumerable: false, value: coral_result});
Object.defineProperty(result, CorrelationIdSymbol, {enumerable: false, value: ''});
Object.defineProperty(result, 'status', {enumerable: false, value: CoralStatus.OK});
Object.defineProperty(result, 'result', {enumerable: false, value: result});
Object.defineProperty(result, 'correlationId', {enumerable: false, value: ''});
return result as Result<T>;
}
export interface AuthToken {
user: string;
policy?: AuthPolicy;
created_at: number;
}
export interface AuthPolicy {
announcements?: boolean;
list_friends?: boolean;
list_friends_presence?: boolean;
friend?: boolean;
friend_presence?: boolean;
webservices?: boolean;
activeevent?: boolean;
current_user?: boolean;
current_user_presence?: boolean;
friends?: string[];
friends_presence?: string[];
}
export enum ZncPresenceEventStreamEvent {
FRIEND_ONLINE = '0',
FRIEND_OFFLINE = '1',
FRIEND_TITLE_CHANGE = '2',
FRIEND_TITLE_STATECHANGE = '3',
PRESENCE_UPDATED = '4',
}
export type PresenceUrlResponse =
Presence | {presence: Presence} |
CurrentUser | {user: CurrentUser} |
Friend | {friend: Friend};
export async function getPresenceFromUrl(presence_url: string, useragent?: string) {
const [signal, cancel, controller] = timeoutSignal();
const response = await fetch(presence_url, {
headers: {
'User-Agent': getUserAgent(useragent),
},
signal,
}).finally(cancel);
debug('fetch %s %s, response %s', 'GET', presence_url, response.status);
if (response.status !== 200) {
throw new ErrorResponse('[zncproxy] Unknown error', response, await response.text());
}
if (!response.headers.get('Content-Type')?.match(/^application\/json(;|$)$/)) {
controller.abort();
throw new ErrorResponse('[zncproxy] Unacceptable content type', response);
}
const data = await response.json() as PresenceUrlResponse;
const user: CurrentUser | Friend | undefined =
'user' in data ? data.user :
'friend' in data ? data.friend :
'nsaId' in data ? data :
undefined;
const presence: Presence =
'presence' in data ? data.presence :
'user' in data ? data.user.presence :
'friend' in data ? data.friend.presence :
data;
if (!('state' in presence)) {
throw new Error('Invalid presence data');
}
return defineResponse([presence, user, data as unknown] as const, response);
}