feat: add 3 new plugin hooks : onBeforeBlobUpload, onBeforeDraftAutoSave, onBeforeEditDraft (#586)
Co-authored-by: Linus Rath <minipixxelinfo@gmail.com>
This commit is contained in:
co-authored by
Linus Rath
parent
782974ecdb
commit
752e71198c
@@ -1344,6 +1344,8 @@ export default function Home() {
|
|||||||
draft = fullDraft;
|
draft = fullDraft;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
draft = await emailHooks.onBeforeEditDraft.transform(draft);
|
||||||
|
|
||||||
const bodyText = draft.bodyValues
|
const bodyText = draft.bodyValues
|
||||||
? Object.values(draft.bodyValues).map(v => v.value).join('\n')
|
? Object.values(draft.bodyValues).map(v => v.value).join('\n')
|
||||||
: '';
|
: '';
|
||||||
|
|||||||
@@ -17,7 +17,7 @@ import { isFilePreviewable } from "@/lib/file-preview";
|
|||||||
import { buildQuotedHtmlBlock, serializeEditorContent } from "@/components/email/quoted-html";
|
import { buildQuotedHtmlBlock, serializeEditorContent } from "@/components/email/quoted-html";
|
||||||
import { buildSignatureBlock } from "@/components/email/signature-block";
|
import { buildSignatureBlock } from "@/components/email/signature-block";
|
||||||
import { emailHooks, contactHooks } from "@/lib/plugin-hooks";
|
import { emailHooks, contactHooks } from "@/lib/plugin-hooks";
|
||||||
import type { OutgoingEmail, RecipientSuggestion } from "@/lib/plugin-types";
|
import type { AlmostSavedDraft, OutgoingEmail, RecipientSuggestion } from "@/lib/plugin-types";
|
||||||
import { useAuthStore } from "@/stores/auth-store";
|
import { useAuthStore } from "@/stores/auth-store";
|
||||||
import { useIdentityStore } from "@/stores/identity-store";
|
import { useIdentityStore } from "@/stores/identity-store";
|
||||||
import { useProMultiAccountIdentities, stripCrossAccountIdentityPrefix } from "@/hooks/use-pro-multi-account-identities";
|
import { useProMultiAccountIdentities, stripCrossAccountIdentityPrefix } from "@/hooks/use-pro-multi-account-identities";
|
||||||
@@ -53,6 +53,7 @@ import { isValidEmail } from "@/lib/validation";
|
|||||||
import { RichTextEditor } from "@/components/email/rich-text-editor";
|
import { RichTextEditor } from "@/components/email/rich-text-editor";
|
||||||
import type { Editor } from "@tiptap/react";
|
import type { Editor } from "@tiptap/react";
|
||||||
import { htmlToPlainText as htmlToPlainTextShared } from "@/lib/html-to-text";
|
import { htmlToPlainText as htmlToPlainTextShared } from "@/lib/html-to-text";
|
||||||
|
import { fileStorage } from "@/lib/plugin-storage";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Derives the text/plain alternative from the composer's HTML body, preserving
|
* Derives the text/plain alternative from the composer's HTML body, preserving
|
||||||
@@ -1129,7 +1130,16 @@ export function EmailComposer({
|
|||||||
const controller = newAttachments[i].abortController;
|
const controller = newAttachments[i].abortController;
|
||||||
try {
|
try {
|
||||||
if (controller?.signal.aborted) continue;
|
if (controller?.signal.aborted) continue;
|
||||||
const { blobId } = await client.uploadBlob(file);
|
|
||||||
|
const fileId = generateUUID();
|
||||||
|
await fileStorage.saveFile(fileId, file);
|
||||||
|
|
||||||
|
const newFileId = await emailHooks.onBeforeBlobUpload.transform(fileId);
|
||||||
|
|
||||||
|
const newFile = await fileStorage.getFile(newFileId) || file;
|
||||||
|
await fileStorage.deleteFile(newFileId);
|
||||||
|
|
||||||
|
const { blobId } = await client.uploadBlob(newFile);
|
||||||
|
|
||||||
if (controller?.signal.aborted) continue;
|
if (controller?.signal.aborted) continue;
|
||||||
setAttachments(prev =>
|
setAttachments(prev =>
|
||||||
@@ -1337,21 +1347,36 @@ export function EmailComposer({
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
const previousDraftId = draftIdRef.current;
|
const previousDraftId = draftIdRef.current;
|
||||||
|
let savedDraft : AlmostSavedDraft = {
|
||||||
|
to: toAddresses,
|
||||||
|
subject: subject || t('no_subject'),
|
||||||
|
body: plainTextMode ? body : htmlToPlainText(body),
|
||||||
|
cc: ccAddresses,
|
||||||
|
bcc: bccAddresses,
|
||||||
|
identityId: currentIdentityRawId,
|
||||||
|
fromEmail,
|
||||||
|
draftId: previousDraftId || undefined,
|
||||||
|
attachments: uploadedAttachments,
|
||||||
|
fromName,
|
||||||
|
htmlBody: plainTextMode ? undefined : body
|
||||||
|
}
|
||||||
|
savedDraft = await emailHooks.onBeforeDraftAutoSave.transform(savedDraft);
|
||||||
|
|
||||||
// Use the JMAP client and raw identity id for the *owning* account
|
// Use the JMAP client and raw identity id for the *owning* account
|
||||||
// - falls back to active client for single-account / same-account
|
// - falls back to active client for single-account / same-account
|
||||||
// identities. See `composerClient` derivation above.
|
// identities. See `composerClient` derivation above.
|
||||||
const savedDraftId = await composerClient.createDraft(
|
const savedDraftId = await composerClient.createDraft(
|
||||||
toAddresses,
|
savedDraft.to,
|
||||||
subject || t('no_subject'),
|
savedDraft.subject,
|
||||||
plainTextMode ? body : htmlToPlainText(body),
|
savedDraft.body,
|
||||||
ccAddresses,
|
savedDraft.cc,
|
||||||
bccAddresses,
|
savedDraft.bcc,
|
||||||
currentIdentityRawId,
|
savedDraft.identityId,
|
||||||
fromEmail,
|
savedDraft.fromEmail,
|
||||||
previousDraftId || undefined,
|
savedDraft.draftId,
|
||||||
uploadedAttachments,
|
savedDraft.attachments,
|
||||||
fromName,
|
savedDraft.fromName,
|
||||||
plainTextMode ? undefined : body
|
savedDraft.htmlBody
|
||||||
);
|
);
|
||||||
|
|
||||||
// Update the ref synchronously so a queued save sees the new id and
|
// Update the ref synchronously so a queued save sees the new id and
|
||||||
|
|||||||
@@ -183,7 +183,14 @@ export const emailHooks = {
|
|||||||
onComposerOpen: new HookBus(),
|
onComposerOpen: new HookBus(),
|
||||||
onBeforeEmailSend: new HookBus(),
|
onBeforeEmailSend: new HookBus(),
|
||||||
onAfterEmailSend: new HookBus(),
|
onAfterEmailSend: new HookBus(),
|
||||||
|
// Transform hook - fires before the draft is auto-saved to the server.
|
||||||
|
// Receive fields passed to client.createDraft and may mutate fields in place.
|
||||||
|
// Return false to cancel the auto-save or a the fields.
|
||||||
|
onBeforeDraftAutoSave: new HookBus(),
|
||||||
onDraftAutoSave: new HookBus(),
|
onDraftAutoSave: new HookBus(),
|
||||||
|
// Transform hook - fires before a draft is created from an email in draft mailbox.
|
||||||
|
// Receive a Email object and may mutate fields in place.
|
||||||
|
onBeforeEditDraft: new HookBus(),
|
||||||
onBeforeEmailDelete: new HookBus(),
|
onBeforeEmailDelete: new HookBus(),
|
||||||
onAfterEmailDelete: new HookBus(),
|
onAfterEmailDelete: new HookBus(),
|
||||||
onBeforeEmailMove: new HookBus(),
|
onBeforeEmailMove: new HookBus(),
|
||||||
@@ -232,6 +239,12 @@ export const emailHooks = {
|
|||||||
// attachment. Handler receives AttachmentInfo (size/type/name only - the
|
// attachment. Handler receives AttachmentInfo (size/type/name only - the
|
||||||
// raw file is not exposed). Return false to refuse the upload.
|
// raw file is not exposed). Return false to refuse the upload.
|
||||||
onBeforeAttachmentUpload: new HookBus(),
|
onBeforeAttachmentUpload: new HookBus(),
|
||||||
|
// Intercept hook fired before a file is uploaded to JMAP server.
|
||||||
|
// It is fired after onBeforeAttachmentUpload.
|
||||||
|
// Handler receive the {file: File, blobId: 'undefined'} object.
|
||||||
|
// If it uploaded, it must return the object with true blobId.
|
||||||
|
// Here, the raw file sended is exposed and can be modified or replaced.
|
||||||
|
onBeforeBlobUpload: new HookBus(),
|
||||||
// Observer fired after an attachment has been uploaded and its blobId is
|
// Observer fired after an attachment has been uploaded and its blobId is
|
||||||
// available. Handler receives AttachmentInfo with `blobId` populated.
|
// available. Handler receives AttachmentInfo with `blobId` populated.
|
||||||
onAfterAttachmentUpload: new HookBus(),
|
onAfterAttachmentUpload: new HookBus(),
|
||||||
|
|||||||
@@ -60,6 +60,7 @@ const PERMISSION_LABELS: Record<string, { title: string; body: string }> = {
|
|||||||
'crypto:full': { title: 'Full cryptographic access (high risk)', body: 'Runs with full cryptographic access in a privileged, same-origin context. It can read your message bodies and private keys, store key material, and sign/encrypt on your behalf. Only enable plugins you fully trust — this is comparable to a full-access browser extension.' },
|
'crypto:full': { title: 'Full cryptographic access (high risk)', body: 'Runs with full cryptographic access in a privileged, same-origin context. It can read your message bodies and private keys, store key material, and sign/encrypt on your behalf. Only enable plugins you fully trust — this is comparable to a full-access browser extension.' },
|
||||||
'email:raw-send': { title: 'Send raw messages', body: 'Submit fully-formed (e.g. signed or encrypted) messages on your behalf.' },
|
'email:raw-send': { title: 'Send raw messages', body: 'Submit fully-formed (e.g. signed or encrypted) messages on your behalf.' },
|
||||||
'email:blob-read': { title: 'Read raw message content', body: 'Fetch the raw bytes of your messages and attachments (needed to decrypt and verify them).' },
|
'email:blob-read': { title: 'Read raw message content', body: 'Fetch the raw bytes of your messages and attachments (needed to decrypt and verify them).' },
|
||||||
|
'email:blob-write': { title: 'Alterate raw message content', body: 'get the raw file content before it is uploaded to alterate it just before is is sended to server. (needed to encrypt)' },
|
||||||
'email:render-takeover': { title: 'Replace rendered email content', body: 'Replace the displayed content of an opened message (e.g. to show decrypted text and a signature-verification badge).' },
|
'email:render-takeover': { title: 'Replace rendered email content', body: 'Replace the displayed content of an opened message (e.g. to show decrypted text and a signature-verification badge).' },
|
||||||
'calendar:read': { title: 'Read your calendar', body: 'Access events, calendars, RSVPs, and reminders.' },
|
'calendar:read': { title: 'Read your calendar', body: 'Access events, calendars, RSVPs, and reminders.' },
|
||||||
'calendar:write': { title: 'Modify your calendar', body: 'Create, edit, or delete events.' },
|
'calendar:write': { title: 'Modify your calendar', body: 'Create, edit, or delete events.' },
|
||||||
|
|||||||
@@ -9,6 +9,8 @@ import { useAuthStore } from '@/stores/auth-store';
|
|||||||
import { useEmailStore } from '@/stores/email-store';
|
import { useEmailStore } from '@/stores/email-store';
|
||||||
import { apiFetch } from '../browser-navigation';
|
import { apiFetch } from '../browser-navigation';
|
||||||
import { awaitDialog } from './host-dialog';
|
import { awaitDialog } from './host-dialog';
|
||||||
|
import { fileStorage} from '../plugin-storage'
|
||||||
|
import { generateUUID } from '../utils';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Methods only callable from the privileged (same-origin) tier. These expose
|
* Methods only callable from the privileged (same-origin) tier. These expose
|
||||||
@@ -19,6 +21,8 @@ import { awaitDialog } from './host-dialog';
|
|||||||
const PRIVILEGED_ONLY_METHODS = new Set<string>([
|
const PRIVILEGED_ONLY_METHODS = new Set<string>([
|
||||||
'jmap.fetchBlob',
|
'jmap.fetchBlob',
|
||||||
'jmap.sendRaw',
|
'jmap.sendRaw',
|
||||||
|
'upfiles.get',
|
||||||
|
'upfiles.set',
|
||||||
]);
|
]);
|
||||||
|
|
||||||
const PERM_PER_METHOD: Record<string, Permission | null> = {
|
const PERM_PER_METHOD: Record<string, Permission | null> = {
|
||||||
@@ -38,6 +42,11 @@ const PERM_PER_METHOD: Record<string, Permission | null> = {
|
|||||||
// jmap (privileged-tier only; see PRIVILEGED_ONLY_METHODS)
|
// jmap (privileged-tier only; see PRIVILEGED_ONLY_METHODS)
|
||||||
'jmap.fetchBlob': 'email:blob-read',
|
'jmap.fetchBlob': 'email:blob-read',
|
||||||
'jmap.sendRaw': 'email:raw-send',
|
'jmap.sendRaw': 'email:raw-send',
|
||||||
|
// uploaded files (privileged-tier only) :
|
||||||
|
// Used only to get a file before it is uploaded to alterate it.
|
||||||
|
// To just read, use jmap.fetchBlob.
|
||||||
|
'upfiles.get' : 'email:blob-write',
|
||||||
|
'upfiles.save' : 'email:blob-write',
|
||||||
// admin
|
// admin
|
||||||
'admin.getConfig': 'admin:config',
|
'admin.getConfig': 'admin:config',
|
||||||
'admin.getAllConfig': 'admin:config',
|
'admin.getAllConfig': 'admin:config',
|
||||||
@@ -263,6 +272,19 @@ async function doJmapSendRaw(
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ─── Uploaded files in IndexedDB (privileged tier) ──────────────────────────
|
||||||
|
|
||||||
|
async function getFile(fileID:string): Promise<File | null> {
|
||||||
|
return await fileStorage.getFile(fileID)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function saveFile(formerFileID:string, file: File): Promise<string> {
|
||||||
|
const fileId = generateUUID();
|
||||||
|
await fileStorage.saveFile(fileId, file);
|
||||||
|
await fileStorage.deleteFile(formerFileID);
|
||||||
|
return fileId;
|
||||||
|
}
|
||||||
|
|
||||||
// ─── admin config (same as before) ────────────────────────────
|
// ─── admin config (same as before) ────────────────────────────
|
||||||
|
|
||||||
async function adminGetAll(pluginId: string): Promise<Record<string, unknown>> {
|
async function adminGetAll(pluginId: string): Promise<Record<string, unknown>> {
|
||||||
@@ -335,6 +357,8 @@ export async function dispatchApiCall(
|
|||||||
args[1] as string,
|
args[1] as string,
|
||||||
args[2] as { delayedUntil?: string; envelopeRecipients?: string[] } | undefined,
|
args[2] as { delayedUntil?: string; envelopeRecipients?: string[] } | undefined,
|
||||||
);
|
);
|
||||||
|
case 'upfiles.get' : return getFile(args[0] as string);
|
||||||
|
case 'upfiles.save' : return saveFile(args[0] as string, args[1] as File);
|
||||||
|
|
||||||
case 'admin.getConfig': return adminGet(plugin.id, args[0] as string);
|
case 'admin.getConfig': return adminGet(plugin.id, args[0] as string);
|
||||||
case 'admin.getAllConfig': return adminGetAll(plugin.id);
|
case 'admin.getAllConfig': return adminGetAll(plugin.id);
|
||||||
|
|||||||
@@ -189,6 +189,16 @@ function buildPluginApi(manifest: PluginManifest) {
|
|||||||
opts?: { delayedUntil?: string; envelopeRecipients?: string[] },
|
opts?: { delayedUntil?: string; envelopeRecipients?: string[] },
|
||||||
) => callApi('jmap.sendRaw', [rawBytes, identityId, opts]),
|
) => callApi('jmap.sendRaw', [rawBytes, identityId, opts]),
|
||||||
},
|
},
|
||||||
|
/**
|
||||||
|
* Used to alterate files before they are uploaded to server.
|
||||||
|
* Edited files are saved on indexedDB and remove once the upload to server begins.
|
||||||
|
*/
|
||||||
|
upfiles: {
|
||||||
|
save: (formerFileId:string, file:File) =>
|
||||||
|
callApi('upfiles.save', [formerFileId, file]) as Promise<string>,
|
||||||
|
get: (fileId:string) =>
|
||||||
|
callApi('upfiles.get', [fileId]) as Promise<File>,
|
||||||
|
},
|
||||||
toast: {
|
toast: {
|
||||||
success: (m: string) => { void callApi('toast.success', [m]); },
|
success: (m: string) => { void callApi('toast.success', [m]); },
|
||||||
error: (m: string) => { void callApi('toast.error', [m]); },
|
error: (m: string) => { void callApi('toast.error', [m]); },
|
||||||
|
|||||||
+23
-3
@@ -1,12 +1,13 @@
|
|||||||
// IndexedDB storage for plugin/theme binary blobs (JS bundles, CSS, previews)
|
// IndexedDB storage for plugin/theme binary blobs (JS bundles, CSS, previews)
|
||||||
|
|
||||||
const DB_NAME = 'bulwark-plugins';
|
const DB_NAME = 'bulwark-plugins';
|
||||||
// Bumped to 2 to add the theme-skin store; existing stores are preserved.
|
// Bumped to 3 to add the file-plugin store; existing stores are preserved.
|
||||||
const DB_VERSION = 2;
|
const DB_VERSION = 3;
|
||||||
const STORE_PLUGINS = 'plugin-code';
|
const STORE_PLUGINS = 'plugin-code';
|
||||||
const STORE_THEMES = 'theme-css';
|
const STORE_THEMES = 'theme-css';
|
||||||
const STORE_THEME_SKINS = 'theme-skin';
|
const STORE_THEME_SKINS = 'theme-skin';
|
||||||
const STORE_PREVIEWS = 'previews';
|
const STORE_PREVIEWS = 'previews';
|
||||||
|
const STORE_FILE_ACCESS_PLUGIN = 'file-plugin'
|
||||||
|
|
||||||
function openDB(): Promise<IDBDatabase> {
|
function openDB(): Promise<IDBDatabase> {
|
||||||
return new Promise((resolve, reject) => {
|
return new Promise((resolve, reject) => {
|
||||||
@@ -26,6 +27,9 @@ function openDB(): Promise<IDBDatabase> {
|
|||||||
if (!db.objectStoreNames.contains(STORE_PREVIEWS)) {
|
if (!db.objectStoreNames.contains(STORE_PREVIEWS)) {
|
||||||
db.createObjectStore(STORE_PREVIEWS);
|
db.createObjectStore(STORE_PREVIEWS);
|
||||||
}
|
}
|
||||||
|
if (!db.objectStoreNames.contains(STORE_FILE_ACCESS_PLUGIN)) {
|
||||||
|
db.createObjectStore(STORE_FILE_ACCESS_PLUGIN);
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
request.onsuccess = () => resolve(request.result);
|
request.onsuccess = () => resolve(request.result);
|
||||||
@@ -33,7 +37,7 @@ function openDB(): Promise<IDBDatabase> {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
async function putItem(storeName: string, key: string, value: string | Blob): Promise<void> {
|
async function putItem(storeName: string, key: string, value: string | Blob | File): Promise<void> {
|
||||||
const db = await openDB();
|
const db = await openDB();
|
||||||
return new Promise((resolve, reject) => {
|
return new Promise((resolve, reject) => {
|
||||||
const tx = db.transaction(storeName, 'readwrite');
|
const tx = db.transaction(storeName, 'readwrite');
|
||||||
@@ -111,3 +115,19 @@ export const pluginStorage = {
|
|||||||
await deleteItem(STORE_PREVIEWS, id);
|
await deleteItem(STORE_PREVIEWS, id);
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Used in host-api for plugins to access to raw data files
|
||||||
|
* to modify them before they are uploaded.
|
||||||
|
*/
|
||||||
|
export const fileStorage = {
|
||||||
|
async saveFile(fileId: string, file: File): Promise<void> {
|
||||||
|
await putItem(STORE_FILE_ACCESS_PLUGIN, fileId, file);
|
||||||
|
},
|
||||||
|
async getFile(fileId: string): Promise<File | null> {
|
||||||
|
return getItem<File>(STORE_FILE_ACCESS_PLUGIN, fileId);
|
||||||
|
},
|
||||||
|
async deleteFile(fileId: string): Promise<void> {
|
||||||
|
await deleteItem(STORE_FILE_ACCESS_PLUGIN, fileId);
|
||||||
|
},
|
||||||
|
}
|
||||||
@@ -674,6 +674,22 @@ export interface OutgoingEmail {
|
|||||||
/** Free-form custom headers added by the composer or earlier handlers */
|
/** Free-form custom headers added by the composer or earlier handlers */
|
||||||
headers?: Record<string, string>;
|
headers?: Record<string, string>;
|
||||||
}
|
}
|
||||||
|
/**
|
||||||
|
* Passed to onBeforeDraftAutoSave handlers as a transform value.
|
||||||
|
*/
|
||||||
|
export interface AlmostSavedDraft{
|
||||||
|
to: string[],
|
||||||
|
subject: string,
|
||||||
|
body: string,
|
||||||
|
cc?: string[],
|
||||||
|
bcc?: string[],
|
||||||
|
identityId?: string,
|
||||||
|
fromEmail?: string,
|
||||||
|
draftId?: string,
|
||||||
|
attachments?: Array<{ blobId: string; name: string; type: string; size: number; disposition?: 'attachment' | 'inline'; cid?: string }>,
|
||||||
|
fromName?: string,
|
||||||
|
htmlBody?: string
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Passed to onBeforeReply / onBeforeReplyAll / onBeforeForward intercept hooks.
|
* Passed to onBeforeReply / onBeforeReplyAll / onBeforeForward intercept hooks.
|
||||||
@@ -885,6 +901,8 @@ export const ALL_PERMISSIONS = [
|
|||||||
'email:raw-send',
|
'email:raw-send',
|
||||||
// Fetch a message blob's raw bytes by blobId (for decrypt/verify).
|
// Fetch a message blob's raw bytes by blobId (for decrypt/verify).
|
||||||
'email:blob-read',
|
'email:blob-read',
|
||||||
|
// Upload a file to server (for encrypt).
|
||||||
|
'email:blob-write',
|
||||||
// Replace the rendered body of an opened email (render-takeover).
|
// Replace the rendered body of an opened email (render-takeover).
|
||||||
'email:render-takeover',
|
'email:render-takeover',
|
||||||
'calendar:read', 'calendar:write',
|
'calendar:read', 'calendar:write',
|
||||||
|
|||||||
Generated
-1
@@ -7908,7 +7908,6 @@
|
|||||||
"version": "0.5.23",
|
"version": "0.5.23",
|
||||||
"resolved": "https://registry.npmjs.org/@swc/helpers/-/helpers-0.5.23.tgz",
|
"resolved": "https://registry.npmjs.org/@swc/helpers/-/helpers-0.5.23.tgz",
|
||||||
"integrity": "sha512-5lSsMOTXURePglDfvuAQUqkGek9Hg2kksOYay2m0+XR++b2NWYL/4sWyuvVBIs8oKnJaxkdi9whaL/sqN13afw==",
|
"integrity": "sha512-5lSsMOTXURePglDfvuAQUqkGek9Hg2kksOYay2m0+XR++b2NWYL/4sWyuvVBIs8oKnJaxkdi9whaL/sqN13afw==",
|
||||||
"license": "Apache-2.0",
|
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"peer": true,
|
"peer": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
|||||||
Reference in New Issue
Block a user