mirror of
https://github.com/pockethost/pockethost.git
synced 2025-06-03 20:56:40 +00:00
feat(local-auth): Initial commit
This commit is contained in:
parent
55b43a1573
commit
5be2c9ef0f
6
.changeset/1719706755991.md
Normal file
6
.changeset/1719706755991.md
Normal file
@ -0,0 +1,6 @@
|
|||||||
|
---
|
||||||
|
'@pockethost/plugin-local-auth': minor
|
||||||
|
'pockethost': minor
|
||||||
|
---
|
||||||
|
|
||||||
|
Initial commit
|
16
packages/plugin-local-auth/LICENSE.md
Normal file
16
packages/plugin-local-auth/LICENSE.md
Normal file
@ -0,0 +1,16 @@
|
|||||||
|
The MIT License (MIT)
|
||||||
|
|
||||||
|
Permission is hereby granted, free of charge, to any person obtaining a copy of this software
|
||||||
|
and associated documentation files (the "Software"), to deal in the Software without restriction,
|
||||||
|
including without limitation the rights to use, copy, modify, merge, publish, distribute,
|
||||||
|
sublicense, and/or sell copies of the Software, and to permit persons to whom the Software
|
||||||
|
is furnished to do so, subject to the following conditions:
|
||||||
|
|
||||||
|
The above copyright notice and this permission notice shall be included in all copies or
|
||||||
|
substantial portions of the Software.
|
||||||
|
|
||||||
|
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING
|
||||||
|
BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
|
||||||
|
NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM,
|
||||||
|
DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||||
|
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
43
packages/plugin-local-auth/package.json
Normal file
43
packages/plugin-local-auth/package.json
Normal file
@ -0,0 +1,43 @@
|
|||||||
|
{
|
||||||
|
"name": "@pockethost/plugin-local-auth",
|
||||||
|
"version": "0.0.1",
|
||||||
|
"repository": {
|
||||||
|
"type": "git",
|
||||||
|
"url": "http://github.com/pockethost/pockethost/packages/plugin-local-auth"
|
||||||
|
},
|
||||||
|
"description": "A pockethost plugin.",
|
||||||
|
"main": "src/index.ts",
|
||||||
|
"module": "src/index.ts",
|
||||||
|
"types": "src/index.ts",
|
||||||
|
"type": "module",
|
||||||
|
"scripts": {
|
||||||
|
"check-types": "tsc --noEmit "
|
||||||
|
},
|
||||||
|
"keywords": [
|
||||||
|
"pockethost"
|
||||||
|
],
|
||||||
|
"author": {
|
||||||
|
"name": "Ben Allfree",
|
||||||
|
"url": "https://github.com/benallfree"
|
||||||
|
},
|
||||||
|
"license": "MIT",
|
||||||
|
"peerDependencies": {
|
||||||
|
"pockethost": "workspace:^1.5.0"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@types/bcryptjs": "^2.4.6",
|
||||||
|
"@types/inquirer": "^9.0.7",
|
||||||
|
"@types/lowdb": "^1.0.15",
|
||||||
|
"@types/node": "^20.8.10",
|
||||||
|
"typescript": "^5.4.5"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@s-libs/micro-dash": "^16.1.0",
|
||||||
|
"bcryptjs": "^2.4.3",
|
||||||
|
"commander": "^11.1.0",
|
||||||
|
"immer": "^10.1.1",
|
||||||
|
"inflection": "^3.0.0",
|
||||||
|
"inquirer": "^9.2.15",
|
||||||
|
"lowdb": "^7.0.1"
|
||||||
|
}
|
||||||
|
}
|
67
packages/plugin-local-auth/readme.md
Normal file
67
packages/plugin-local-auth/readme.md
Normal file
@ -0,0 +1,67 @@
|
|||||||
|
# plugin-local-auth
|
||||||
|
|
||||||
|
A plugin for [pockethost](https://www.npmjs.com/package/pockethost).
|
||||||
|
|
||||||
|
This plugin implements a local user auth database.
|
||||||
|
|
||||||
|
## Quickstart
|
||||||
|
|
||||||
|
**Install**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npx pockethost plugin install @pockethost/plugin-local-auth
|
||||||
|
```
|
||||||
|
|
||||||
|
**Get help**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npx pockethost local-auth --help
|
||||||
|
```
|
||||||
|
|
||||||
|
**Create user/admin**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npx pockethost local-auth create foo@bar.com myPassword
|
||||||
|
npx pockethost local-auth create admin@bar.com myPassword --admin
|
||||||
|
```
|
||||||
|
|
||||||
|
**Delete user**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npx pockethost local-auth delete foo@bar.com
|
||||||
|
```
|
||||||
|
|
||||||
|
**List users**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npx pockethost local-auth ls
|
||||||
|
```
|
||||||
|
|
||||||
|
## Variables
|
||||||
|
|
||||||
|
The following variables will be used if they are found in the shell environment. PocketHost will also load them from an `.env` file if found at load time.
|
||||||
|
|
||||||
|
| Name | Default | Discussion |
|
||||||
|
| ------------------ | ------------------------------- | ------------------------------------------------------------- |
|
||||||
|
| PH_LOCAL_AUTH_HOME | `.pockethost/plugin-local-auth` | The home directory for any data storage needs of this plugin. |
|
||||||
|
|
||||||
|
## Actions
|
||||||
|
|
||||||
|
## Filters
|
||||||
|
|
||||||
|
## Related Plugins
|
||||||
|
|
||||||
|
`local-auth` pairs well with:
|
||||||
|
|
||||||
|
- `@pockethost/plugin-ftp-server`
|
||||||
|
- `@pockethost/plugin-auto-admin`
|
||||||
|
|
||||||
|
## Support
|
||||||
|
|
||||||
|
PocketHost has a thriving [Discord community](https://discord.gg/nVTxCMEcGT).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Sponsored by https://pockethost.io. Instantly host your PocketBase projects.
|
||||||
|
|
||||||
|
---
|
85
packages/plugin-local-auth/src/AuthCommand.ts
Normal file
85
packages/plugin-local-auth/src/AuthCommand.ts
Normal file
@ -0,0 +1,85 @@
|
|||||||
|
import { forEach } from '@s-libs/micro-dash'
|
||||||
|
import { Command } from 'commander'
|
||||||
|
import { gracefulExit } from 'pockethost/core'
|
||||||
|
import { DbService } from './db'
|
||||||
|
import { error, info } from './log'
|
||||||
|
|
||||||
|
export const AuthCommand = async () => {
|
||||||
|
const {
|
||||||
|
addUser,
|
||||||
|
getFirstUserByExactCriteria,
|
||||||
|
getAllUsersByExactCriteria,
|
||||||
|
authenticate,
|
||||||
|
deleteUserByExactCriteria,
|
||||||
|
} = await DbService()
|
||||||
|
|
||||||
|
const cmd = new Command(`auth`)
|
||||||
|
.description(`Command root`)
|
||||||
|
.addCommand(
|
||||||
|
new Command(`add`)
|
||||||
|
.argument(`<username>`, `User name`)
|
||||||
|
.argument(`<password>`, `User password`)
|
||||||
|
.alias(`a`)
|
||||||
|
.option(`-a, --admin`, `Make the user an admin`)
|
||||||
|
.description(`Add a user`)
|
||||||
|
.action(async (username, password, options) => {
|
||||||
|
if (getFirstUserByExactCriteria({ username })) {
|
||||||
|
error(`User ${username} already exists`)
|
||||||
|
await gracefulExit()
|
||||||
|
}
|
||||||
|
await addUser(username, password, options.admin)
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
.addCommand(
|
||||||
|
new Command(`update`)
|
||||||
|
.argument(`<username>`, `User name`)
|
||||||
|
.argument(`<password>`, `User password`)
|
||||||
|
.option(`-a, --admin`, `Make the user an admin`)
|
||||||
|
.alias(`u`)
|
||||||
|
.alias(`up`)
|
||||||
|
.description(`Update a user`)
|
||||||
|
.action(async (username: string, password: string, options) => {
|
||||||
|
await deleteUserByExactCriteria({ username })
|
||||||
|
await addUser(username, password, options.admin)
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
.addCommand(
|
||||||
|
new Command(`delete`)
|
||||||
|
.argument(`<username>`, `User ID`)
|
||||||
|
.alias(`d`)
|
||||||
|
.alias(`del`)
|
||||||
|
.alias(`rm`)
|
||||||
|
.description(`Delete a user`)
|
||||||
|
.action(async (username: string, options) => {
|
||||||
|
await deleteUserByExactCriteria({ username })
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
.addCommand(
|
||||||
|
new Command(`list`)
|
||||||
|
.alias(`ls`)
|
||||||
|
.description(`List users`)
|
||||||
|
.action(async (options) => {
|
||||||
|
info(`Listing users`)
|
||||||
|
const users = getAllUsersByExactCriteria({})
|
||||||
|
forEach(users, (user) => {
|
||||||
|
info(`${user.username}`, user.isAdmin ? `(admin)` : ``)
|
||||||
|
})
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
.addCommand(
|
||||||
|
new Command(`login`)
|
||||||
|
.argument(`<username>`, `User name`)
|
||||||
|
.argument(`<password>`, `User password`)
|
||||||
|
.alias(`a`)
|
||||||
|
.description(`Test the user login`)
|
||||||
|
.action(async (username, password, options) => {
|
||||||
|
const isAuthenticated = authenticate(username, password)
|
||||||
|
if (isAuthenticated) {
|
||||||
|
info(`User ${username} authenticated`)
|
||||||
|
} else {
|
||||||
|
error(`User ${username} not authenticated`)
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
return cmd
|
||||||
|
}
|
21
packages/plugin-local-auth/src/constants.ts
Normal file
21
packages/plugin-local-auth/src/constants.ts
Normal file
@ -0,0 +1,21 @@
|
|||||||
|
import { underscore } from 'inflection'
|
||||||
|
import { dirname, join } from 'path'
|
||||||
|
import { PH_HOME, Settings, mkPath } from 'pockethost/core'
|
||||||
|
import { fileURLToPath } from 'url'
|
||||||
|
|
||||||
|
export const PLUGIN_NAME = `plugin-local-auth`
|
||||||
|
|
||||||
|
export const HOME_DIR =
|
||||||
|
process.env.PH_LOCAL_AUTH_HOME || join(PH_HOME(), PLUGIN_NAME)
|
||||||
|
export const PLUGIN_NAME_CONSTANT_CASE = underscore(PLUGIN_NAME, true)
|
||||||
|
|
||||||
|
const __dirname = dirname(fileURLToPath(import.meta.url))
|
||||||
|
|
||||||
|
export const PLUGIN_DATA = (...paths: string[]) => join(HOME_DIR, ...paths)
|
||||||
|
|
||||||
|
export const PROJECT_DIR = (...paths: string[]) =>
|
||||||
|
join(__dirname, '..', ...paths)
|
||||||
|
|
||||||
|
export const settings = Settings({
|
||||||
|
PH_LOCAL_AUTH_HOME: mkPath(HOME_DIR, { create: true }),
|
||||||
|
})
|
98
packages/plugin-local-auth/src/db.ts
Normal file
98
packages/plugin-local-auth/src/db.ts
Normal file
@ -0,0 +1,98 @@
|
|||||||
|
import bcrypt from 'bcryptjs'
|
||||||
|
import { JSONFileSyncPreset } from 'lowdb/node'
|
||||||
|
import { mkSingleton } from 'pockethost'
|
||||||
|
import { UserId } from 'pockethost/src/common/schema/BaseFields'
|
||||||
|
import { PLUGIN_DATA } from './constants'
|
||||||
|
import { info } from './log'
|
||||||
|
|
||||||
|
export type UserFields = {
|
||||||
|
id: UserId
|
||||||
|
username: string
|
||||||
|
passwordHash: string
|
||||||
|
isAdmin: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
export const DbService = mkSingleton(async () => {
|
||||||
|
const db = JSONFileSyncPreset<{
|
||||||
|
defaultUid: UserId
|
||||||
|
users: { [_: UserId]: UserFields }
|
||||||
|
}>(PLUGIN_DATA('users.json'), { users: {}, defaultUid: '' })
|
||||||
|
|
||||||
|
const getUserByExactCriteria = (criteria: Partial<UserFields>) => {
|
||||||
|
return Object.values(db.data.users).find((user) =>
|
||||||
|
Object.entries(criteria).every(
|
||||||
|
([k, v]) => user[k as keyof UserFields] === v,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const deleteUserByExactCriteria = (criteria: Partial<UserFields>) => {
|
||||||
|
const user = getUserByExactCriteria(criteria)
|
||||||
|
if (user) {
|
||||||
|
db.update((data) => {
|
||||||
|
delete data.users[user.id]
|
||||||
|
})
|
||||||
|
info(`Deleted user ${user.username}`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const addUser = (username: string, password: string, isAdmin = false) => {
|
||||||
|
if (getUserByExactCriteria({ username })) {
|
||||||
|
throw new Error(`User ${username} already exists`)
|
||||||
|
}
|
||||||
|
const id = username
|
||||||
|
const passwordHash = bcrypt.hashSync(password, 10)
|
||||||
|
db.update((data) => {
|
||||||
|
data.users = {
|
||||||
|
...data.users,
|
||||||
|
[id]: { id, username, passwordHash, isAdmin },
|
||||||
|
}
|
||||||
|
})
|
||||||
|
info(`Added user ${username}/${password}`)
|
||||||
|
return getFirstUserByExactCriteria({ id })!
|
||||||
|
}
|
||||||
|
|
||||||
|
const getAllUsersByExactCriteria = (criteria: Partial<UserFields>) => {
|
||||||
|
return Object.values(db.data.users).filter((user) =>
|
||||||
|
Object.entries(criteria).every(
|
||||||
|
([k, v]) => user[k as keyof UserFields] === v,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const getFirstUserByExactCriteria = (criteria: Partial<UserFields>) => {
|
||||||
|
return getAllUsersByExactCriteria(criteria)[0]
|
||||||
|
}
|
||||||
|
|
||||||
|
const authenticate = (
|
||||||
|
username: string,
|
||||||
|
password: string,
|
||||||
|
): UserFields | undefined => {
|
||||||
|
const user = getFirstUserByExactCriteria({ username })
|
||||||
|
if (user && bcrypt.compareSync(password, user.passwordHash)) {
|
||||||
|
return user
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const getDefaultUser = (): UserFields | undefined => {
|
||||||
|
return getFirstUserByExactCriteria({ id: db.data.defaultUid })
|
||||||
|
}
|
||||||
|
|
||||||
|
const setDefaultUser = (user: UserFields) => {
|
||||||
|
db.update((data) => {
|
||||||
|
data.defaultUid = user.id
|
||||||
|
})
|
||||||
|
info(`Set default user to ${user.username}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
addUser,
|
||||||
|
getUserByExactCriteria,
|
||||||
|
deleteUserByExactCriteria,
|
||||||
|
getAllUsersByExactCriteria,
|
||||||
|
getFirstUserByExactCriteria,
|
||||||
|
authenticate,
|
||||||
|
getDefaultUser,
|
||||||
|
setDefaultUser,
|
||||||
|
}
|
||||||
|
})
|
3
packages/plugin-local-auth/src/index.ts
Normal file
3
packages/plugin-local-auth/src/index.ts
Normal file
@ -0,0 +1,3 @@
|
|||||||
|
import { plugin } from './plugin'
|
||||||
|
|
||||||
|
export default plugin
|
@ -0,0 +1,15 @@
|
|||||||
|
/// <reference path="../../../../pockethost/src/instance-app/types/all.d.ts" />
|
||||||
|
|
||||||
|
$app.onBeforeServe().add((e) => {
|
||||||
|
const dao = $app.dao()
|
||||||
|
const { mkLog } = /** @type {Lib} */ (require(`${__hooks}/_ph_lib.js`))
|
||||||
|
|
||||||
|
const log = mkLog(`plugin-local-auth`)
|
||||||
|
|
||||||
|
if (!$os.getenv(`PH_PLUGIN_LOCAL_AUTH_ENABLED`)) {
|
||||||
|
log(`Starting `)
|
||||||
|
} else {
|
||||||
|
log(`Not enabled. Skipping...`)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
})
|
5
packages/plugin-local-auth/src/log.ts
Normal file
5
packages/plugin-local-auth/src/log.ts
Normal file
@ -0,0 +1,5 @@
|
|||||||
|
import { LoggerService } from 'pockethost'
|
||||||
|
import { PLUGIN_NAME } from './constants'
|
||||||
|
|
||||||
|
const logger = LoggerService().create(PLUGIN_NAME)
|
||||||
|
export const { dbg, info, error } = logger
|
89
packages/plugin-local-auth/src/plugin.ts
Normal file
89
packages/plugin-local-auth/src/plugin.ts
Normal file
@ -0,0 +1,89 @@
|
|||||||
|
import { produce } from 'immer'
|
||||||
|
import inquirer from 'inquirer'
|
||||||
|
import {
|
||||||
|
PocketHostPlugin,
|
||||||
|
onAfterPluginsLoadedAction,
|
||||||
|
onAuthenticateUserFilter,
|
||||||
|
onCliCommandsFilter,
|
||||||
|
onInstanceConfigFilter,
|
||||||
|
onNewInstanceRecordFilter,
|
||||||
|
onSettingsFilter,
|
||||||
|
} from 'pockethost'
|
||||||
|
import { UserId } from 'pockethost/src/common/schema/BaseFields'
|
||||||
|
import { AuthCommand } from './AuthCommand'
|
||||||
|
import {
|
||||||
|
PLUGIN_NAME,
|
||||||
|
PLUGIN_NAME_CONSTANT_CASE,
|
||||||
|
PROJECT_DIR,
|
||||||
|
settings,
|
||||||
|
} from './constants'
|
||||||
|
import { DbService } from './db'
|
||||||
|
import { dbg, info } from './log'
|
||||||
|
|
||||||
|
export type WithUid = { uid: UserId }
|
||||||
|
|
||||||
|
export const plugin: PocketHostPlugin = async ({}) => {
|
||||||
|
dbg(`initializing ${PLUGIN_NAME}`)
|
||||||
|
|
||||||
|
const { getDefaultUser, addUser, setDefaultUser, authenticate } =
|
||||||
|
await DbService({})
|
||||||
|
|
||||||
|
onInstanceConfigFilter(async (config) => {
|
||||||
|
return produce(config, (draft) => {
|
||||||
|
draft.env[`PH_${PLUGIN_NAME_CONSTANT_CASE}_ENABLED`] = true.toString()
|
||||||
|
draft.binds.hooks.push({
|
||||||
|
src: PROJECT_DIR(`src/instance-app/hooks/**/*`),
|
||||||
|
base: PROJECT_DIR(`src/instance-app/hooks`),
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
onCliCommandsFilter(async (commands) => {
|
||||||
|
return [...commands, await AuthCommand()]
|
||||||
|
})
|
||||||
|
|
||||||
|
onSettingsFilter(async (allSettings) => ({ ...allSettings, ...settings }))
|
||||||
|
|
||||||
|
onNewInstanceRecordFilter<Partial<WithUid>>(async (instance) => {
|
||||||
|
const uid = instance.uid || getDefaultUser()?.id
|
||||||
|
|
||||||
|
return { ...instance, uid }
|
||||||
|
})
|
||||||
|
|
||||||
|
onAuthenticateUserFilter(async (userId, context) => {
|
||||||
|
const { username, password } = context
|
||||||
|
return userId || authenticate(username, password)?.id
|
||||||
|
})
|
||||||
|
|
||||||
|
onAfterPluginsLoadedAction(async () => {
|
||||||
|
if (getDefaultUser()) return
|
||||||
|
info(`Local auth needs a default admin user.`)
|
||||||
|
const answers = await inquirer.prompt<{
|
||||||
|
username: string
|
||||||
|
password: string
|
||||||
|
}>([
|
||||||
|
{
|
||||||
|
type: 'input',
|
||||||
|
name: 'username',
|
||||||
|
message: 'Username',
|
||||||
|
validate: (input) => {
|
||||||
|
if (!input) return 'Username cannot be empty'
|
||||||
|
return true
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: 'password',
|
||||||
|
name: 'password',
|
||||||
|
message: 'Password',
|
||||||
|
validate: (input) => {
|
||||||
|
if (!input) return 'Password cannot be empty'
|
||||||
|
return true
|
||||||
|
},
|
||||||
|
},
|
||||||
|
])
|
||||||
|
const { username, password } = answers
|
||||||
|
const user = addUser(username, password, true)
|
||||||
|
setDefaultUser(user)
|
||||||
|
info(`User ${user.username} created as admin.`)
|
||||||
|
})
|
||||||
|
}
|
20
packages/plugin-local-auth/tsconfig.json
Normal file
20
packages/plugin-local-auth/tsconfig.json
Normal file
@ -0,0 +1,20 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"lib": ["ES2021.String"],
|
||||||
|
"allowJs": true,
|
||||||
|
"checkJs": true,
|
||||||
|
"esModuleInterop": true,
|
||||||
|
"forceConsistentCasingInFileNames": true,
|
||||||
|
"resolveJsonModule": true,
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"sourceMap": true,
|
||||||
|
"strict": true,
|
||||||
|
"target": "ESNext",
|
||||||
|
"module": "ESNext",
|
||||||
|
"moduleResolution": "node",
|
||||||
|
"noUncheckedIndexedAccess": true,
|
||||||
|
"strictNullChecks": true,
|
||||||
|
"noEmit": true
|
||||||
|
},
|
||||||
|
"include": ["**/*.ts"]
|
||||||
|
}
|
@ -8,6 +8,7 @@ export enum CoreFilters {
|
|||||||
CliCommands = 'core_cli_commands',
|
CliCommands = 'core_cli_commands',
|
||||||
ServeSlugs = 'core_serve_slugs',
|
ServeSlugs = 'core_serve_slugs',
|
||||||
SpawnConfig = 'core_spawn_config',
|
SpawnConfig = 'core_spawn_config',
|
||||||
|
AuthenticateUser = 'core_authenticate_user',
|
||||||
GetInstanceByRequestInfo = 'core_get_instance_by_request_info',
|
GetInstanceByRequestInfo = 'core_get_instance_by_request_info',
|
||||||
GetInstanceById = 'core_get_instance_by_id',
|
GetInstanceById = 'core_get_instance_by_id',
|
||||||
InstanceConfig = 'core_instance_config',
|
InstanceConfig = 'core_instance_config',
|
||||||
|
Loading…
x
Reference in New Issue
Block a user