Files
wekan/models/lib/cloudStorage.js
T
Lauri Ojansivu dca9427bfb New features:
- **Admin Panel / Attachments: the MongoDB GridFS page notes how to make legacy
  files visible.** A translatable line under the "MongoDB GridFS Storage" title
  reminds admins: "To have attachments and avatars visible, move them from
  CollectionFS to any other Storage." Thanks to Claude.
- **Admin Panel / Attachments: the Google Cloud Storage page now documents the
  required bucket permission.** A note under the "GCS Storage" title explains that
  the service account needs the **Storage Object Admin** role
  (`roles/storage.objectAdmin`) on the bucket (Cloud Console → Cloud Storage →
  Buckets → your bucket → Permissions → Grant access → add the service account →
  role "Storage Object Admin"), and that the bucket must exist in the same
  project — otherwise "Test connection" fails with `storage.objects.list denied`.
  Thanks to Claude.
- **Admin Panel / Attachments: a "Save" button next to the Enabled / Read toggles
  at the top of each cloud storage.** S3, Azure and Google Cloud Storage now have
  a Save button directly below the "[ ] Enabled  [ ] Read" row, so those toggles
  can be saved without scrolling to the bottom of the (now quite long) storage
  page. It saves the same way as the existing bottom Save button — the server
  merges that provider's config and preserves secrets left blank — and the bottom
  Save button is kept. (Filesystem and GridFS have only a Read toggle, which
  already saves the instant it is toggled, so they need no button.) Thanks to Claude.

Fixes:

- **Admin Panel / Attachments: "Run MongoDB compact" now actually compacts the
  database.** MongoDB refuses `compact` on an active replica-set primary unless
  `force: true` is given, so every collection failed with "will not run compact
  on an active replica set primary … use force:true to force" — and because the
  usual Meteor setup is a single-node replica set (only a primary, no
  secondaries), nothing was compacted at all and no disk space was reclaimed. The
  primary is now compacted with `force: true` (secondaries are still compacted
  without it, so the primary stays available while they run), so the GridFS
  collections are actually rewritten and freed space is returned to the
  filesystem. Thanks to Claude.
- **Admin Panel / Attachments: clearer cloud "Test connection" errors, and cloud
  config inputs are trimmed.** Testing an Azure/GCS/S3 connection used to report a
  single generic `Incomplete configuration or adapter not installed` even when the
  real problem was specific — e.g. Azure rejecting the config with `Invalid URL`
  (a stray space/newline in the account name, or a bad endpoint / connection
  string). `testCloudConnection` now reports the actual cause: adapter not
  installed, required fields missing, the adapter's own configuration error (such
  as `Invalid URL`), or the real `listFiles` error (auth failure, container not
  found, …). It also pre-validates the config and gives an actionable message
  before the adapter turns it into a cryptic error — e.g. for Azure it explains
  that the **Storage account name** must be just the account name (3–24 lowercase
  letters/numbers, e.g. `wekanstorage`, not a full `https://…` URL and with no
  spaces), or that the **Connection string** is malformed; and when Azure still
  returns `Invalid URL` the message appends which field to check. In addition, all
  cloud-storage text fields are now trimmed when read from the form, so
  leading/trailing whitespace from copy-paste no longer produces an invalid URL.

Thanks to xet7 and Claude.
2026-06-06 09:28:05 +03:00

337 lines
11 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* Multi-cloud storage manager (server only).
*
* Uses the @tweedegolf/storage-abstraction adapters so WeKan can store
* attachments on any of: S3-compatible object storage (AWS S3, MinIO,
* Cloudflare R2, Backblaze B2, Wasabi, DigitalOcean Spaces, Ceph…), Azure Blob
* Storage and Google Cloud Storage — in addition to the native filesystem and
* GridFS backends.
*
* IMPORTANT: we deliberately do NOT use the storage-abstraction `Storage`
* wrapper class. Its constructor loads the per-provider adapter with a dynamic
* `require(variable)` which bundlers (rspack/webpack) cannot analyse — that
* produces "Critical dependency: the request of a dependency is an expression"
* warnings and, worse, the adapter package is then missing from the bundle at
* runtime. Instead we `require()` each adapter package with a literal string
* (statically analysable, so it is bundled) and instantiate the adapter class
* directly. Each adapter implements the same interface the `Storage` wrapper
* delegates to (addFileFromStream / getFileAsStream / removeFile / listFiles),
* so the rest of WeKan is unaffected.
*
* Configuration (including secrets) lives in the `attachmentStorageSettings`
* MongoDB document under `storageConfig.{s3,azure,gcs}` and is editable from
* Admin Panel / Attachments. Adapters are built lazily and cached; call
* refreshCloudStorageFromSettings() after the settings change.
*
* The adapter packages are required inside try/catch so the app keeps running
* (with cloud storage simply unavailable) when they are not installed yet —
* run `meteor npm install` to enable them.
*/
import { Meteor } from 'meteor/meteor';
import AttachmentStorageSettings from '/models/attachmentStorageSettings';
import {
STORAGE_NAME_S3,
STORAGE_NAME_AZURE,
STORAGE_NAME_GCS,
CLOUD_STORAGE_NAMES,
} from './fileStoreConstants';
// provider -> { storage, bucketName, read, write } where `storage` is the
// adapter instance (it exposes the same methods the Storage wrapper would).
const adapters = {};
// The exported adapter class name per provider, used after require().
const ADAPTER_CLASS_NAME = {
[STORAGE_NAME_S3]: 'AdapterAmazonS3',
[STORAGE_NAME_AZURE]: 'AdapterAzureBlob',
[STORAGE_NAME_GCS]: 'AdapterGoogleCloud',
};
// Cache of loaded adapter classes (null = tried and unavailable).
const adapterClassCache = {};
/**
* Load a provider's adapter class. Uses a literal-string require per provider
* so the bundler includes the package (no dynamic-expression require).
*/
function loadAdapterClass(provider) {
if (Object.prototype.hasOwnProperty.call(adapterClassCache, provider)) {
return adapterClassCache[provider];
}
let mod = null;
try {
switch (provider) {
case STORAGE_NAME_S3:
// eslint-disable-next-line global-require
mod = require('@tweedegolf/sab-adapter-amazon-s3');
break;
case STORAGE_NAME_AZURE:
// eslint-disable-next-line global-require
mod = require('@tweedegolf/sab-adapter-azure-blob');
break;
case STORAGE_NAME_GCS:
// eslint-disable-next-line global-require
mod = require('@tweedegolf/sab-adapter-google-cloud');
break;
default:
mod = null;
}
} catch (error) {
console.error(
`[cloudStorage] Adapter for "${provider}" is not installed. ` +
'Run "meteor npm install" to enable it.', error.message,
);
}
const cls = mod ? mod[ADAPTER_CLASS_NAME[provider]] : null;
adapterClassCache[provider] = cls || null;
return adapterClassCache[provider];
}
/**
* Translate WeKan's storageConfig.<provider> document into the configuration
* object expected by the storage-abstraction adapter. Returns null when the
* mandatory fields for that provider are missing.
*
* Note: the field is `type` (storage-abstraction v2.x), not `provider`.
*/
function buildProviderConfig(provider, cfg) {
if (!cfg) {
return null;
}
if (provider === STORAGE_NAME_S3) {
if (!cfg.bucket || !cfg.accessKeyId || !cfg.secretAccessKey) {
return null;
}
const config = {
type: 's3',
bucketName: cfg.bucket,
accessKeyId: cfg.accessKeyId,
secretAccessKey: cfg.secretAccessKey,
region: cfg.region || 'us-east-1',
};
// Custom endpoint (MinIO, R2, Wasabi, Ceph, DigitalOcean Spaces…).
if (cfg.endpoint) {
config.endpoint = cfg.endpoint;
// Most non-AWS S3-compatible servers (notably MinIO) require path-style.
config.forcePathStyle = cfg.forcePathStyle !== false;
}
return config;
}
if (provider === STORAGE_NAME_AZURE) {
if (!cfg.bucket) {
return null;
}
if (cfg.connectionString) {
return {
type: 'azure',
connectionString: cfg.connectionString,
bucketName: cfg.bucket,
};
}
if (cfg.accountName && cfg.accountKey) {
return {
type: 'azure',
accountName: cfg.accountName,
accountKey: cfg.accountKey,
bucketName: cfg.bucket,
};
}
return null;
}
if (provider === STORAGE_NAME_GCS) {
if (!cfg.bucket) {
return null;
}
const config = {
type: 'gcs',
bucketName: cfg.bucket,
};
if (cfg.projectId) {
config.projectId = cfg.projectId;
}
// Service-account credentials may be supplied either as a path to a key
// file or as the JSON key contents pasted into the admin panel.
if (cfg.keyFilename) {
config.keyFilename = cfg.keyFilename;
} else if (cfg.credentials) {
try {
config.credentials = typeof cfg.credentials === 'string'
? JSON.parse(cfg.credentials)
: cfg.credentials;
} catch (error) {
console.error('[cloudStorage] Invalid GCS credentials JSON:', error.message);
return null;
}
} else {
// Fall back to Application Default Credentials (GOOGLE_APPLICATION_CREDENTIALS).
}
return config;
}
return null;
}
/**
* Catch obviously-wrong cloud config before the adapter turns it into a cryptic
* "Invalid URL". Returns a human-friendly error string, or null when it looks OK.
*/
function validateCloudConfig(provider, cfg) {
if (!cfg) {
return 'Configuration is empty';
}
if (provider === STORAGE_NAME_AZURE) {
const connectionString = (cfg.connectionString || '').trim();
if (connectionString) {
if (!/AccountName=/i.test(connectionString) ||
!/(AccountKey=|SharedAccessSignature=)/i.test(connectionString)) {
return 'Connection string looks malformed. Expected e.g. ' +
'DefaultEndpointsProtocol=https;AccountName=NAME;AccountKey=KEY;EndpointSuffix=core.windows.net';
}
return null;
}
const accountName = (cfg.accountName || '').trim();
if (!/^[a-z0-9]{3,24}$/.test(accountName)) {
return 'Storage account name must be 3–24 lowercase letters/numbers — just the name ' +
'(e.g. "wekanstorage"), not a URL and with no spaces. Or use a Connection string instead.';
}
}
if (provider === STORAGE_NAME_S3) {
const endpoint = (cfg.endpoint || '').trim();
if (endpoint && /\s/.test(endpoint)) {
return 'Endpoint must not contain spaces (e.g. https://s3.eu-west-1.amazonaws.com).';
}
}
return null;
}
/** Construct a provider adapter instance from its WeKan config, or null. */
function createAdapterInstance(provider, cfg) {
const AdapterClass = loadAdapterClass(provider);
if (!AdapterClass) {
return null;
}
const config = buildProviderConfig(provider, cfg);
if (!config) {
return null;
}
try {
const instance = new AdapterClass(config);
// Adapters record configuration problems on `configError` instead of throwing.
if (instance.configError) {
console.error(`[cloudStorage] ${provider} configuration error:`, instance.configError);
return null;
}
return instance;
} catch (error) {
console.error(`[cloudStorage] Failed to initialize ${provider} storage:`, error.message);
return null;
}
}
function buildAdapter(provider, cfg) {
const instance = createAdapterInstance(provider, cfg);
if (!instance) {
return null;
}
return {
storage: instance,
bucketName: cfg.bucket,
read: cfg.read !== false,
write: cfg.write !== false,
};
}
/**
* Rebuild every cloud adapter from the persisted settings document. Disabled or
* misconfigured providers are removed from the cache.
*/
export async function refreshCloudStorageFromSettings() {
if (!Meteor.isServer) {
return;
}
let settings;
try {
settings = await AttachmentStorageSettings.findOneAsync({});
} catch (error) {
console.error('[cloudStorage] Could not load storage settings:', error.message);
return;
}
const storageConfig = (settings && settings.storageConfig) || {};
CLOUD_STORAGE_NAMES.forEach(provider => {
const cfg = storageConfig[provider];
if (cfg && cfg.enabled !== false) {
const adapter = buildAdapter(provider, cfg);
if (adapter) {
adapters[provider] = adapter;
return;
}
}
delete adapters[provider];
});
}
/** Returns the cached adapter ({ storage, bucketName, read, write }) or null. */
export function getCloudAdapter(provider) {
return adapters[provider] || null;
}
/** True when the given provider has a usable, configured adapter. */
export function isCloudConfigured(provider) {
return !!adapters[provider];
}
/** True for any storage name backed by the cloud abstraction. */
export function isCloudStorageName(storageName) {
return CLOUD_STORAGE_NAMES.includes(storageName);
}
/**
* Try to reach a provider with the given (possibly unsaved) config by listing
* its bucket. Returns { ok, error }. Used by the "Test connection" button.
*/
export async function testCloudConnection(provider, cfg) {
// Build the instance step by step so the actual reason is reported to the
// admin instead of a single generic message.
const AdapterClass = loadAdapterClass(provider);
if (!AdapterClass) {
return { ok: false, error: `Cloud storage adapter for "${provider}" is not installed` };
}
const validationError = validateCloudConfig(provider, cfg);
if (validationError) {
return { ok: false, error: validationError };
}
const config = buildProviderConfig(provider, cfg);
if (!config) {
return { ok: false, error: 'Incomplete configuration: required fields are missing' };
}
let instance;
try {
instance = new AdapterClass(config);
} catch (error) {
return { ok: false, error: error.message || 'Failed to initialize storage adapter' };
}
if (instance.configError) {
let error = String(instance.configError).replace(/^\[configError\]\s*/, '');
// "Invalid URL" from Azure means the account name / connection string is bad.
if (provider === STORAGE_NAME_AZURE && /invalid url/i.test(error)) {
error += ' — check the Storage account name (just the name, e.g. "wekanstorage", ' +
'no https:// or spaces) or the Connection string.';
}
return { ok: false, error };
}
try {
const result = await instance.listFiles(cfg.bucket);
if (result && result.error) {
return { ok: false, error: result.error };
}
return { ok: true };
} catch (error) {
return { ok: false, error: error.message || 'Connection failed' };
}
}