mirror of https://github.com/requarks/wiki
You can not select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
606 lines
22 KiB
606 lines
22 KiB
import fs from 'node:fs/promises'
|
|
import path from 'node:path'
|
|
import { load } from 'js-yaml'
|
|
import { and, eq, inArray } from 'drizzle-orm'
|
|
import { parseModuleProps } from '../helpers/common.ts'
|
|
import { sites as sitesTable, storage as storageTable } from '../db/schema.ts'
|
|
import type { ModuleProp } from '../helpers/common.ts'
|
|
|
|
/** The kinds of content a target can be asked to hold. */
|
|
export const CONTENT_TYPES = ['pages', 'images', 'documents', 'others', 'large'] as const
|
|
|
|
/**
|
|
* The module every site stores its content in, and the only one that is guaranteed to work: assets
|
|
* and pages live in the wiki database. It cannot be disabled, as that would leave content nowhere.
|
|
*/
|
|
const DB_MODULE = 'db'
|
|
|
|
/** An action a module knows how to run on demand, as declared by its `definition.yml`. */
|
|
export interface StorageAction {
|
|
/** Key of the handler on the module implementation, i.e. what gets called. */
|
|
handler: string
|
|
label: string
|
|
hint: string
|
|
/** Shown in red, and turned into a confirmation prompt by the admin area. */
|
|
warn?: string
|
|
icon: string
|
|
}
|
|
|
|
/** A storage module, as declared by its `definition.yml`. */
|
|
export interface StorageDefinition {
|
|
key: string
|
|
title: string
|
|
description: string
|
|
icon: string
|
|
banner: string
|
|
vendor: string
|
|
website: string
|
|
contentTypes: {
|
|
defaultTypesEnabled: string[]
|
|
defaultLargeThreshold: string
|
|
}
|
|
assetDelivery: {
|
|
isStreamingSupported: boolean
|
|
isDirectAccessSupported: boolean
|
|
defaultStreamingEnabled: boolean
|
|
defaultDirectAccessEnabled: boolean
|
|
}
|
|
versioning: {
|
|
isSupported: boolean
|
|
/** Versioning is inherent to the module and cannot be turned off, as in a git history. */
|
|
isForceEnabled: boolean
|
|
defaultEnabled: boolean
|
|
}
|
|
/** Declared by modules that cannot be configured by hand, e.g. an app installed on a provider. */
|
|
setup?: {
|
|
handler: string
|
|
defaultValues: Record<string, any>
|
|
}
|
|
props: Record<string, ModuleProp>
|
|
actions: StorageAction[]
|
|
/**
|
|
* Whether a `storage.ts` sits next to the definition.
|
|
*
|
|
* No module ships one yet, so every target is configuration-only for now: nothing reads or writes
|
|
* content through a module. Actions and setup are gated on this, so that the admin area never
|
|
* offers to run something that has no implementation behind it.
|
|
*/
|
|
hasImplementation: boolean
|
|
}
|
|
|
|
/** A configured target: the module definition, plus how this site has it set up. */
|
|
export interface StorageTarget {
|
|
id: string
|
|
module: string
|
|
isEnabled: boolean
|
|
title: string
|
|
description: string
|
|
icon: string
|
|
banner: string
|
|
vendor: string
|
|
website: string
|
|
contentTypes: {
|
|
activeTypes: string[]
|
|
largeThreshold: string
|
|
}
|
|
assetDelivery: {
|
|
isStreamingSupported: boolean
|
|
isDirectAccessSupported: boolean
|
|
streaming: boolean
|
|
directAccess: boolean
|
|
}
|
|
versioning: {
|
|
isSupported: boolean
|
|
isForceEnabled: boolean
|
|
enabled: boolean
|
|
}
|
|
setup?: {
|
|
handler: string
|
|
state: string
|
|
values: Record<string, any>
|
|
}
|
|
props: Record<string, ModuleProp>
|
|
config: Record<string, any>
|
|
actions: StorageAction[]
|
|
}
|
|
|
|
/** The shape a target is written with. Every field is optional, i.e. it doubles as a patch. */
|
|
export interface StorageTargetInput {
|
|
id: string
|
|
isEnabled?: boolean
|
|
contentTypes?: {
|
|
activeTypes?: string[]
|
|
largeThreshold?: string
|
|
}
|
|
assetDelivery?: {
|
|
streaming?: boolean
|
|
directAccess?: boolean
|
|
}
|
|
versioning?: {
|
|
enabled?: boolean
|
|
}
|
|
config?: Record<string, any>
|
|
}
|
|
|
|
/** What a module implementation is expected to export, once any of them do. */
|
|
export interface StorageModule {
|
|
/** Advance a multi-step setup process, returning what the admin area should do next. */
|
|
setup?: (targetId: string, state: Record<string, any>) => Promise<Record<string, any>>
|
|
/** Undo whatever `setup` configured, so that it can be started over. */
|
|
setupDestroy?: (targetId: string) => Promise<void>
|
|
/** Handlers named by the definition's actions. */
|
|
[handler: string]: any
|
|
}
|
|
|
|
/**
|
|
* Storage model
|
|
*
|
|
* A storage target is one module configured for one site — S3 for assets, git for pages, and so on.
|
|
* Each module lives in `modules/storage/<key>/definition.yml`, which declares what it supports and
|
|
* what it needs configured. Every site gets a row per module (see `syncSite`), so a target always
|
|
* has a stable ID whether or not it has ever been enabled.
|
|
*
|
|
* Nothing dispatches content to targets yet: pages and assets are read and written straight from the
|
|
* database, and no module ships an implementation. What this model handles is the configuration those
|
|
* modules will read once they exist.
|
|
*/
|
|
class Storage {
|
|
/** Definitions read from disk, refreshed by `refreshFromDisk()`. */
|
|
definitions: StorageDefinition[] = []
|
|
|
|
/** Implementations loaded by `ensureModule()`, keyed by module. */
|
|
modules: Record<string, StorageModule> = {}
|
|
|
|
/**
|
|
* Load the storage module definitions from disk.
|
|
*/
|
|
async refreshFromDisk(): Promise<void> {
|
|
const storagePath = path.join(WIKI.SERVERPATH, 'modules/storage')
|
|
const definitions: StorageDefinition[] = []
|
|
try {
|
|
for (const dir of await fs.readdir(storagePath)) {
|
|
const raw = await fs.readFile(path.join(storagePath, dir, 'definition.yml'), 'utf8')
|
|
const parsed = load(raw) as Record<string, any>
|
|
// -> The directory name is the key, as it is for every other module type
|
|
parsed.key = dir
|
|
// -> Props carry a display `order`, applied once here so that every consumer — the admin
|
|
// area included — reads them in the order the module meant them to be shown in
|
|
parsed.props = Object.fromEntries(
|
|
Object.entries(parseModuleProps(parsed.props ?? {})).sort(
|
|
([, a], [, b]) => a.order - b.order
|
|
)
|
|
)
|
|
// -> Declared as a map keyed by handler, which is far more readable in YAML than a list of
|
|
// objects, but the handler has to travel with the action for it to be callable
|
|
parsed.actions = Object.entries(parsed.actions ?? {}).map(([handler, action]) => ({
|
|
handler,
|
|
...(action as Omit<StorageAction, 'handler'>)
|
|
}))
|
|
parsed.versioning = {
|
|
isSupported: false,
|
|
isForceEnabled: false,
|
|
defaultEnabled: false,
|
|
...parsed.versioning
|
|
}
|
|
parsed.hasImplementation = await this.hasImplementation(dir)
|
|
definitions.push(parsed as StorageDefinition)
|
|
}
|
|
// -> The database target first, then alphabetically: it is the one every site starts with
|
|
this.definitions = definitions.sort((a, b) =>
|
|
a.key === DB_MODULE ? -1 : b.key === DB_MODULE ? 1 : a.title.localeCompare(b.title)
|
|
)
|
|
WIKI.logger.info(`Found ${this.definitions.length} storage modules [ OK ]`)
|
|
} catch (err: any) {
|
|
this.definitions = []
|
|
WIKI.logger.error(
|
|
`Could not read the storage module definitions at ${storagePath} [ FAILED ]`
|
|
)
|
|
WIKI.logger.error(err.message)
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Whether the module has any code to run, as opposed to only a definition
|
|
*/
|
|
async hasImplementation(key: string): Promise<boolean> {
|
|
try {
|
|
await fs.access(path.join(WIKI.SERVERPATH, 'modules/storage', key, 'storage.ts'))
|
|
return true
|
|
} catch {
|
|
return false
|
|
}
|
|
}
|
|
|
|
/**
|
|
* A single definition, or null when nothing on disk declares that key
|
|
*/
|
|
getDefinition(key: string): StorageDefinition | null {
|
|
return this.definitions.find((d) => d.key === key) ?? null
|
|
}
|
|
|
|
/**
|
|
* Give a site a row per installed module, and drop rows for modules no longer on disk.
|
|
*
|
|
* Existing rows are left alone: their settings belong to the site, whereas everything the
|
|
* definition declares is read from disk on every request rather than copied into the row.
|
|
*/
|
|
async syncSite(siteId: string): Promise<void> {
|
|
const existing = await WIKI.db
|
|
.select({ module: storageTable.module })
|
|
.from(storageTable)
|
|
.where(eq(storageTable.siteId, siteId))
|
|
const existingKeys = existing.map((t) => t.module)
|
|
const definedKeys = this.definitions.map((d) => d.key)
|
|
|
|
for (const definition of this.definitions) {
|
|
if (existingKeys.includes(definition.key)) {
|
|
continue
|
|
}
|
|
await WIKI.db.insert(storageTable).values({
|
|
siteId,
|
|
module: definition.key,
|
|
// -> Content has to land somewhere from the moment a site exists
|
|
isEnabled: definition.key === DB_MODULE,
|
|
contentTypes: {
|
|
activeTypes: definition.contentTypes?.defaultTypesEnabled ?? [],
|
|
largeThreshold: definition.contentTypes?.defaultLargeThreshold ?? '5MB'
|
|
},
|
|
assetDelivery: {
|
|
streaming: definition.assetDelivery?.defaultStreamingEnabled ?? false,
|
|
directAccess: definition.assetDelivery?.defaultDirectAccessEnabled ?? false
|
|
},
|
|
versioning: {
|
|
enabled: definition.versioning.isForceEnabled || definition.versioning.defaultEnabled
|
|
},
|
|
config: this.buildConfig(definition.key),
|
|
state: definition.setup ? { setup: 'notconfigured' } : {}
|
|
})
|
|
}
|
|
|
|
// -> A module removed from disk should not linger in the admin list
|
|
const orphaned = existingKeys.filter((key) => !definedKeys.includes(key))
|
|
if (orphaned.length > 0) {
|
|
await WIKI.db
|
|
.delete(storageTable)
|
|
.where(and(eq(storageTable.siteId, siteId), inArray(storageTable.module, orphaned)))
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Register the installed storage modules for every site. Called at boot, after the sites cache.
|
|
*/
|
|
async syncAllSites(): Promise<void> {
|
|
WIKI.logger.info('Registering storage targets for all sites...')
|
|
const sites = await WIKI.db.select({ id: sitesTable.id }).from(sitesTable)
|
|
for (const site of sites) {
|
|
await WIKI.models.storage.syncSite(site.id)
|
|
}
|
|
WIKI.logger.info(`Registered storage targets for ${sites.length} sites [ OK ]`)
|
|
}
|
|
|
|
/**
|
|
* The stored target rows, without anything merged in from disk
|
|
*/
|
|
async getTargets({
|
|
siteId,
|
|
enabledOnly = false
|
|
}: { siteId?: string; enabledOnly?: boolean } = {}) {
|
|
const conditions = [
|
|
siteId ? eq(storageTable.siteId, siteId) : undefined,
|
|
enabledOnly ? eq(storageTable.isEnabled, true) : undefined
|
|
].filter(Boolean)
|
|
return WIKI.db
|
|
.select()
|
|
.from(storageTable)
|
|
.where(conditions.length > 0 ? and(...conditions) : undefined)
|
|
}
|
|
|
|
/**
|
|
* Every target of a site, in the order the admin area lists them.
|
|
*
|
|
* Config values are completed from the module's declared defaults, so a prop added to a module
|
|
* after a target was configured is returned with its default rather than as a missing key.
|
|
*/
|
|
async getSiteTargets(siteId: string): Promise<StorageTarget[]> {
|
|
const rows = await this.getTargets({ siteId })
|
|
const targets: StorageTarget[] = []
|
|
// -> Driven by the definitions rather than by the rows, so that the list is ordered the same way
|
|
// and a module dropped on disk without a restart is simply absent instead of half-present
|
|
for (const definition of this.definitions) {
|
|
const row = rows.find((t) => t.module === definition.key)
|
|
if (!row) {
|
|
continue
|
|
}
|
|
const contentTypes = (row.contentTypes ?? {}) as Record<string, any>
|
|
const assetDelivery = (row.assetDelivery ?? {}) as Record<string, any>
|
|
const versioning = (row.versioning ?? {}) as Record<string, any>
|
|
targets.push({
|
|
id: row.id,
|
|
module: definition.key,
|
|
isEnabled: row.isEnabled,
|
|
title: definition.title,
|
|
description: definition.description,
|
|
icon: definition.icon,
|
|
banner: definition.banner,
|
|
vendor: definition.vendor,
|
|
website: definition.website,
|
|
contentTypes: {
|
|
activeTypes: contentTypes.activeTypes ?? [],
|
|
largeThreshold: contentTypes.largeThreshold ?? '5MB'
|
|
},
|
|
assetDelivery: {
|
|
isStreamingSupported: definition.assetDelivery?.isStreamingSupported ?? false,
|
|
isDirectAccessSupported: definition.assetDelivery?.isDirectAccessSupported ?? false,
|
|
streaming: assetDelivery.streaming ?? false,
|
|
directAccess: assetDelivery.directAccess ?? false
|
|
},
|
|
versioning: {
|
|
isSupported: definition.versioning.isSupported,
|
|
isForceEnabled: definition.versioning.isForceEnabled,
|
|
enabled: versioning.enabled ?? false
|
|
},
|
|
// -> Only offered for a module that can actually run its setup process
|
|
...(definition.setup &&
|
|
definition.hasImplementation && {
|
|
setup: {
|
|
handler: definition.setup.handler,
|
|
state: ((row.state ?? {}) as Record<string, any>).setup ?? 'notconfigured',
|
|
values: this.buildSetupValues(definition, row.config as Record<string, any>)
|
|
}
|
|
}),
|
|
props: definition.props,
|
|
config: this.buildConfig(definition.key, {}, row.config as Record<string, any>),
|
|
// -> Same reasoning as setup: an action with nothing behind it cannot be run
|
|
actions: definition.hasImplementation ? definition.actions : []
|
|
})
|
|
}
|
|
return targets
|
|
}
|
|
|
|
/**
|
|
* A single target of a site, or null if there is no such target
|
|
*/
|
|
async getSiteTargetById(siteId: string, id: string): Promise<StorageTarget | null> {
|
|
return (await this.getSiteTargets(siteId)).find((t) => t.id === id) ?? null
|
|
}
|
|
|
|
/**
|
|
* The values the setup form starts from: whatever the module stored, else its declared defaults.
|
|
*/
|
|
buildSetupValues(
|
|
definition: StorageDefinition,
|
|
stored: Record<string, any> = {}
|
|
): Record<string, any> {
|
|
const values: Record<string, any> = {}
|
|
for (const [key, value] of Object.entries(definition.setup?.defaultValues ?? {})) {
|
|
values[key] = stored[key] ?? value
|
|
}
|
|
return values
|
|
}
|
|
|
|
/**
|
|
* Merge incoming config values onto the ones already stored, keeping only what the module declares.
|
|
*
|
|
* Read-only props are never taken from the client: they are declarations of something the server
|
|
* does not support changing, so the stored value (or the module default) always wins.
|
|
*/
|
|
buildConfig(
|
|
moduleKey: string,
|
|
incoming: Record<string, any> = {},
|
|
existing: Record<string, any> = {}
|
|
): Record<string, any> {
|
|
const props = this.getDefinition(moduleKey)?.props ?? {}
|
|
const config: Record<string, any> = {}
|
|
for (const [key, prop] of Object.entries(props)) {
|
|
const current = existing[key] !== undefined ? existing[key] : prop.default
|
|
config[key] = prop.readOnly || incoming[key] === undefined ? current : incoming[key]
|
|
}
|
|
return config
|
|
}
|
|
|
|
/**
|
|
* Check incoming config values against what the module declares.
|
|
*
|
|
* The props are a runtime declaration read from a YAML file, so no JSON Schema can cover them —
|
|
* without this, a boolean prop would happily store the string `"maybe"`.
|
|
*
|
|
* @returns The reason it is invalid, or null when it is fine
|
|
*/
|
|
validateConfig(moduleKey: string, incoming: Record<string, any> = {}): string | null {
|
|
const props = this.getDefinition(moduleKey)?.props ?? {}
|
|
for (const [key, value] of Object.entries(incoming)) {
|
|
const prop = props[key]
|
|
// -> Unknown keys are dropped by buildConfig rather than refused: a module losing a prop must
|
|
// not make the admin area unable to save
|
|
if (!prop || prop.readOnly || value === undefined) {
|
|
continue
|
|
}
|
|
if (prop.enum) {
|
|
// -> Enum entries are declared as `value` or `value|label`
|
|
const allowed = prop.enum.map((entry) => entry.split('|')[0])
|
|
if (!allowed.includes(`${value}`)) {
|
|
return `"${value}" is not a valid value for ${prop.title}.`
|
|
}
|
|
continue
|
|
}
|
|
switch (prop.type) {
|
|
case 'boolean':
|
|
if (typeof value !== 'boolean') {
|
|
return `${prop.title} must be true or false.`
|
|
}
|
|
break
|
|
case 'number':
|
|
if (typeof value !== 'number' || !Number.isFinite(value)) {
|
|
return `${prop.title} must be a number.`
|
|
}
|
|
break
|
|
default:
|
|
if (typeof value !== 'string') {
|
|
return `${prop.title} must be a string.`
|
|
}
|
|
}
|
|
}
|
|
return null
|
|
}
|
|
|
|
/**
|
|
* Check a target patch against what its module supports.
|
|
*
|
|
* @returns The reason it is invalid, or null when it is fine
|
|
*/
|
|
validateTarget(target: StorageTarget, patch: StorageTargetInput): string | null {
|
|
const definition = this.getDefinition(target.module)!
|
|
if (patch.isEnabled === false && target.module === DB_MODULE) {
|
|
return 'The database storage target cannot be disabled, as content would have nowhere to live.'
|
|
}
|
|
if (patch.isEnabled === true && target.setup && target.setup.state !== 'configured') {
|
|
return `${definition.title} cannot be enabled until its setup process is completed.`
|
|
}
|
|
const activeTypes = patch.contentTypes?.activeTypes
|
|
if (activeTypes) {
|
|
const unknown = activeTypes.find(
|
|
(type) => !(CONTENT_TYPES as readonly string[]).includes(type)
|
|
)
|
|
if (unknown) {
|
|
return `"${unknown}" is not a valid content type.`
|
|
}
|
|
if (target.module === DB_MODULE && !activeTypes.includes('pages')) {
|
|
return 'The database storage target must keep holding pages.'
|
|
}
|
|
}
|
|
const largeThreshold = patch.contentTypes?.largeThreshold
|
|
if (largeThreshold !== undefined && !/^\d+(\.\d+)?\s?(B|KB|MB|GB|TB)$/i.test(largeThreshold)) {
|
|
return `"${largeThreshold}" is not a valid size threshold. Use a size such as "5MB".`
|
|
}
|
|
return this.validateConfig(target.module, patch.config)
|
|
}
|
|
|
|
/**
|
|
* Apply a patch to a target.
|
|
*
|
|
* Capabilities the module does not have are stored as off whatever was asked for, and versioning it
|
|
* forces on is stored as on — the admin area disables those controls, but the values are the
|
|
* module's to decide, not the client's.
|
|
*
|
|
* @param target The target as it currently stands, which the caller already has from validating
|
|
* @returns Whether the target was written
|
|
*/
|
|
async updateTarget(
|
|
siteId: string,
|
|
target: StorageTarget,
|
|
patch: StorageTargetInput
|
|
): Promise<boolean> {
|
|
const definition = this.getDefinition(target.module)!
|
|
|
|
const values: Partial<typeof storageTable.$inferInsert> = {}
|
|
if (patch.isEnabled !== undefined) {
|
|
values.isEnabled = patch.isEnabled
|
|
}
|
|
if (patch.contentTypes) {
|
|
values.contentTypes = {
|
|
activeTypes: patch.contentTypes.activeTypes ?? target.contentTypes.activeTypes,
|
|
largeThreshold: patch.contentTypes.largeThreshold ?? target.contentTypes.largeThreshold
|
|
}
|
|
}
|
|
if (patch.assetDelivery) {
|
|
values.assetDelivery = {
|
|
streaming:
|
|
definition.assetDelivery.isStreamingSupported &&
|
|
(patch.assetDelivery.streaming ?? target.assetDelivery.streaming),
|
|
directAccess:
|
|
definition.assetDelivery.isDirectAccessSupported &&
|
|
(patch.assetDelivery.directAccess ?? target.assetDelivery.directAccess)
|
|
}
|
|
}
|
|
if (patch.versioning) {
|
|
values.versioning = {
|
|
enabled:
|
|
definition.versioning.isForceEnabled ||
|
|
(definition.versioning.isSupported &&
|
|
(patch.versioning.enabled ?? target.versioning.enabled))
|
|
}
|
|
}
|
|
if (patch.config !== undefined) {
|
|
values.config = this.buildConfig(target.module, patch.config, target.config)
|
|
}
|
|
if (Object.keys(values).length < 1) {
|
|
return false
|
|
}
|
|
|
|
const result = await WIKI.db
|
|
.update(storageTable)
|
|
.set(values)
|
|
.where(and(eq(storageTable.siteId, siteId), eq(storageTable.id, target.id)))
|
|
return (result.rowCount ?? 0) > 0
|
|
}
|
|
|
|
/**
|
|
* Ensure a module's implementation is loaded
|
|
*
|
|
* @returns The implementation, or null when the module has none or it failed to load
|
|
*/
|
|
async ensureModule(key: string): Promise<StorageModule | null> {
|
|
if (this.modules[key]) {
|
|
return this.modules[key]
|
|
}
|
|
if (!this.getDefinition(key)?.hasImplementation) {
|
|
return null
|
|
}
|
|
try {
|
|
// -> Extension-sensitive dynamic import, invisible to the type checker
|
|
this.modules[key] = (await import(`../modules/storage/${key}/storage.ts`)).default
|
|
WIKI.logger.debug(`Activated storage module ${key} [ OK ]`)
|
|
return this.modules[key]
|
|
} catch (err: any) {
|
|
WIKI.logger.warn(`Failed to load storage module ${key} [ FAILED ]`)
|
|
WIKI.logger.warn(err)
|
|
return null
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Run one of the actions a module declares.
|
|
*
|
|
* @throws When the module cannot be loaded or does not implement the handler
|
|
*/
|
|
async executeAction(target: StorageTarget, handler: string): Promise<void> {
|
|
const mod = await this.ensureModule(target.module)
|
|
if (!mod) {
|
|
throw new Error(`The ${target.title} storage module has no implementation installed.`)
|
|
}
|
|
if (typeof mod[handler] !== 'function') {
|
|
throw new Error(`The ${target.title} storage module does not implement "${handler}".`)
|
|
}
|
|
await mod[handler](target)
|
|
}
|
|
|
|
/**
|
|
* Advance a module's setup process.
|
|
*
|
|
* @returns What the admin area should do next, as decided by the module
|
|
* @throws When the module cannot be loaded or has no setup process
|
|
*/
|
|
async runSetup(target: StorageTarget, state: Record<string, any>): Promise<Record<string, any>> {
|
|
const mod = await this.ensureModule(target.module)
|
|
if (!mod?.setup) {
|
|
throw new Error(`The ${target.title} storage module has no setup process.`)
|
|
}
|
|
return mod.setup(target.id, state)
|
|
}
|
|
|
|
/**
|
|
* Undo a module's setup, so that it can be started over.
|
|
*
|
|
* @throws When the module cannot be loaded or has no setup process
|
|
*/
|
|
async destroySetup(target: StorageTarget): Promise<void> {
|
|
const mod = await this.ensureModule(target.module)
|
|
if (!mod?.setupDestroy) {
|
|
throw new Error(`The ${target.title} storage module has no setup process.`)
|
|
}
|
|
await mod.setupDestroy(target.id)
|
|
}
|
|
}
|
|
|
|
export const storage = new Storage()
|