From a53eccd91e0f9fd32942f33bb6473cbae910c13f Mon Sep 17 00:00:00 2001 From: Samuel Elliott Date: Wed, 6 Apr 2022 23:17:54 +0100 Subject: [PATCH] Update znc types, add other endpoints and use /v3/Account/GetToken to renew the app token --- src/api/znc-proxy.ts | 66 ++++++++++++++++--- src/api/znc-types.ts | 72 +++++++++++++++++++-- src/api/znc.ts | 100 +++++++++++++++++++++++++---- src/cli/nso/http-server.ts | 128 +++++++++++++++++++++++++++++++++++++ 4 files changed, 343 insertions(+), 23 deletions(-) diff --git a/src/api/znc-proxy.ts b/src/api/znc-proxy.ts index 40a9490..be925af 100644 --- a/src/api/znc-proxy.ts +++ b/src/api/znc-proxy.ts @@ -1,9 +1,10 @@ import fetch from 'node-fetch'; import createDebug from 'debug'; -import { ActiveEvent, Announcement, CurrentUser, Friend, WebService, WebServiceToken } from './znc-types.js'; +import { ActiveEvent, Announcements, CurrentUser, Event, Friend, PresencePermissions, User, WebService, WebServiceToken, ZncStatus, ZncSuccessResponse } from './znc-types.js'; import { ErrorResponse } from './util.js'; import ZncApi from './znc.js'; import { SavedToken, version } from '../util.js'; +import { NintendoAccountUser } from './na.js'; const debug = createDebug('api:znc-proxy'); @@ -30,6 +31,8 @@ export default class ZncProxyApi implements ZncApi { debug('fetch %s %s, response %s', method, url, response.status); + if (response.status === 204) return null!; + if (response.status !== 200) { throw new ErrorResponse('[zncproxy] Unknown error', response); } @@ -40,33 +43,80 @@ export default class ZncProxyApi implements ZncApi { } async getAnnouncements() { - const response = await this.fetch<{announcements: Announcement[]}>('/announcements'); - return {status: 0 as const, result: response.announcements, correlationId: ''}; + const response = await this.fetch<{announcements: Announcements}>('/announcements'); + return {status: ZncStatus.OK as const, result: response.announcements, correlationId: ''}; } async getFriendList() { const response = await this.fetch<{friends: Friend[]}>('/friends'); - return {status: 0 as const, result: response, correlationId: ''}; + return {status: ZncStatus.OK as const, result: response, correlationId: ''}; + } + + async addFavouriteFriend(nsaid: string) { + await this.fetch('/friend/' + nsaid, 'POST', JSON.stringify({ + isFavoriteFriend: true, + })); + return {status: ZncStatus.OK as const, result: {}, correlationId: ''}; + } + + async removeFavouriteFriend(nsaid: string) { + await this.fetch('/friend/' + nsaid, 'POST', JSON.stringify({ + isFavoriteFriend: false, + })); + return {status: ZncStatus.OK as const, result: {}, correlationId: ''}; } async getWebServices() { const response = await this.fetch<{webservices: WebService[]}>('/webservices'); - return {status: 0 as const, result: response.webservices, correlationId: ''}; + return {status: ZncStatus.OK as const, result: response.webservices, correlationId: ''}; } async getActiveEvent() { const response = await this.fetch<{activeevent: ActiveEvent}>('/activeevent'); - return {status: 0 as const, result: response.activeevent, correlationId: ''}; + return {status: ZncStatus.OK as const, result: response.activeevent, correlationId: ''}; + } + + async getEvent(id: number) { + const response = await this.fetch<{event: Event}>('/event/' + id); + return {status: ZncStatus.OK as const, result: response.event, correlationId: ''}; + } + + async getUser(id: number) { + const response = await this.fetch<{user: User}>('/user/' + id); + return {status: ZncStatus.OK as const, result: response.user, correlationId: ''}; } async getCurrentUser() { const response = await this.fetch<{user: CurrentUser}>('/user'); - return {status: 0 as const, result: response.user, correlationId: ''}; + return {status: ZncStatus.OK as const, result: response.user, correlationId: ''}; + } + + async getCurrentUserPermissions() { + const user = await this.getCurrentUser(); + + return { + status: ZncStatus.OK as const, + result: { + etag: user.result.etag, + permissions: user.result.permissions, + }, + correlationId: '', + }; + } + + async updateCurrentUserPermissions( + to: PresencePermissions, from: PresencePermissions, etag: string + ): Promise> { + throw new Error('Not supported in ZncProxyApi'); } async getWebServiceToken(id: string) { const response = await this.fetch<{token: WebServiceToken}>('/webservice/' + id + '/token'); - return {status: 0 as const, result: response.token, correlationId: ''}; + return {status: ZncStatus.OK as const, result: response.token, correlationId: ''}; + } + + async getToken(token: string, user: NintendoAccountUser): Promise> { + throw new Error('Not supported in ZncProxyApi'); } async renewToken() { diff --git a/src/api/znc-types.ts b/src/api/znc-types.ts index 32a3026..f142abc 100644 --- a/src/api/znc-types.ts +++ b/src/api/znc-types.ts @@ -1,18 +1,27 @@ export interface ZncSuccessResponse { - status: 0; + status: ZncStatus.OK; result: T; correlationId: string; } export interface ZncErrorResponse { - status: number; + status: ZncStatus | number; errorMessage: string; correlationId: string; } +export enum ZncStatus { + OK = 0, + + BAD_REQUEST = 9400, + INVALID_TOKEN = 9403, + TOKEN_EXPIRED = 9404, + UPGRADE_REQUIRED = 9427, +} export type ZncResponse = ZncSuccessResponse | ZncErrorResponse; +/** /v3/Account/Login */ export interface AccountLogin { user: CurrentUser; webApiServerCredential: { @@ -25,6 +34,9 @@ export interface AccountLogin { }; } +/** /v1/Announcement/List */ +export type Announcements = Announcement[]; + export interface Announcement { announcementId: number; priority: number; @@ -34,6 +46,7 @@ export interface Announcement { description: string; } +/** /v3/Friend/List */ export interface Friends { friends: Friend[]; } @@ -82,6 +95,9 @@ export interface Game { sysDescription: string; } +/** /v1/Game/ListWebServices */ +export type WebServices = WebService[]; + export interface WebService { id: number; uri: string; @@ -96,10 +112,48 @@ export interface WebServiceAttribute { attrKey: string; } -export interface ActiveEvent { - // ?? +/** /v1/Event/GetActiveEvent */ +export type ActiveEvent = _ActiveEvent | {}; + +export interface _ActiveEvent extends Event { + activateId: string; } +/** /v1/Event/Show */ +export interface Event { + id: number; + name: string; + description: string; + shareUri: string; + ownerUserId: number; + members: EventMember[]; + passCode: string; + eventType: 3; // ?? + allowJoinGameWithoutCoral: boolean; + game: { + id: number; + }; + imageUri: string; +} + +export interface EventMember { + id: number; + name: string; + imageUri: string; + isPlaying: boolean; + isInvited: boolean; + isJoinedVoip: boolean; +} + +/** /v3/User/Show */ +export interface User { + id: number; + nsaId: string; + imageUri: string; + name: string; +} + +/** /v3/User/ShowSelf */ export interface CurrentUser { id: number; nsaId: string; @@ -127,12 +181,22 @@ export interface CurrentUser { }; presence: Presence; } + export enum PresencePermissions { FRIENDS = 'FRIENDS', FAVORITE_FRIENDS = 'FAVORITE_FRIENDS', SELF = 'SELF', } +/** /v3/User/Permissions/ShowSelf */ +export interface CurrentUserPermissions { + etag: string; + permissions: { + presence: PresencePermissions; + }; +} + +/** /v2/Game/GetWebServiceToken */ export interface WebServiceToken { accessToken: string; expiresIn: number; diff --git a/src/api/znc.ts b/src/api/znc.ts index 900dacd..8c2b1ad 100644 --- a/src/api/znc.ts +++ b/src/api/znc.ts @@ -2,8 +2,8 @@ import fetch from 'node-fetch'; import { v4 as uuidgen } from 'uuid'; import createDebug from 'debug'; import { flapg, FlapgIid, genfc } from './f.js'; -import { AccountLogin, ActiveEvent, Announcement, CurrentUser, Friends, WebService, WebServiceToken, ZncResponse } from './znc-types.js'; -import { getNintendoAccountToken, getNintendoAccountUser } from './na.js'; +import { AccountLogin, ActiveEvent, Announcements, CurrentUser, CurrentUserPermissions, Event, Friends, PresencePermissions, User, WebServices, WebServiceToken, ZncResponse, ZncStatus } from './znc-types.js'; +import { getNintendoAccountToken, getNintendoAccountUser, NintendoAccountUser } from './na.js'; import { ErrorResponse, JwtPayload } from './util.js'; const debug = createDebug('api:znc'); @@ -20,7 +20,8 @@ export default class ZncApi { static useragent: string | null = null; constructor( - public token: string + public token: string, + public useragent: string | null = ZncApi.useragent ) {} async fetch(url: string, method = 'GET', body?: string, headers?: object) { @@ -43,7 +44,7 @@ export default class ZncApi { if ('errorMessage' in data) { throw new ErrorResponse('[znc] ' + data.errorMessage, response, data); } - if (data.status !== 0) { + if (data.status !== ZncStatus.OK) { throw new ErrorResponse('[znc] Unknown error', response, data); } @@ -51,17 +52,33 @@ export default class ZncApi { } async getAnnouncements() { - return this.fetch('/v1/Announcement/List', 'POST', '{"parameter":{}}'); + return this.fetch('/v1/Announcement/List', 'POST', '{"parameter":{}}'); } async getFriendList() { return this.fetch('/v3/Friend/List', 'POST', '{"parameter":{}}'); } + async addFavouriteFriend(nsaid: string) { + return this.fetch<{}>('/v3/Friend/Favorite/Create', 'POST', JSON.stringify({ + parameter: { + nsaId: nsaid, + }, + })); + } + + async removeFavouriteFriend(nsaid: string) { + return this.fetch<{}>('/v3/Friend/Favorite/Create', 'POST', JSON.stringify({ + parameter: { + nsaId: nsaid, + }, + })); + } + async getWebServices() { const uuid = uuidgen(); - return this.fetch('/v1/Game/ListWebServices', 'POST', JSON.stringify({ + return this.fetch('/v1/Game/ListWebServices', 'POST', JSON.stringify({ requestId: uuid, })); } @@ -70,17 +87,52 @@ export default class ZncApi { return this.fetch('/v1/Event/GetActiveEvent', 'POST', '{"parameter":{}}'); } + async getEvent(id: number) { + return this.fetch('/v1/Event/Show', 'POST', JSON.stringify({ + parameter: { + id, + }, + })); + } + + async getUser(id: number) { + return this.fetch('/v3/User/Show', 'POST', JSON.stringify({ + parameter: { + id, + }, + })); + } + async getCurrentUser() { return this.fetch('/v3/User/ShowSelf', 'POST', '{"parameter":{}}'); } + async getCurrentUserPermissions() { + return this.fetch('/v3/User/Permissions/ShowSelf', 'POST', '{"parameter":{}}'); + } + + async updateCurrentUserPermissions(to: PresencePermissions, from: PresencePermissions, etag: string) { + return this.fetch<{}>('/v3/User/Permissions/UpdateSelf', 'POST', JSON.stringify({ + parameter: { + permissions: { + presence: { + toValue: to, + fromValue: from, + }, + }, + etag, + }, + })); + } + async getWebServiceToken(id: string) { const uuid = uuidgen(); const timestamp = '' + Math.floor(Date.now() / 1000); + const useragent = this.useragent ?? undefined; const data = process.env.ZNCA_API_URL ? - await genfc(process.env.ZNCA_API_URL + '/f', this.token, timestamp, uuid, FlapgIid.APP) : - await flapg(this.token, timestamp, uuid, FlapgIid.APP); + await genfc(process.env.ZNCA_API_URL + '/f', this.token, timestamp, uuid, FlapgIid.APP, useragent) : + await flapg(this.token, timestamp, uuid, FlapgIid.APP, useragent); const req = { id, @@ -95,17 +147,43 @@ export default class ZncApi { })); } + async getToken(token: string, user: NintendoAccountUser) { + const uuid = uuidgen(); + const timestamp = '' + Math.floor(Date.now() / 1000); + + // Nintendo Account token + const nintendoAccountToken = await getNintendoAccountToken(token, ZNCA_CLIENT_ID); + + const id_token = nintendoAccountToken.id_token; + const useragent = this.useragent ?? undefined; + const data = process.env.ZNCA_API_URL ? + await genfc(process.env.ZNCA_API_URL + '/f', id_token, timestamp, uuid, FlapgIid.NSO, useragent) : + await flapg(id_token, timestamp, uuid, FlapgIid.NSO, useragent); + + const req = { + naBirthday: user.birthday, + timestamp, + f: data.f, + requestId: uuid, + naIdToken: this.token, + }; + + return this.fetch('/v3/Account/GetToken', 'POST', JSON.stringify({ + parameter: req, + })); + } + static async createWithSessionToken(token: string, useragent = ZncApi.useragent) { const data = await this.loginWithSessionToken(token, useragent); return { - nso: new this(data.credential.accessToken), + nso: new this(data.credential.accessToken, useragent), data, }; } - async renewToken(token: string, useragent = ZncApi.useragent) { - const data = await ZncApi.loginWithSessionToken(token, useragent); + async renewToken(token: string) { + const data = await ZncApi.loginWithSessionToken(token, this.useragent); this.token = data.credential.accessToken; diff --git a/src/cli/nso/http-server.ts b/src/cli/nso/http-server.ts index 8e60ac3..2185170 100644 --- a/src/cli/nso/http-server.ts +++ b/src/cli/nso/http-server.ts @@ -343,6 +343,24 @@ export async function handler(argv: ArgumentsCamelCase) { })); }); + app.get('/api/znc/friends/favourites', authToken, (req, res, next) => { + if (!req.zncAuthPolicy) return next(); + if (!req.zncAuthPolicy.list_friends) return tokenUnauthorised(req, res); + next(); + }, localAuth, nsoAuth, getFriendsData, async (req, res) => { + const [friends, updated] = cached_friendsdata.get(req.zncAuth!.user.id)!; + + res.setHeader('Content-Type', 'application/json'); + res.end(JSON.stringify({ + friends: friends.filter(f => { + if (req.zncAuthPolicy?.friends && !req.zncAuthPolicy.friends.includes(f.nsaId)) return false; + + return f.isFavoriteFriend; + }), + updated, + })); + }); + app.get('/api/znc/friends/presence', authToken, (req, res, next) => { if (!req.zncAuthPolicy) return next(); if (!req.zncAuthPolicy.list_friends_presence) return tokenUnauthorised(req, res); @@ -365,6 +383,30 @@ export async function handler(argv: ArgumentsCamelCase) { res.end(JSON.stringify(presence)); }); + app.get('/api/znc/friends/favourites/presence', authToken, (req, res, next) => { + if (!req.zncAuthPolicy) return next(); + if (!req.zncAuthPolicy.list_friends_presence) return tokenUnauthorised(req, res); + next(); + }, localAuth, nsoAuth, getFriendsData, async (req, res) => { + const [friends, updated] = cached_friendsdata.get(req.zncAuth!.user.id)!; + const presence: Record = {}; + + for (const friend of friends) { + if (req.zncAuthPolicy) { + const p = req.zncAuthPolicy; + if (p.friends_presence && !p.friends_presence.includes(friend.nsaId)) continue; + if (p.friends && !p.friends_presence && !p.friends.includes(friend.nsaId)) continue; + } + + if (!friend.isFavoriteFriend) continue; + + presence[friend.nsaId] = friend.presence; + } + + res.setHeader('Content-Type', 'application/json'); + res.end(JSON.stringify(presence)); + }); + app.get('/api/znc/friend/:nsaid', authToken, (req, res, next) => { if (!req.zncAuthPolicy) return next(); if (!req.zncAuthPolicy.friend) return tokenUnauthorised(req, res); @@ -388,6 +430,56 @@ export async function handler(argv: ArgumentsCamelCase) { res.end(JSON.stringify({friend, updated})); }); + app.post('/api/znc/friend/:nsaid', nsoAuth, getFriendsData, bodyParser.json(), async (req, res) => { + const [friends, updated] = cached_friendsdata.get(req.zncAuth!.user.id)!; + const friend = friends.find(f => f.nsaId === req.params.nsaid); + + if (!friend) { + res.statusCode = 404; + res.setHeader('Content-Type', 'application/json'); + res.end(JSON.stringify({ + error: 'not_found', + error_message: 'The user is not friends with the authenticated user.', + })); + return; + } + + if ('isFavoriteFriend' in req.body && + req.body.isFavoriteFriend !== true && + req.body.isFavoriteFriend !== false + ) { + res.statusCode = 400; + res.setHeader('Content-Type', 'application/json'); + res.end(JSON.stringify({ + error: 'invalid_request', + error_message: 'Invalid value for isFavoriteFriend', + })); + return; + } + + if ('isFavoriteFriend' in req.body) { + try { + if (friend.isFavoriteFriend !== req.body.isFavoriteFriend) { + if (req.body.isFavoriteFriend) await req.znc!.addFavouriteFriend(friend.nsaId); + if (!req.body.isFavoriteFriend) await req.znc!.removeFavouriteFriend(friend.nsaId); + } else { + // No change + } + } catch (err) { + res.statusCode = 500; + res.setHeader('Content-Type', 'application/json'); + res.end(JSON.stringify({ + error: err, + error_message: (err as Error).message, + })); + return; + } + } + + res.statusCode = 204; + res.end(); + }); + app.get('/api/znc/friend/:nsaid/presence', authToken, (req, res, next) => { if (!req.zncAuthPolicy) return next(); if (!req.zncAuthPolicy.friend_presence) return tokenUnauthorised(req, res); @@ -452,6 +544,42 @@ export async function handler(argv: ArgumentsCamelCase) { res.end(JSON.stringify({activeevent, updated})); }); + app.get('/api/znc/event/:id', nsoAuth, async (req, res) => { + try { + const response = await req.znc!.getEvent(parseInt(req.params.id)); + + res.setHeader('Content-Type', 'application/json'); + res.end(JSON.stringify({ + event: response.result, + })); + } catch (err) { + res.statusCode = 500; + res.setHeader('Content-Type', 'application/json'); + res.end(JSON.stringify({ + error: err, + error_message: (err as Error).message, + })); + } + }); + + app.get('/api/znc/user/:id', nsoAuth, async (req, res) => { + try { + const response = await req.znc!.getUser(parseInt(req.params.id)); + + res.setHeader('Content-Type', 'application/json'); + res.end(JSON.stringify({ + user: response.result, + })); + } catch (err) { + res.statusCode = 500; + res.setHeader('Content-Type', 'application/json'); + res.end(JSON.stringify({ + error: err, + error_message: (err as Error).message, + })); + } + }); + // // Nintendo Switch user data //