@msout/microsoft-webauth 0.0.7 → 0.0.9
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/README.md +38 -2
- package/package.json +1 -1
- package/src/auth.js +110 -25
- package/src/config.js +31 -9
- package/src/index.js +8 -5
package/README.md
CHANGED
|
@@ -2,7 +2,8 @@
|
|
|
2
2
|
|
|
3
3
|
A tool to authenticate against microsoft online (live or professionnal tenants)
|
|
4
4
|
We do not rely on GraphAPI.
|
|
5
|
-
|
|
5
|
+
|
|
6
|
+
e.g:
|
|
6
7
|
- https://onenote.cloud.microsoft/notebooks
|
|
7
8
|
- https://outlook.live.com/mail/
|
|
8
9
|
- and by extension https://login.microsoft.com
|
|
@@ -18,6 +19,13 @@ This is a standalone CLI tool for authenticating with Microsoft accounts using P
|
|
|
18
19
|
- MFA/2FA support (OTC codes, number matching)
|
|
19
20
|
- Session persistence
|
|
20
21
|
|
|
22
|
+
## Why this project ?
|
|
23
|
+
|
|
24
|
+
While this let you authenticate this is a part of a bigger purpose,
|
|
25
|
+
primary aim is to offer people a simple way to get out of Microsoft OneNote, because you regardless of what ms documentation states
|
|
26
|
+
=> https://learn.microsoft.com/en-us/answers/questions/2276682/onenote-api-fails-with-large-sharepoint-document-l
|
|
27
|
+
|
|
28
|
+
in essence you want to search for microsoft-onenote-list-notebook, microsoft-onenote-exporter
|
|
21
29
|
|
|
22
30
|
## Available on npmjs
|
|
23
31
|
|
|
@@ -43,6 +51,12 @@ npm install @msout/microsoft-webauth
|
|
|
43
51
|
microsoft-webauth login --email your@email.com --password yourpassword
|
|
44
52
|
```
|
|
45
53
|
|
|
54
|
+
With custom auth file path:
|
|
55
|
+
|
|
56
|
+
```bash
|
|
57
|
+
microsoft-webauth login --email your@email.com --password yourpassword --auth-file /path/to/authfile.json
|
|
58
|
+
```
|
|
59
|
+
|
|
46
60
|
### Login (Manual/Interactive)
|
|
47
61
|
|
|
48
62
|
```bash
|
|
@@ -51,18 +65,36 @@ microsoft-webauth login
|
|
|
51
65
|
|
|
52
66
|
This will open a browser window. Log in manually, then press Enter when you see the notebooks list.
|
|
53
67
|
|
|
68
|
+
With custom auth file path:
|
|
69
|
+
|
|
70
|
+
```bash
|
|
71
|
+
microsoft-webauth login --auth-file /path/to/authfile.json
|
|
72
|
+
```
|
|
73
|
+
|
|
54
74
|
### Check Authentication Status
|
|
55
75
|
|
|
56
76
|
```bash
|
|
57
77
|
microsoft-webauth check
|
|
58
78
|
```
|
|
59
79
|
|
|
80
|
+
With custom auth file path:
|
|
81
|
+
|
|
82
|
+
```bash
|
|
83
|
+
microsoft-webauth check --auth-file /path/to/authfile.json
|
|
84
|
+
```
|
|
85
|
+
|
|
60
86
|
### Logout
|
|
61
87
|
|
|
62
88
|
```bash
|
|
63
89
|
microsoft-webauth logout
|
|
64
90
|
```
|
|
65
91
|
|
|
92
|
+
With custom auth file path:
|
|
93
|
+
|
|
94
|
+
```bash
|
|
95
|
+
microsoft-webauth logout --auth-file /path/to/authfile.json
|
|
96
|
+
```
|
|
97
|
+
|
|
66
98
|
## Options
|
|
67
99
|
|
|
68
100
|
| Option | Description |
|
|
@@ -71,13 +103,16 @@ microsoft-webauth logout
|
|
|
71
103
|
| `--password <password>` | Microsoft account password (for automated login) |
|
|
72
104
|
| `--notheadless` | Run in visible browser mode (disable headless) |
|
|
73
105
|
| `--dodump` | Dump HTML content to files for debugging |
|
|
106
|
+
| `--auth-file <path>` | Path to auth file (default: ~/.microsoft-webauth/auth-file.json) |
|
|
74
107
|
|
|
75
108
|
## Output
|
|
76
109
|
|
|
77
|
-
Authentication state is saved to
|
|
110
|
+
Authentication state is saved to the specified auth file path. By default, it uses `~/.microsoft-webauth/auth-file.json`. A metadata file `{auth-file-prefix}-meta.json` stores:
|
|
78
111
|
- Email used for login
|
|
79
112
|
- Login timestamp
|
|
80
113
|
|
|
114
|
+
When files already exist, they are automatically backed up with `.old` suffix. If `.old` files already exist, a warning is displayed before overwriting.
|
|
115
|
+
|
|
81
116
|
## Testing
|
|
82
117
|
|
|
83
118
|
```bash
|
|
@@ -108,3 +143,4 @@ microsoft-webauth-playwright/
|
|
|
108
143
|
## License
|
|
109
144
|
|
|
110
145
|
ISC — same as MSOneNote Exporter.
|
|
146
|
+
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@msout/microsoft-webauth",
|
|
3
|
-
"version": "0.0.
|
|
3
|
+
"version": "0.0.9",
|
|
4
4
|
"description": "Microsoft web authentication module, using playwright to automate the login process and retrieve cookies for authenticated sessions.",
|
|
5
5
|
"main": "src/index.js",
|
|
6
6
|
"bin": {
|
package/src/auth.js
CHANGED
|
@@ -6,27 +6,94 @@
|
|
|
6
6
|
const { chromium } = require('playwright');
|
|
7
7
|
const fs = require('fs-extra');
|
|
8
8
|
const logger = require('./utils/logger');
|
|
9
|
-
const {
|
|
9
|
+
const { DEFAULT_AUTH_FILE, getAuthMetaFilePath, ensureAuthDir, ONENOTE_URL } = require('./config');
|
|
10
10
|
const path = require('path');
|
|
11
11
|
const readline = require('readline');
|
|
12
12
|
|
|
13
|
-
|
|
14
|
-
|
|
13
|
+
/** Returns the auth file path, defaulting to DEFAULT_AUTH_FILE */
|
|
14
|
+
function getAuthFilePath(authFilePath) {
|
|
15
|
+
return authFilePath || DEFAULT_AUTH_FILE;
|
|
16
|
+
}
|
|
15
17
|
|
|
16
18
|
/** Returns { email, loginTime } from auth-meta.json, or null if not found. */
|
|
17
|
-
async function getAuthMeta() {
|
|
19
|
+
async function getAuthMeta(authFilePath) {
|
|
20
|
+
const filePath = getAuthFilePath(authFilePath);
|
|
21
|
+
const metaPath = getAuthMetaFilePath(filePath);
|
|
18
22
|
try {
|
|
19
|
-
if (await fs.pathExists(
|
|
20
|
-
return await fs.readJson(
|
|
23
|
+
if (await fs.pathExists(metaPath)) {
|
|
24
|
+
return await fs.readJson(metaPath);
|
|
21
25
|
}
|
|
22
26
|
} catch (e) { }
|
|
23
27
|
return null;
|
|
24
28
|
}
|
|
25
29
|
|
|
30
|
+
/** Generates backup path by appending .old to file */
|
|
31
|
+
function getBackupPath(filePath) {
|
|
32
|
+
return filePath + '.old';
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Checks if files exist and handles backup logic
|
|
37
|
+
* Returns object with { authFileExists, metaFileExists, willOverwriteOld }
|
|
38
|
+
*/
|
|
39
|
+
async function checkAndPrepareFiles(authFilePath) {
|
|
40
|
+
const metaFilePath = getAuthMetaFilePath(authFilePath);
|
|
41
|
+
|
|
42
|
+
const authFileExists = await fs.pathExists(authFilePath);
|
|
43
|
+
const metaFileExists = await fs.pathExists(metaFilePath);
|
|
44
|
+
|
|
45
|
+
// Check if .old versions exist
|
|
46
|
+
const authOldPath = getBackupPath(authFilePath);
|
|
47
|
+
const metaOldPath = getBackupPath(metaFilePath);
|
|
48
|
+
const authOldExists = await fs.pathExists(authOldPath);
|
|
49
|
+
const metaOldExists = await fs.pathExists(metaOldPath);
|
|
50
|
+
|
|
51
|
+
let willOverwriteOld = false;
|
|
52
|
+
|
|
53
|
+
// If current files exist, backup them
|
|
54
|
+
if (authFileExists || metaFileExists) {
|
|
55
|
+
// Warn if .old files already exist (they will be erased)
|
|
56
|
+
if (authOldExists || metaOldExists) {
|
|
57
|
+
logger.warn(`Warning: Backup files (.old) already exist and will be erased:`);
|
|
58
|
+
if (authOldExists) logger.warn(` ${authOldPath}`);
|
|
59
|
+
if (metaOldExists) logger.warn(` ${metaOldPath}`);
|
|
60
|
+
willOverwriteOld = true;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
// Create backup directory if it doesn't exist
|
|
64
|
+
const dir = path.dirname(authFilePath);
|
|
65
|
+
await ensureAuthDir(authFilePath);
|
|
66
|
+
|
|
67
|
+
// Backup existing files
|
|
68
|
+
if (authFileExists) {
|
|
69
|
+
await fs.move(authFilePath, authOldPath, { overwrite: true });
|
|
70
|
+
logger.info(`Backed up ${authFilePath} to ${authOldPath}`);
|
|
71
|
+
}
|
|
72
|
+
if (metaFileExists) {
|
|
73
|
+
await fs.move(metaFilePath, metaOldPath, { overwrite: true });
|
|
74
|
+
logger.info(`Backed up ${metaFilePath} to ${metaOldPath}`);
|
|
75
|
+
}
|
|
76
|
+
} else {
|
|
77
|
+
// Ensure directory exists for new files
|
|
78
|
+
await ensureAuthDir(authFilePath);
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
return {
|
|
82
|
+
authFileExists,
|
|
83
|
+
metaFileExists,
|
|
84
|
+
willOverwriteOld,
|
|
85
|
+
authOldPath,
|
|
86
|
+
metaOldPath
|
|
87
|
+
};
|
|
88
|
+
}
|
|
89
|
+
|
|
26
90
|
/** Deletes auth.json and auth-meta.json (full logout). */
|
|
27
|
-
async function logout() {
|
|
28
|
-
|
|
29
|
-
|
|
91
|
+
async function logout(authFilePath) {
|
|
92
|
+
const filePath = getAuthFilePath(authFilePath);
|
|
93
|
+
const metaPath = getAuthMetaFilePath(filePath);
|
|
94
|
+
|
|
95
|
+
await fs.remove(filePath);
|
|
96
|
+
await fs.remove(metaPath);
|
|
30
97
|
}
|
|
31
98
|
|
|
32
99
|
/**
|
|
@@ -63,15 +130,20 @@ async function waitForAuthSuccess(page, targetUrl) {
|
|
|
63
130
|
]);
|
|
64
131
|
logger.success('Outlook mail interface detected.');
|
|
65
132
|
} else {
|
|
66
|
-
// OneNote default behavior
|
|
67
|
-
|
|
133
|
+
// OneNote default behavior — wait for the authenticated notebooks interface.
|
|
134
|
+
// IMPORTANT: We must require /notebooks in the URL to avoid matching the
|
|
135
|
+
// unauthenticated marketing landing page (onenote.cloud.microsoft/en-us)
|
|
136
|
+
// which also matches the old hostname-only check and caused premature auth saving.
|
|
137
|
+
logger.info('Waiting for redirection to authenticated notebooks interface...');
|
|
68
138
|
await Promise.any([
|
|
69
|
-
|
|
139
|
+
// Primary: URL must contain /notebooks (authenticated app)
|
|
140
|
+
page.waitForURL(url => url.toString().includes('/notebooks'), { timeout: 60000 }),
|
|
141
|
+
// Fallback UI elements that only appear when actually signed in
|
|
70
142
|
page.waitForSelector('text="My notebooks"', { state: 'visible', timeout: 60000 }),
|
|
71
143
|
page.waitForSelector('text="Create new notebook"', { state: 'visible', timeout: 60000 }),
|
|
72
|
-
page.waitForSelector('text="Welcome, "', { state: 'visible', timeout: 60000 })
|
|
144
|
+
page.waitForSelector('text="Welcome, "', { state: 'visible', timeout: 60000 }),
|
|
73
145
|
]);
|
|
74
|
-
logger.success('
|
|
146
|
+
logger.success('Authenticated notebooks interface detected.');
|
|
75
147
|
}
|
|
76
148
|
}
|
|
77
149
|
|
|
@@ -130,15 +202,22 @@ async function dismissFidoPage(page, logger) {
|
|
|
130
202
|
}
|
|
131
203
|
|
|
132
204
|
async function login(credentials = {}) {
|
|
133
|
-
const { email, password, targetUrl } = credentials;
|
|
205
|
+
const { email, password, targetUrl, authFile } = credentials;
|
|
134
206
|
const isAutomated = !!(email && password);
|
|
135
207
|
const headless = !credentials.notheadless && isAutomated;
|
|
136
208
|
// Use targetUrl if provided, otherwise default to ONENOTE_URL for backward compatibility
|
|
137
209
|
const finalTargetUrl = targetUrl || ONENOTE_URL;
|
|
138
210
|
|
|
211
|
+
// Get the auth file path (use provided or default)
|
|
212
|
+
const filePath = getAuthFilePath(authFile);
|
|
213
|
+
const metaPath = getAuthMetaFilePath(filePath);
|
|
214
|
+
|
|
139
215
|
// Added to verify version on user's machine
|
|
140
216
|
logger.debug('Authentication Module: Version 4.4-DEBUG starting...');
|
|
141
217
|
|
|
218
|
+
logger.debug(`Using auth file path: ${filePath}`);
|
|
219
|
+
logger.debug(`Using meta file path: ${metaPath}`);
|
|
220
|
+
|
|
142
221
|
if (isAutomated) {
|
|
143
222
|
logger.info(`Attempting automated login for ${email}...`);
|
|
144
223
|
} else {
|
|
@@ -147,6 +226,9 @@ async function login(credentials = {}) {
|
|
|
147
226
|
const serviceName = finalTargetUrl.includes('outlook') ? 'Outlook' : 'OneNote';
|
|
148
227
|
logger.warn(`The script will wait until you successfully reach the ${serviceName} interface.`);
|
|
149
228
|
}
|
|
229
|
+
|
|
230
|
+
// Prepare files (backup existing if needed, create directory)
|
|
231
|
+
await checkAndPrepareFiles(filePath);
|
|
150
232
|
|
|
151
233
|
const browser = await chromium.launch({ headless: !!headless });
|
|
152
234
|
const context = await browser.newContext({
|
|
@@ -570,14 +652,14 @@ async function login(credentials = {}) {
|
|
|
570
652
|
}
|
|
571
653
|
|
|
572
654
|
logger.info('Saving authentication state...');
|
|
573
|
-
await context.storageState({ path:
|
|
655
|
+
await context.storageState({ path: filePath });
|
|
574
656
|
|
|
575
|
-
await fs.writeJson(
|
|
657
|
+
await fs.writeJson(metaPath, {
|
|
576
658
|
email: email || 'manual login',
|
|
577
659
|
loginTime: new Date().toISOString()
|
|
578
660
|
});
|
|
579
661
|
|
|
580
|
-
logger.success(`Authentication successful! State saved to ${
|
|
662
|
+
logger.success(`Authentication successful! State saved to ${filePath}`);
|
|
581
663
|
} catch (error) {
|
|
582
664
|
logger.error('Authentication failed or cancelled:', error);
|
|
583
665
|
if (isAutomated) {
|
|
@@ -588,16 +670,19 @@ async function login(credentials = {}) {
|
|
|
588
670
|
}
|
|
589
671
|
}
|
|
590
672
|
|
|
591
|
-
async function getAuthenticatedContext(browser) {
|
|
592
|
-
|
|
593
|
-
|
|
673
|
+
async function getAuthenticatedContext(browser, authFilePath) {
|
|
674
|
+
const filePath = getAuthFilePath(authFilePath);
|
|
675
|
+
if (await fs.pathExists(filePath)) {
|
|
676
|
+
return browser.newContext({ storageState: filePath });
|
|
594
677
|
} else {
|
|
595
678
|
throw new Error('No authentication state found. Please run "login" command first.');
|
|
596
679
|
}
|
|
597
680
|
}
|
|
598
681
|
|
|
599
|
-
async function checkAuth(targetUrl = ONENOTE_URL) {
|
|
600
|
-
|
|
682
|
+
async function checkAuth(targetUrl = ONENOTE_URL, authFilePath) {
|
|
683
|
+
const filePath = getAuthFilePath(authFilePath);
|
|
684
|
+
|
|
685
|
+
if (!(await fs.pathExists(filePath))) {
|
|
601
686
|
return false;
|
|
602
687
|
}
|
|
603
688
|
|
|
@@ -605,7 +690,7 @@ async function checkAuth(targetUrl = ONENOTE_URL) {
|
|
|
605
690
|
try {
|
|
606
691
|
logger.debug('Verifying authentication session...');
|
|
607
692
|
browser = await chromium.launch({ headless: true });
|
|
608
|
-
const context = await browser.newContext({ storageState:
|
|
693
|
+
const context = await browser.newContext({ storageState: filePath });
|
|
609
694
|
const page = await context.newPage();
|
|
610
695
|
|
|
611
696
|
await page.goto(targetUrl, { waitUntil: 'domcontentloaded', timeout: 15000 });
|
|
@@ -618,7 +703,7 @@ async function checkAuth(targetUrl = ONENOTE_URL) {
|
|
|
618
703
|
|
|
619
704
|
if (isLoginUrl) {
|
|
620
705
|
logger.warn('Authentication session has expired. Deleting stale auth state.');
|
|
621
|
-
await logout();
|
|
706
|
+
await logout(authFilePath);
|
|
622
707
|
return false;
|
|
623
708
|
}
|
|
624
709
|
|
package/src/config.js
CHANGED
|
@@ -4,24 +4,46 @@
|
|
|
4
4
|
* @copyright 2026 phptr,enoola,msout
|
|
5
5
|
*/
|
|
6
6
|
const path = require('path');
|
|
7
|
+
const os = require('os');
|
|
8
|
+
const fs = require('fs-extra');
|
|
7
9
|
|
|
8
10
|
/**
|
|
9
|
-
* Returns the
|
|
10
|
-
* For this standalone CLI tool, we use the project root.
|
|
11
|
+
* Returns the default auth file path (~/.microsoft-webauth/auth-file.json)
|
|
11
12
|
*/
|
|
12
|
-
function
|
|
13
|
-
|
|
13
|
+
function getDefaultAuthFilePath() {
|
|
14
|
+
const homeDir = os.homedir();
|
|
15
|
+
const appDir = path.join(homeDir, '.microsoft-webauth');
|
|
16
|
+
return path.join(appDir, 'auth-file.json');
|
|
14
17
|
}
|
|
15
18
|
|
|
16
|
-
|
|
19
|
+
/**
|
|
20
|
+
* Generates the meta file path from auth file path
|
|
21
|
+
* e.g., /path/to/auth-file.json -> /path/to/auth-file-meta.json
|
|
22
|
+
*/
|
|
23
|
+
function getAuthMetaFilePath(authFilePath) {
|
|
24
|
+
const dir = path.dirname(authFilePath);
|
|
25
|
+
const name = path.basename(authFilePath);
|
|
26
|
+
// Remove .json extension if present, then add -meta.json
|
|
27
|
+
const baseName = name.replace(/\.json$/, '');
|
|
28
|
+
return path.join(dir, `${baseName}-meta.json`);
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Ensures the directory for the auth file exists, creates it if needed
|
|
33
|
+
*/
|
|
34
|
+
async function ensureAuthDir(authFilePath) {
|
|
35
|
+
const dir = path.dirname(authFilePath);
|
|
36
|
+
await fs.ensureDir(dir);
|
|
37
|
+
}
|
|
17
38
|
|
|
18
|
-
const
|
|
19
|
-
const ONENOTE_URL = 'https://onenote.cloud.microsoft/
|
|
39
|
+
const DEFAULT_AUTH_FILE = getDefaultAuthFilePath();
|
|
40
|
+
const ONENOTE_URL = 'https://onenote.cloud.microsoft/notebooks';
|
|
20
41
|
const OUTLOOK_URL = 'https://outlook.cloud.microsoft/mail/';
|
|
21
42
|
|
|
22
43
|
module.exports = {
|
|
23
|
-
|
|
44
|
+
DEFAULT_AUTH_FILE,
|
|
45
|
+
getAuthMetaFilePath,
|
|
46
|
+
ensureAuthDir,
|
|
24
47
|
ONENOTE_URL,
|
|
25
48
|
OUTLOOK_URL,
|
|
26
|
-
USER_DATA_DIR,
|
|
27
49
|
};
|
package/src/index.js
CHANGED
|
@@ -7,7 +7,7 @@
|
|
|
7
7
|
const { program } = require('commander');
|
|
8
8
|
const logger = require('./utils/logger');
|
|
9
9
|
const { login, checkAuth, getAuthMeta, logout } = require('./auth');
|
|
10
|
-
const { ONENOTE_URL, OUTLOOK_URL } = require('./config');
|
|
10
|
+
const { DEFAULT_AUTH_FILE, ONENOTE_URL, OUTLOOK_URL } = require('./config');
|
|
11
11
|
|
|
12
12
|
program
|
|
13
13
|
.name('webauth')
|
|
@@ -22,6 +22,7 @@ program
|
|
|
22
22
|
.option('--notheadless', 'Run in visible browser mode for debugging')
|
|
23
23
|
.option('--dodump', 'Dump HTML content to files for debugging')
|
|
24
24
|
.option('--against <target>', 'Target service: onenote (default) or outlook', 'onenote')
|
|
25
|
+
.option('--auth-file <path>', 'Path to auth file (default: ~/.microsoft-webauth/auth-file.json)', DEFAULT_AUTH_FILE)
|
|
25
26
|
.action(async (options) => {
|
|
26
27
|
const targetUrl = options.against === 'outlook' ? OUTLOOK_URL : ONENOTE_URL;
|
|
27
28
|
await login({ ...options, targetUrl });
|
|
@@ -31,12 +32,13 @@ program
|
|
|
31
32
|
.command('check')
|
|
32
33
|
.description('Check if authenticated')
|
|
33
34
|
.option('--against <target>', 'Target service: onenote (default) or outlook', 'onenote')
|
|
35
|
+
.option('--auth-file <path>', 'Path to auth file (default: ~/.microsoft-webauth/auth-file.json)', DEFAULT_AUTH_FILE)
|
|
34
36
|
.action(async (options) => {
|
|
35
37
|
const targetUrl = options.against === 'outlook' ? OUTLOOK_URL : ONENOTE_URL;
|
|
36
|
-
const isAuth = await checkAuth(targetUrl);
|
|
38
|
+
const isAuth = await checkAuth(targetUrl, options.authFile);
|
|
37
39
|
if (isAuth) {
|
|
38
40
|
logger.success('Authentication file found. You are authenticated.');
|
|
39
|
-
const meta = await getAuthMeta();
|
|
41
|
+
const meta = await getAuthMeta(options.authFile);
|
|
40
42
|
if (meta && meta.email) {
|
|
41
43
|
const loginTime = new Date(meta.loginTime).toLocaleString();
|
|
42
44
|
logger.info(`Logged in as: ${meta.email}`);
|
|
@@ -50,8 +52,9 @@ program
|
|
|
50
52
|
program
|
|
51
53
|
.command('logout')
|
|
52
54
|
.description('Clear authentication state')
|
|
53
|
-
.
|
|
54
|
-
|
|
55
|
+
.option('--auth-file <path>', 'Path to auth file (default: ~/.microsoft-webauth/auth-file.json)', DEFAULT_AUTH_FILE)
|
|
56
|
+
.action(async (options) => {
|
|
57
|
+
await logout(options.authFile);
|
|
55
58
|
logger.success('Logged out successfully. Authentication state cleared.');
|
|
56
59
|
});
|
|
57
60
|
|