Add parental controls

This commit is contained in:
Samuel Elliott
2022-03-16 20:58:15 +00:00
parent 1b71e8b8fe
commit 3919ceeb21
23 changed files with 1137 additions and 32 deletions

View File

@@ -4,6 +4,7 @@ import * as crypto from 'crypto';
import type { Arguments as ParentArguments } from '../cli.js';
import { ArgumentsCamelCase, Argv, getToken, initStorage, YargsArguments } from '../util.js';
import { getNintendoAccountSessionToken } from '../api/na.js';
import { ZNCA_CLIENT_ID } from '../api/znc.js';
const debug = createDebug('cli:auth');
@@ -31,7 +32,7 @@ export async function handler(argv: ArgumentsCamelCase<Arguments>) {
const params = {
state,
redirect_uri: 'npf71b963c1b7b6d119://auth',
client_id: '71b963c1b7b6d119',
client_id: ZNCA_CLIENT_ID,
scope: 'openid user user.birthday user.mii user.screenName',
response_type: 'session_token_code',
session_token_code_challenge: challenge,
@@ -71,8 +72,8 @@ export async function handler(argv: ArgumentsCamelCase<Arguments>) {
const authorisedparams = new URLSearchParams(authorisedurl.hash.substr(1));
debug('Redirect URL parameters', [...authorisedparams.entries()]);
const token = await getNintendoAccountSessionToken(
authorisedparams.get('session_token_code')!, verifier, '71b963c1b7b6d119');
const code = authorisedparams.get('session_token_code')!;
const token = await getNintendoAccountSessionToken(code, verifier, ZNCA_CLIENT_ID);
console.log('Session token', token);

View File

@@ -3,7 +3,7 @@ import createDebug from 'debug';
import Table from 'cli-table/lib/index.js';
import { PresenceState } from '../api/znc-types.js';
import type { Arguments as ParentArguments } from '../cli.js';
import { ArgumentsCamelCase, Argv, getToken, initStorage, YargsArguments } from '../util.js';
import { ArgumentsCamelCase, Argv, getToken, hrduration, initStorage, YargsArguments } from '../util.js';
const debug = createDebug('cli:friends');
@@ -67,8 +67,6 @@ export async function handler(argv: ArgumentsCamelCase<Arguments>) {
for (const friend of friends.result.friends) {
const online = friend.presence.state === PresenceState.ONLINE ||
friend.presence.state === PresenceState.PLAYING;
const hours = 'name' in friend.presence.game ? Math.floor(friend.presence.game.totalPlayTime / 60) : 0;
const minutes = 'name' in friend.presence.game ? friend.presence.game.totalPlayTime - (hours * 60) : 0;
table.push([
friend.id,
@@ -76,10 +74,9 @@ export async function handler(argv: ArgumentsCamelCase<Arguments>) {
friend.name,
online ?
'name' in friend.presence.game ?
'Playing ' + friend.presence.game.name +
'; played for ' + (hours || !minutes ? hours + ' hour' + (hours === 1 ? '' : 's') : '') +
(minutes ? ', ' + minutes + ' minute' + (minutes === 1 ? '' : 's'): '') +
' since ' + new Date(friend.presence.game.firstPlayedAt * 1000).toLocaleDateString('en-GB') :
'Playing ' + friend.presence.game.name + '; played for ' +
hrduration(friend.presence.game.totalPlayTime) + ' since ' +
new Date(friend.presence.game.firstPlayedAt * 1000).toLocaleDateString('en-GB') :
'Online' :
friend.presence.logoutAt ?
'Last seen ' + new Date(friend.presence.logoutAt * 1000).toISOString() :

View File

@@ -9,3 +9,4 @@ export * as friends from './friends.js';
export * as presence from './presence.js';
export * as notify from './notify.js';
export * as httpServer from './http-server.js';
export * as pctl from './pctl.js';

16
src/cli/pctl.ts Normal file
View File

@@ -0,0 +1,16 @@
import createDebug from 'debug';
import type { Arguments as ParentArguments } from '../cli.js';
import { Argv } from '../util.js';
import * as commands from './pctl/index.js';
const debug = createDebug('cli:pctl');
export const command = 'pctl <command>';
export const desc = 'Nintendo Switch Parental Controls';
export function builder(yargs: Argv<ParentArguments>) {
for (const command of Object.values(commands)) {
// @ts-expect-error
yargs.command(command);
}
}

114
src/cli/pctl/auth.ts Normal file
View File

@@ -0,0 +1,114 @@
import * as util from 'util';
import createDebug from 'debug';
import * as crypto from 'crypto';
import type { Arguments as ParentArguments } from '../../cli.js';
import { ArgumentsCamelCase, Argv, getPctlToken, initStorage, YargsArguments } from '../../util.js';
import { getNintendoAccountSessionToken } from '../../api/na.js';
import { MOON_CLIENT_ID } from '../../api/moon.js';
const debug = createDebug('cli:pctl:auth');
export const command = 'auth';
export const desc = 'Generate a link to login to a Nintendo Account';
export function builder(yargs: Argv<ParentArguments>) {
return yargs.option('auth', {
describe: 'Authenticate immediately',
type: 'boolean',
default: true,
}).option('select', {
describe: 'Set as default user (default: true if only user)',
type: 'boolean',
});
}
type Arguments = YargsArguments<ReturnType<typeof builder>>;
export async function handler(argv: ArgumentsCamelCase<Arguments>) {
const state = crypto.randomBytes(36).toString('base64url');
const verifier = crypto.randomBytes(32).toString('base64url');
const challenge = crypto.createHash('sha256').update(verifier).digest().toString('base64url');
const params = {
state,
redirect_uri: 'npf54789befb391a838://auth',
client_id: MOON_CLIENT_ID,
scope: [
'openid',
'user',
'user.mii',
'moonUser:administration',
'moonDevice:create',
'moonOwnedDevice:administration',
'moonParentalControlSetting',
'moonParentalControlSetting:update',
'moonParentalControlSettingState',
'moonPairingState',
'moonSmartDevice:administration',
'moonDailySummary',
'moonMonthlySummary',
].join(' '),
response_type: 'session_token_code',
session_token_code_challenge: challenge,
session_token_code_challenge_method: 'S256',
};
const authoriseurl = 'https://accounts.nintendo.com/connect/1.0.0/authorize?' +
new URLSearchParams(params).toString();
debug('Authentication parameters', {
state,
verifier,
challenge,
}, params);
console.log('1. Open this URL and login to your Nintendo Account:');
console.log('');
console.log(authoriseurl);
console.log('');
console.log('2. On the "Linking an External Account" page, right click "Select this person" and copy the link. It should start with "npf54789befb391a838://auth".');
console.log('');
const read = await import('read');
// @ts-expect-error
const prompt = util.promisify(read.default as typeof read);
const applink = await prompt({
prompt: `Paste the link: `,
// silent: true,
output: process.stderr,
});
console.log('');
const authorisedurl = new URL(applink);
const authorisedparams = new URLSearchParams(authorisedurl.hash.substr(1));
debug('Redirect URL parameters', [...authorisedparams.entries()]);
const code = authorisedparams.get('session_token_code')!;
const token = await getNintendoAccountSessionToken(code, verifier, MOON_CLIENT_ID);
console.log('Session token', token);
if (argv.auth) {
const storage = await initStorage(argv.dataPath);
const {moon, data} = await getPctlToken(storage, token.session_token);
console.log('Authenticated as Nintendo Account %s (%s)',
data.user.nickname, data.user.id);
await storage.setItem('NintendoAccountToken-pctl.' + data.user.id, token.session_token);
const users = new Set(await storage.getItem('NintendoAccountIds') ?? []);
users.add(data.user.id);
await storage.setItem('NintendoAccountIds', [...users]);
if ('select' in argv ? argv.select : users.size === 1) {
await storage.setItem('SelectedUser', data.user.id);
console.log('Set as default user');
}
}
}

View File

@@ -0,0 +1,79 @@
import createDebug from 'debug';
// @ts-expect-error
import Table from 'cli-table/lib/index.js';
import type { Arguments as ParentArguments } from '../../cli.js';
import { ArgumentsCamelCase, Argv, getPctlToken, hrduration, initStorage, YargsArguments } from '../../util.js';
const debug = createDebug('cli:pctl:daily-summaries');
export const command = 'daily-summaries <device>';
export const desc = 'Show daily summaries';
export function builder(yargs: Argv<ParentArguments>) {
return yargs.positional('device', {
describe: 'Nintendo Switch device ID',
type: 'string',
demandOption: true,
}).option('user', {
describe: 'Nintendo Account ID',
type: 'string',
}).option('token', {
describe: 'Nintendo Account session token',
type: 'string',
}).option('json', {
describe: 'Output raw JSON',
type: 'boolean',
}).option('json-pretty-print', {
describe: 'Output pretty-printed JSON',
type: 'boolean',
});
}
type Arguments = YargsArguments<ReturnType<typeof builder>>;
export async function handler(argv: ArgumentsCamelCase<Arguments>) {
const storage = await initStorage(argv.dataPath);
const usernsid = argv.user ?? await storage.getItem('SelectedUser');
const token: string = argv.token ||
await storage.getItem('NintendoAccountToken-pctl.' + usernsid);
const {moon, data} = await getPctlToken(storage, token);
const summaries = await moon.getDailySummaries(argv.device);
if (argv.jsonPrettyPrint) {
console.log(JSON.stringify(summaries, null, 4));
return;
}
if (argv.json) {
console.log(JSON.stringify(summaries));
return;
}
const table = new Table({
head: [
'Date',
'Status',
'Play time',
'Misc. time',
'Titles played',
'Users played',
'Notices',
],
});
for (const summary of summaries.items) {
table.push([
summary.date,
summary.result,
hrduration(summary.playingTime / 60, true),
hrduration(summary.miscTime / 60, true),
summary.playedApps.map(t => t.title).join('\n'),
summary.devicePlayers.map(p => p.nickname)
.concat(summary.anonymousPlayer ? ['Unknown user'] : []).join('\n'),
[...summary.importantInfos, ...summary.observations.map(o => o.type)].join('\n'),
]);
}
console.log(table.toString());
}

59
src/cli/pctl/devices.ts Normal file
View File

@@ -0,0 +1,59 @@
import createDebug from 'debug';
// @ts-expect-error
import Table from 'cli-table/lib/index.js';
import type { Arguments as ParentArguments } from '../../cli.js';
import { ArgumentsCamelCase, Argv, getPctlToken, initStorage, YargsArguments } from '../../util.js';
const debug = createDebug('cli:pctl:devices');
export const command = 'devices';
export const desc = 'List Nintendo Switch consoles';
export function builder(yargs: Argv<ParentArguments>) {
return yargs.option('user', {
describe: 'Nintendo Account ID',
type: 'string',
}).option('token', {
describe: 'Nintendo Account session token',
type: 'string',
});
}
type Arguments = YargsArguments<ReturnType<typeof builder>>;
export async function handler(argv: ArgumentsCamelCase<Arguments>) {
console.warn('Listing devices');
const storage = await initStorage(argv.dataPath);
const usernsid = argv.user ?? await storage.getItem('SelectedUser');
const token: string = argv.token ||
await storage.getItem('NintendoAccountToken-pctl.' + usernsid);
const {moon, data} = await getPctlToken(storage, token);
const devices = await moon.getDevices();
const table = new Table({
head: [
'ID',
'Label',
'Serial number',
'Software version',
'PIN',
'Last synchronised',
],
});
for (const device of devices.items) {
table.push([
device.deviceId,
device.label,
device.device.serialNumber,
device.device.firmwareVersion.displayedVersion + ' (' + device.device.firmwareVersion.internalVersion + ')',
device.device.synchronizedUnlockCode,
new Date(device.device.synchronizedParentalControlSetting.synchronizedAt * 1000).toISOString(),
]);
}
console.log(table.toString());
}

View File

@@ -0,0 +1,95 @@
import createDebug from 'debug';
import * as fs from 'fs/promises';
import mkdirp from 'mkdirp';
import type { Arguments as ParentArguments } from '../../cli.js';
import { ArgumentsCamelCase, Argv, getPctlToken, initStorage, YargsArguments } from '../../util.js';
import * as path from 'path';
import { DailySummaryResult } from '../../api/moon-types.js';
import MoonApi from '../../api/moon.js';
const debug = createDebug('cli:pctl:dump-summaries');
export const command = 'dump-summaries <directory>';
export const desc = 'Download all daily and monthly summaries';
export function builder(yargs: Argv<ParentArguments>) {
return yargs.positional('directory', {
describe: 'Directory to write summary data to',
type: 'string',
demandOption: true,
}).option('user', {
describe: 'Nintendo Account ID',
type: 'string',
}).option('token', {
describe: 'Nintendo Account session token',
type: 'string',
}).option('device', {
describe: 'Nintendo Switch device ID',
type: 'array',
});
}
type Arguments = YargsArguments<ReturnType<typeof builder>>;
export async function handler(argv: ArgumentsCamelCase<Arguments>) {
const storage = await initStorage(argv.dataPath);
const usernsid = argv.user ?? await storage.getItem('SelectedUser');
const token: string = argv.token ||
await storage.getItem('NintendoAccountToken-pctl.' + usernsid);
const {moon, data} = await getPctlToken(storage, token);
await mkdirp(argv.directory);
const devices = await moon.getDevices();
for (const device of argv.device ?? devices.items.map(d => d.deviceId)) {
console.warn('Downloading summaries for device %s', device);
await dumpMonthlySummariesForDevice(moon, argv.directory, '' + device);
await dumpDailySummariesForDevice(moon, argv.directory, '' + device);
}
}
async function dumpMonthlySummariesForDevice(moon: MoonApi, directory: string, device: string) {
debug('Fetching monthly summaries for device %s', device);
const monthlySummaries = await moon.getMonthlySummaries(device);
for (const item of monthlySummaries.items) {
const filename = 'pctl-monthly-' + item.deviceId + '-' + item.month + '.json';
const file = path.join(directory, filename);
try {
await fs.stat(file);
debug('Skipping monthly summary %s for device %s, file already exists', item.month, item.deviceId);
continue;
} catch (err) {}
debug('Fetching monthly summary %s for device %s', item.month, item.deviceId);
const summary = await moon.getMonthlySummary(item.deviceId, item.month);
debug('Writing %s', filename);
await fs.writeFile(file, JSON.stringify(summary, null, 4) + '\n', 'utf-8');
}
}
async function dumpDailySummariesForDevice(moon: MoonApi, directory: string, device: string) {
debug('Fetching daily summaries for device %s', device);
const summaries = await moon.getDailySummaries(device);
const timestamp = Date.now();
for (const summary of summaries.items) {
const filename = 'pctl-daily-' + summary.deviceId + '-' + summary.date +
(summary.result === DailySummaryResult.ACHIEVED ? '' : '-' + timestamp) + '.json';
const file = path.join(directory, filename);
try {
await fs.stat(file);
debug('Skipping daily summary %s for device %s, file already exists', summary.date, summary.deviceId);
continue;
} catch (err) {}
debug('Writing %s', filename);
await fs.writeFile(file, JSON.stringify(summary, null, 4) + '\n', 'utf-8');
}
}

8
src/cli/pctl/index.ts Normal file
View File

@@ -0,0 +1,8 @@
export * as token from './token.js';
export * as auth from './auth.js';
export * as devices from './devices.js';
export * as dailySummaries from './daily-summaries.js';
export * as monthlySummaries from './monthly-summaries.js';
export * as monthlySummary from './monthly-summary.js';
export * as settings from './settings.js';
export * as dumpSummaries from './dump-summaries.js';

View File

@@ -0,0 +1,51 @@
import createDebug from 'debug';
// @ts-expect-error
import Table from 'cli-table/lib/index.js';
import type { Arguments as ParentArguments } from '../../cli.js';
import { ArgumentsCamelCase, Argv, getPctlToken, initStorage, YargsArguments } from '../../util.js';
const debug = createDebug('cli:pctl:monthly-summaries');
export const command = 'monthly-summaries <device>';
export const desc = 'List monthly summaries';
export function builder(yargs: Argv<ParentArguments>) {
return yargs.positional('device', {
describe: 'Nintendo Switch device ID',
type: 'string',
demandOption: true,
}).option('user', {
describe: 'Nintendo Account ID',
type: 'string',
}).option('token', {
describe: 'Nintendo Account session token',
type: 'string',
});
}
type Arguments = YargsArguments<ReturnType<typeof builder>>;
export async function handler(argv: ArgumentsCamelCase<Arguments>) {
const storage = await initStorage(argv.dataPath);
const usernsid = argv.user ?? await storage.getItem('SelectedUser');
const token: string = argv.token ||
await storage.getItem('NintendoAccountToken-pctl.' + usernsid);
const {moon, data} = await getPctlToken(storage, token);
const summaries = await moon.getMonthlySummaries(argv.device);
const table = new Table({
head: [
'Month',
],
});
for (const summary of summaries.items) {
table.push([
summary.month,
]);
}
console.log(table.toString());
}

View File

@@ -0,0 +1,76 @@
import createDebug from 'debug';
// @ts-expect-error
import Table from 'cli-table/lib/index.js';
import type { Arguments as ParentArguments } from '../../cli.js';
import { ArgumentsCamelCase, Argv, getPctlToken, initStorage, YargsArguments } from '../../util.js';
const debug = createDebug('cli:pctl:monthly-summary');
export const command = 'monthly-summary <device> <month>';
export const desc = 'Show monthly summary data';
export function builder(yargs: Argv<ParentArguments>) {
return yargs.positional('device', {
describe: 'Nintendo Switch device ID',
type: 'string',
demandOption: true,
}).positional('month', {
describe: 'Report month',
type: 'string',
demandOption: true,
}).option('user', {
describe: 'Nintendo Account ID',
type: 'string',
}).option('token', {
describe: 'Nintendo Account session token',
type: 'string',
}).option('json', {
describe: 'Output raw JSON',
type: 'boolean',
}).option('json-pretty-print', {
describe: 'Output pretty-printed JSON',
type: 'boolean',
});
}
type Arguments = YargsArguments<ReturnType<typeof builder>>;
export async function handler(argv: ArgumentsCamelCase<Arguments>) {
const storage = await initStorage(argv.dataPath);
const usernsid = argv.user ?? await storage.getItem('SelectedUser');
const token: string = argv.token ||
await storage.getItem('NintendoAccountToken-pctl.' + usernsid);
const {moon, data} = await getPctlToken(storage, token);
const summary = await moon.getMonthlySummary(argv.device, argv.month);
if (argv.jsonPrettyPrint) {
console.log(JSON.stringify(summary, null, 4));
return;
}
if (argv.json) {
console.log(JSON.stringify(summary));
return;
}
const titles = new Table({
head: [
'Title',
'First played',
'Days',
'Ranking',
],
});
for (const title of summary.playedApps) {
titles.push([
title.title,
title.firstPlayDate,
title.playingDays,
title.position,
]);
}
console.log(titles.toString());
}

37
src/cli/pctl/settings.ts Normal file
View File

@@ -0,0 +1,37 @@
import createDebug from 'debug';
import type { Arguments as ParentArguments } from '../../cli.js';
import { ArgumentsCamelCase, Argv, getPctlToken, initStorage, YargsArguments } from '../../util.js';
const debug = createDebug('cli:pctl:settings');
export const command = 'settings <device>';
export const desc = 'Show parental control setting state';
export function builder(yargs: Argv<ParentArguments>) {
return yargs.positional('device', {
describe: 'Nintendo Switch device ID',
type: 'string',
demandOption: true,
}).option('user', {
describe: 'Nintendo Account ID',
type: 'string',
}).option('token', {
describe: 'Nintendo Account session token',
type: 'string',
});
}
type Arguments = YargsArguments<ReturnType<typeof builder>>;
export async function handler(argv: ArgumentsCamelCase<Arguments>) {
const storage = await initStorage(argv.dataPath);
const usernsid = argv.user ?? await storage.getItem('SelectedUser');
const token: string = argv.token ||
await storage.getItem('NintendoAccountToken-pctl.' + usernsid);
const {moon, data} = await getPctlToken(storage, token);
const d = await moon.getParentalControlSettingState(argv.device);
console.log(d);
}

56
src/cli/pctl/token.ts Normal file
View File

@@ -0,0 +1,56 @@
import * as util from 'util';
import createDebug from 'debug';
import type { Arguments as ParentArguments } from '../../cli.js';
import { ArgumentsCamelCase, Argv, getPctlToken, initStorage, YargsArguments } from '../../util.js';
const debug = createDebug('cli:pctl:token');
export const command = 'token [token]';
export const desc = 'Authenticate with a Nintendo Account session token';
export function builder(yargs: Argv<ParentArguments>) {
return yargs.positional('token', {
describe: 'Nintendo Account session token (it is recommended this is not set and you enter it interactively)',
type: 'string',
}).option('select', {
describe: 'Set as default user (default: true if only user)',
type: 'boolean',
});
}
type Arguments = YargsArguments<ReturnType<typeof builder>>;
export async function handler(argv: ArgumentsCamelCase<Arguments>) {
const storage = await initStorage(argv.dataPath);
if (!argv.token) {
const read = await import('read');
// @ts-expect-error
const prompt = util.promisify(read.default as typeof read);
argv.token = await prompt({
prompt: `Token: `,
silent: true,
output: process.stderr,
});
}
const {moon, data} = await getPctlToken(storage, argv.token);
console.warn('Authenticated as Nintendo Account %s (%s)',
data.user.nickname, data.user.id);
await storage.setItem('NintendoAccountToken-pctl.' + data.user.id, argv.token);
const users = new Set(await storage.getItem('NintendoAccountIds') ?? []);
users.add(data.user.id);
await storage.setItem('NintendoAccountIds', [...users]);
console.log('Saved token');
if ('select' in argv ? argv.select : users.size === 1) {
await storage.setItem('SelectedUser', data.user.id);
console.log('Set as default user');
}
}

31
src/cli/pctl/user.ts Normal file
View File

@@ -0,0 +1,31 @@
import createDebug from 'debug';
import type { Arguments as ParentArguments } from '../../cli.js';
import { ArgumentsCamelCase, Argv, getPctlToken, initStorage, YargsArguments } from '../../util.js';
const debug = createDebug('cli:pctl:user');
export const command = 'user';
export const desc = 'Get the authenticated Nintendo Account';
export function builder(yargs: Argv<ParentArguments>) {
return yargs.option('user', {
describe: 'Nintendo Account ID',
type: 'string',
}).option('token', {
describe: 'Nintendo Account session token',
type: 'string',
});
}
type Arguments = YargsArguments<ReturnType<typeof builder>>;
export async function handler(argv: ArgumentsCamelCase<Arguments>) {
const storage = await initStorage(argv.dataPath);
const usernsid = argv.user ?? await storage.getItem('SelectedUser');
const token: string = argv.token ||
await storage.getItem('NintendoAccountToken-pctl.' + usernsid);
const {moon, data} = await getPctlToken(storage, token);
console.log('Nintendo Account', data.user);
}

View File

@@ -2,7 +2,7 @@ import createDebug from 'debug';
// @ts-expect-error
import Table from 'cli-table/lib/index.js';
import type { Arguments as ParentArguments } from '../cli.js';
import { Argv, initStorage, SavedToken } from '../util.js';
import { Argv, initStorage, SavedMoonToken, SavedToken } from '../util.js';
const debug = createDebug('cli:users');
@@ -24,22 +24,27 @@ export function builder(yargs: Argv<ParentArguments>) {
'Country',
'Nintendo Switch ID',
'Nintendo Switch username',
'Parental Controls',
],
});
for (const userid of users ?? []) {
const token: string | undefined = await storage.getItem('NintendoAccountToken.' + userid);
if (!token) continue;
const cache: SavedToken | undefined = await storage.getItem('NsoToken.' + token);
if (!cache) continue;
const nsoCache: SavedToken | undefined = token ? await storage.getItem('NsoToken.' + token) : undefined;
const moonToken: string | undefined = await storage.getItem('NintendoAccountToken-pctl.' + userid);
const moonCache: SavedMoonToken | undefined = moonToken ? await storage.getItem('MoonToken.' + moonToken) : undefined;
const user = nsoCache?.user ?? moonCache?.user;
if (!user) continue;
table.push([
cache.user.id + (selected === cache.user.id ? ' *' : ''),
cache.user.screenName,
cache.user.nickname,
cache.user.country,
cache.nsoAccount.user.nsaId,
cache.nsoAccount.user.name,
user.id + (selected === user.id ? ' *' : ''),
user.screenName,
user.nickname,
user.country,
nsoCache?.nsoAccount.user.nsaId ?? 'Not signed in',
nsoCache?.nsoAccount.user.name ?? 'Not signed in',
moonCache ? 'Signed in' : 'Not signed in',
]);
}