mirror of
https://github.com/Myxelium/Bridge-Multi.git
synced 2026-04-11 22:29:38 +00:00
Updated API endponts, Removed Google Auth
This commit is contained in:
4
package-lock.json
generated
4
package-lock.json
generated
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "bridge",
|
||||
"version": "1.4.2",
|
||||
"version": "1.4.3",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "bridge",
|
||||
"version": "1.4.2",
|
||||
"version": "1.4.3",
|
||||
"license": "GPL-3.0",
|
||||
"dependencies": {
|
||||
"@angular/animations": "^14.2.12",
|
||||
|
||||
@@ -25,25 +25,6 @@
|
||||
<label>Download video backgrounds</label>
|
||||
</div>
|
||||
</div>
|
||||
<div *ngIf="loginAvailable" class="field">
|
||||
<label>Google rate limit delay</label>
|
||||
<div id="rateLimitInput" class="ui right labeled input">
|
||||
<input type="number" [value]="settingsService.rateLimitDelay" (input)="changeRateLimit($event)">
|
||||
<div class="ui basic label">
|
||||
sec
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div *ngIf="loginAvailable" class="field">
|
||||
<div class="ui button" data-tooltip="Removes rate limit delay" data-position="right center" (click)="googleLogin()">
|
||||
<i class="google icon"></i>Sign in with Google
|
||||
</div>
|
||||
</div>
|
||||
<div *ngIf="!loginAvailable" class="field">
|
||||
<div class="ui button" (click)="googleLogout()">
|
||||
<i class="google icon"></i>Sign out
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div *ngIf="settingsService.rateLimitDelay < 30" class="ui warning message">
|
||||
<i class="exclamation circle icon"></i>
|
||||
|
||||
@@ -14,7 +14,6 @@ export class SettingsComponent implements OnInit, AfterViewInit {
|
||||
|
||||
cacheSize = 'Calculating...'
|
||||
updateAvailable = false
|
||||
loginAvailable = true
|
||||
loginClicked = false
|
||||
downloadUpdateText = 'Update available'
|
||||
retryUpdateText = 'Failed to check for update'
|
||||
@@ -53,10 +52,6 @@ export class SettingsComponent implements OnInit, AfterViewInit {
|
||||
this.updateAvailable = isAvailable
|
||||
this.ref.detectChanges()
|
||||
})
|
||||
this.electronService.invoke('get-auth-status', undefined).then(isAuthenticated => {
|
||||
this.loginAvailable = !isAuthenticated
|
||||
this.ref.detectChanges()
|
||||
})
|
||||
|
||||
const cacheSize = await this.settingsService.getCacheSize()
|
||||
this.cacheSize = Math.round(cacheSize / 1000000) + ' MB'
|
||||
@@ -95,19 +90,6 @@ export class SettingsComponent implements OnInit, AfterViewInit {
|
||||
}
|
||||
}
|
||||
|
||||
async googleLogin() {
|
||||
if (this.loginClicked) { return }
|
||||
this.loginClicked = true
|
||||
const isAuthenticated = await this.electronService.invoke('google-login', undefined)
|
||||
this.loginAvailable = !isAuthenticated
|
||||
this.loginClicked = false
|
||||
}
|
||||
|
||||
async googleLogout() {
|
||||
this.loginAvailable = true
|
||||
await this.electronService.invoke('google-logout', undefined)
|
||||
}
|
||||
|
||||
openLibraryDirectory() {
|
||||
this.electronService.openFolder(this.settingsService.libraryDirectory)
|
||||
}
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import { IPCInvokeHandler } from '../../shared/IPCHandler'
|
||||
import { AlbumArtResult } from '../../shared/interfaces/songDetails.interface'
|
||||
import * as needle from 'needle'
|
||||
import { serverURL } from '../../shared/Paths'
|
||||
|
||||
/**
|
||||
@@ -12,20 +11,9 @@ class AlbumArtHandler implements IPCInvokeHandler<'album-art'> {
|
||||
/**
|
||||
* @returns an `AlbumArtResult` object containing the album art for the song with `songID`.
|
||||
*/
|
||||
async handler(songID: number) {
|
||||
return new Promise<AlbumArtResult>((resolve, reject) => {
|
||||
needle.request(
|
||||
'get',
|
||||
serverURL + `/api/data/albumArt`, {
|
||||
songID: songID
|
||||
}, (err, response) => {
|
||||
if (err) {
|
||||
reject(err)
|
||||
} else {
|
||||
resolve(response.body)
|
||||
}
|
||||
})
|
||||
})
|
||||
async handler(songID: number): Promise<AlbumArtResult> {
|
||||
const response = await fetch(`https://${serverURL}/api/data/album-art/${songID}`)
|
||||
return await response.json()
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import { IPCInvokeHandler } from '../../shared/IPCHandler'
|
||||
import { VersionResult } from '../../shared/interfaces/songDetails.interface'
|
||||
import { serverURL } from '../../shared/Paths'
|
||||
import * as needle from 'needle'
|
||||
|
||||
/**
|
||||
* Handles the 'batch-song-details' event.
|
||||
@@ -12,20 +11,9 @@ class BatchSongDetailsHandler implements IPCInvokeHandler<'batch-song-details'>
|
||||
/**
|
||||
* @returns an array of all the chart versions with a songID found in `songIDs`.
|
||||
*/
|
||||
async handler(songIDs: number[]) {
|
||||
return new Promise<VersionResult[]>((resolve, reject) => {
|
||||
needle.request(
|
||||
'get',
|
||||
serverURL + `/api/data/songsVersions`, {
|
||||
songIDs: songIDs
|
||||
}, (err, response) => {
|
||||
if (err) {
|
||||
reject(err)
|
||||
} else {
|
||||
resolve(response.body)
|
||||
}
|
||||
})
|
||||
})
|
||||
async handler(songIDs: number[]): Promise<VersionResult[]> {
|
||||
const response = await fetch(`https://${serverURL}/api/data/song-versions/${songIDs.join(',')}`)
|
||||
return await response.json()
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import { IPCInvokeHandler } from '../../shared/IPCHandler'
|
||||
import { SongSearch, SongResult } from '../../shared/interfaces/search.interface'
|
||||
import * as needle from 'needle'
|
||||
import { SongResult, SongSearch } from '../../shared/interfaces/search.interface'
|
||||
import { serverURL } from '../../shared/Paths'
|
||||
|
||||
/**
|
||||
@@ -12,23 +11,16 @@ class SearchHandler implements IPCInvokeHandler<'song-search'> {
|
||||
/**
|
||||
* @returns the top 50 songs that match `search`.
|
||||
*/
|
||||
async handler(search: SongSearch) {
|
||||
return new Promise<SongResult[]>((resolve, reject) => {
|
||||
needle.request(
|
||||
'get',
|
||||
serverURL + `/api/search`, search, (err, response) => {
|
||||
if (err) {
|
||||
reject(err.message)
|
||||
} else {
|
||||
if (response.body.errors) {
|
||||
console.log(response.body)
|
||||
resolve([])
|
||||
} else {
|
||||
resolve(response.body)
|
||||
}
|
||||
}
|
||||
})
|
||||
async handler(search: SongSearch): Promise<SongResult[]> {
|
||||
const response = await fetch(`https://${serverURL}/api/search`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
body: JSON.stringify(search)
|
||||
})
|
||||
|
||||
return await response.json()
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import { IPCInvokeHandler } from '../../shared/IPCHandler'
|
||||
import { VersionResult } from '../../shared/interfaces/songDetails.interface'
|
||||
import * as needle from 'needle'
|
||||
import { serverURL } from '../../shared/Paths'
|
||||
|
||||
/**
|
||||
@@ -12,20 +11,9 @@ class SongDetailsHandler implements IPCInvokeHandler<'song-details'> {
|
||||
/**
|
||||
* @returns the chart versions with `songID`.
|
||||
*/
|
||||
async handler(songID: number) {
|
||||
return new Promise<VersionResult[]>((resolve, reject) => {
|
||||
needle.request(
|
||||
'get',
|
||||
serverURL + `/api/data/songVersions`, {
|
||||
songID: songID
|
||||
}, (err, response) => {
|
||||
if (err) {
|
||||
reject(err)
|
||||
} else {
|
||||
resolve(response.body)
|
||||
}
|
||||
})
|
||||
})
|
||||
async handler(songID: number): Promise<VersionResult[]> {
|
||||
const response = await fetch(`https://${serverURL}/api/data/song-versions/${songID}`)
|
||||
return await response.json()
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -145,7 +145,7 @@ export class ChartDownload {
|
||||
for (let i = 0; i < this.files.length; i++) {
|
||||
let wasCanceled = false
|
||||
this.cancelFn = () => { wasCanceled = true }
|
||||
const downloader = await getDownloader(this.files[i].webContentLink, join(this.tempPath, this.files[i].name))
|
||||
const downloader = getDownloader(this.files[i].webContentLink, join(this.tempPath, this.files[i].name))
|
||||
if (wasCanceled) { return }
|
||||
this.cancelFn = () => downloader.cancelDownload()
|
||||
|
||||
|
||||
@@ -6,7 +6,6 @@ import { Readable } from 'stream'
|
||||
// TODO: replace needle with got (for cancel() method) (if before-headers event is possible?)
|
||||
import { googleTimer } from './GoogleTimer'
|
||||
import { DownloadError } from './ChartDownload'
|
||||
import { googleAuth } from '../google/GoogleAuth'
|
||||
import { google } from 'googleapis'
|
||||
import Bottleneck from 'bottleneck'
|
||||
import { promisify } from 'util'
|
||||
@@ -49,12 +48,8 @@ const downloadErrors = {
|
||||
* @param url The download link.
|
||||
* @param fullPath The full path to where this file should be stored (including the filename).
|
||||
*/
|
||||
export async function getDownloader(url: string, fullPath: string): Promise<FileDownloader> {
|
||||
if (await googleAuth.attemptToAuthenticate()) {
|
||||
return new APIFileDownloader(url, fullPath)
|
||||
} else {
|
||||
export function getDownloader(url: string, fullPath: string): FileDownloader {
|
||||
return new SlowFileDownloader(url, fullPath)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -1,62 +0,0 @@
|
||||
import * as http from 'http'
|
||||
import { URL } from 'url'
|
||||
import { REDIRECT_PATH, REDIRECT_BASE, SERVER_PORT } from '../../shared/Paths'
|
||||
|
||||
type EventCallback = {
|
||||
'listening': () => void
|
||||
'authCode': (authCode: string) => Promise<void>
|
||||
}
|
||||
type Callbacks = { [E in keyof EventCallback]: EventCallback[E] }
|
||||
|
||||
class AuthServer {
|
||||
|
||||
private server: http.Server
|
||||
private callbacks = {} as Callbacks
|
||||
private connections = {}
|
||||
|
||||
/**
|
||||
* Calls `callback` when `event` fires. (no events will be fired after `this.cancelDownload()` is called)
|
||||
*/
|
||||
on<E extends keyof EventCallback>(event: E, callback: EventCallback[E]) {
|
||||
this.callbacks[event] = callback
|
||||
}
|
||||
|
||||
/**
|
||||
* Starts listening on `SERVER_PORT` for the authentication callback.
|
||||
* Emits the 'listening' event when the server is ready to listen.
|
||||
* Emits the 'authCode' event when the callback request provides the authentication code.
|
||||
*/
|
||||
startServer() {
|
||||
if (this.server != null) {
|
||||
this.callbacks.listening()
|
||||
} else {
|
||||
this.server = http.createServer(this.requestListener.bind(this))
|
||||
this.server.on('connection', (conn) => {
|
||||
const key = conn.remoteAddress + ':' + conn.remotePort
|
||||
this.connections[key] = conn
|
||||
conn.on('close', () => delete this.connections[key])
|
||||
})
|
||||
|
||||
this.server.listen(SERVER_PORT, () => this.callbacks.listening())
|
||||
}
|
||||
}
|
||||
|
||||
private requestListener(req: http.IncomingMessage, res: http.ServerResponse) {
|
||||
if (req.url.includes(REDIRECT_PATH)) {
|
||||
const searchParams = new URL(req.url, REDIRECT_BASE).searchParams
|
||||
res.end()
|
||||
this.destroyServer()
|
||||
this.callbacks.authCode(searchParams.get('code'))
|
||||
}
|
||||
}
|
||||
|
||||
private destroyServer() {
|
||||
this.server.close()
|
||||
for (const key in this.connections) {
|
||||
this.connections[key].destroy()
|
||||
}
|
||||
this.server = null
|
||||
}
|
||||
}
|
||||
|
||||
export const authServer = new AuthServer()
|
||||
@@ -1,190 +0,0 @@
|
||||
/* eslint-disable @typescript-eslint/no-misused-promises */
|
||||
/* eslint-disable @typescript-eslint/camelcase */
|
||||
import { dataPath, REDIRECT_URI } from '../../shared/Paths'
|
||||
import { mainWindow } from '../../main'
|
||||
import { join } from 'path'
|
||||
import { readFile, writeFile } from 'jsonfile'
|
||||
import { google } from 'googleapis'
|
||||
import { Credentials } from 'googleapis/node_modules/google-auth-library/build/src/auth/credentials'
|
||||
import { OAuth2Client } from 'googleapis-common/node_modules/google-auth-library/build/src/auth/oauth2client'
|
||||
import * as needle from 'needle'
|
||||
import { authServer } from './AuthServer'
|
||||
import { BrowserWindow } from 'electron'
|
||||
import { serverURL } from '../../shared/Paths'
|
||||
import * as fs from 'fs'
|
||||
import { promisify } from 'util'
|
||||
import { devLog } from '../../shared/ElectronUtilFunctions'
|
||||
import { serializeError } from 'serialize-error'
|
||||
|
||||
const unlink = promisify(fs.unlink)
|
||||
|
||||
const TOKEN_PATH = join(dataPath, 'token.json')
|
||||
|
||||
export class GoogleAuth {
|
||||
|
||||
private hasAuthenticated = false
|
||||
|
||||
private oAuth2Client: OAuth2Client = null
|
||||
private token: Credentials = null
|
||||
|
||||
/**
|
||||
* Attempts to authenticate the googleapis library using the token stored at `TOKEN_PATH`.
|
||||
* @returns `true` if the user is authenticated, and `false` otherwise.
|
||||
*/
|
||||
async attemptToAuthenticate() {
|
||||
|
||||
if (this.hasAuthenticated) {
|
||||
return true
|
||||
}
|
||||
|
||||
// Get client info from server
|
||||
if (!await this.getOAuth2Client()) {
|
||||
return false
|
||||
}
|
||||
|
||||
// Get stored token
|
||||
if (!await this.getStoredToken()) {
|
||||
return false
|
||||
}
|
||||
|
||||
// Token has been restored from a previous session
|
||||
this.authenticateWithToken()
|
||||
return true
|
||||
}
|
||||
|
||||
/**
|
||||
* Uses OAuth2 to generate a token that can be used to authenticate download requests.
|
||||
* Involves displaying a popup window to the user.
|
||||
* @returns true if the auth token was generated, and false otherwise.
|
||||
*/
|
||||
async generateAuthToken() {
|
||||
|
||||
if (this.hasAuthenticated) {
|
||||
return true
|
||||
}
|
||||
|
||||
// Get client info from server
|
||||
if (!await this.getOAuth2Client()) {
|
||||
return false
|
||||
}
|
||||
|
||||
let popupWindow: BrowserWindow
|
||||
|
||||
return new Promise<boolean>(resolve => {
|
||||
authServer.on('listening', () => {
|
||||
const authUrl = this.oAuth2Client.generateAuthUrl({
|
||||
access_type: 'offline',
|
||||
// This scope is too broad, but is the only one that will actually download files for some dumb reason.
|
||||
// If you want this fixed, please upvote/star my issue on the Google bug tracker so they will fix it faster:
|
||||
// https://issuetracker.google.com/issues/168687448
|
||||
scope: ['https://www.googleapis.com/auth/drive.readonly'],
|
||||
redirect_uri: REDIRECT_URI
|
||||
})
|
||||
|
||||
popupWindow = new BrowserWindow({
|
||||
fullscreenable: false,
|
||||
modal: true,
|
||||
maximizable: false,
|
||||
minimizable: false,
|
||||
show: false,
|
||||
parent: mainWindow,
|
||||
autoHideMenuBar: true,
|
||||
center: true,
|
||||
thickFrame: true,
|
||||
useContentSize: true,
|
||||
width: 400
|
||||
})
|
||||
popupWindow.loadURL(authUrl, { userAgent: 'Chrome' })
|
||||
popupWindow.on('ready-to-show', () => popupWindow.show())
|
||||
popupWindow.on('closed', () => resolve(this.hasAuthenticated))
|
||||
})
|
||||
|
||||
authServer.on('authCode', async (authCode) => {
|
||||
this.token = (await this.oAuth2Client.getToken(authCode)).tokens
|
||||
writeFile(TOKEN_PATH, this.token).catch(err => devLog('Got token, but failed to write it to TOKEN_PATH:', serializeError(err)))
|
||||
|
||||
this.authenticateWithToken()
|
||||
|
||||
popupWindow.close()
|
||||
})
|
||||
|
||||
authServer.startServer()
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Use this.token as the credentials for this.oAuth2Client, and make the google library use this authentication.
|
||||
* Assumes these have already been defined correctly.
|
||||
*/
|
||||
private authenticateWithToken() {
|
||||
this.oAuth2Client.setCredentials(this.token)
|
||||
google.options({ auth: this.oAuth2Client })
|
||||
this.hasAuthenticated = true
|
||||
}
|
||||
|
||||
/**
|
||||
* Attempts to get Bridge's client info from the server.
|
||||
* @returns true if this.clientID and this.clientSecret have been set, and false if that failed.
|
||||
*/
|
||||
private async getOAuth2Client() {
|
||||
if (this.oAuth2Client != null) {
|
||||
return true
|
||||
} else {
|
||||
return new Promise<boolean>(resolve => {
|
||||
needle.request(
|
||||
'get',
|
||||
serverURL + `/api/data/client`, null, (err, response) => {
|
||||
if (err) {
|
||||
devLog('Could not authenticate because client info could not be retrieved from the server:', serializeError(err))
|
||||
resolve(false)
|
||||
} else {
|
||||
this.oAuth2Client = new google.auth.OAuth2(response.body.CLIENT_ID, response.body.CLIENT_SECRET, REDIRECT_URI)
|
||||
resolve(true)
|
||||
}
|
||||
})
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Attempts to retrieve a previously stored auth token at `TOKEN_PATH`.
|
||||
* Note: will not try again if this.token === undefined.
|
||||
* @returns true if this.token has been set, and false if that failed or the token didn't exist.
|
||||
*/
|
||||
private async getStoredToken() {
|
||||
if (this.token === undefined) {
|
||||
return false // undefined means no token file was found
|
||||
} else if (this.token !== null) {
|
||||
return true
|
||||
} else {
|
||||
try {
|
||||
this.token = await readFile(TOKEN_PATH)
|
||||
return true
|
||||
} catch (err) {
|
||||
if (err?.code && err?.code != 'ENOENT') {
|
||||
this.token = null // File exists but could not be accessed; next attempt should try again
|
||||
} else {
|
||||
this.token = undefined
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Removes a previously stored auth token from `TOKEN_PATH`.
|
||||
*/
|
||||
async deleteStoredToken() {
|
||||
this.token = undefined
|
||||
this.hasAuthenticated = false
|
||||
try {
|
||||
await unlink(TOKEN_PATH)
|
||||
} catch (err) {
|
||||
devLog('Failed to delete token:', serializeError(err))
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export const googleAuth = new GoogleAuth()
|
||||
@@ -1,56 +0,0 @@
|
||||
import { IPCInvokeHandler } from '../../shared/IPCHandler'
|
||||
import { googleAuth } from './GoogleAuth'
|
||||
|
||||
/**
|
||||
* Handles the 'google-login' event.
|
||||
*/
|
||||
class GoogleLoginHandler implements IPCInvokeHandler<'google-login'> {
|
||||
event: 'google-login' = 'google-login'
|
||||
|
||||
/**
|
||||
* @returns `true` if the user has been authenticated.
|
||||
*/
|
||||
async handler() {
|
||||
return new Promise<boolean>(resolve => {
|
||||
googleAuth.generateAuthToken().then((isLoggedIn) => resolve(isLoggedIn))
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
export const googleLoginHandler = new GoogleLoginHandler()
|
||||
|
||||
/**
|
||||
* Handles the 'google-login' event.
|
||||
*/
|
||||
class GoogleLogoutHandler implements IPCInvokeHandler<'google-logout'> {
|
||||
event: 'google-logout' = 'google-logout'
|
||||
|
||||
/**
|
||||
* @returns `true` if the user has been authenticated.
|
||||
*/
|
||||
async handler() {
|
||||
return new Promise<undefined>(resolve => {
|
||||
googleAuth.deleteStoredToken().then(() => resolve(undefined))
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
export const googleLogoutHandler = new GoogleLogoutHandler()
|
||||
|
||||
/**
|
||||
* Handles the 'get-auth-status' event.
|
||||
*/
|
||||
class GetAuthStatusHandler implements IPCInvokeHandler<'get-auth-status'> {
|
||||
event: 'get-auth-status' = 'get-auth-status'
|
||||
|
||||
/**
|
||||
* @returns `true` if the user is authenticated with Google.
|
||||
*/
|
||||
handler() {
|
||||
return new Promise<boolean>(resolve => {
|
||||
googleAuth.attemptToAuthenticate().then(isAuthenticated => resolve(isAuthenticated))
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
export const getAuthStatusHandler = new GetAuthStatusHandler()
|
||||
@@ -9,7 +9,6 @@ import { Settings } from './Settings'
|
||||
import { batchSongDetailsHandler } from '../ipc/browse/BatchSongDetailsHandler.ipc'
|
||||
import { getSettingsHandler, setSettingsHandler } from '../ipc/SettingsHandler.ipc'
|
||||
import { clearCacheHandler } from '../ipc/CacheHandler.ipc'
|
||||
import { googleLoginHandler, getAuthStatusHandler, googleLogoutHandler } from '../ipc/google/GoogleLoginHandler.ipc'
|
||||
import { updateChecker, UpdateProgress, getCurrentVersionHandler, downloadUpdateHandler, quitAndInstallHandler, getUpdateAvailableHandler } from '../ipc/UpdateHandler.ipc'
|
||||
import { UpdateInfo } from 'electron-updater'
|
||||
import { openURLHandler } from '../ipc/OpenURLHandler.ipc'
|
||||
@@ -32,9 +31,6 @@ export function getIPCInvokeHandlers(): IPCInvokeHandler<keyof IPCInvokeEvents>[
|
||||
albumArtHandler,
|
||||
getCurrentVersionHandler,
|
||||
getUpdateAvailableHandler,
|
||||
googleLoginHandler,
|
||||
googleLogoutHandler,
|
||||
getAuthStatusHandler
|
||||
]
|
||||
}
|
||||
|
||||
@@ -74,18 +70,6 @@ export type IPCInvokeEvents = {
|
||||
input: undefined
|
||||
output: boolean
|
||||
}
|
||||
'google-login': {
|
||||
input: undefined
|
||||
output: boolean
|
||||
}
|
||||
'google-logout': {
|
||||
input: undefined
|
||||
output: undefined
|
||||
}
|
||||
'get-auth-status': {
|
||||
input: undefined
|
||||
output: boolean
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
Reference in New Issue
Block a user