ldap-rest 0.1.9 → 0.2.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1 +1 @@
1
- export{asyncHandler,escapeDnValue,escapeLdapFilter,validateDnValue}from"../lib/utils.js";export{BadGatewayError,BadRequestError,ConflictError,ForbiddenError,GatewayTimeoutError,HttpError,NotFoundError,ServiceUnavailableError,TooManyRequestsError,UnauthorizedError,UriTooLongError}from"../lib/errors.js";class DmPlugin{constructor(r){this.registeredHooks={},this.server=r,this.config=r.config,this.logger=r.logger,this.registeredHooks=r.hooks}opNumber(){return this.server.operationSequence++}}export{DmPlugin as default};
1
+ export{asyncHandler,escapeDnValue,escapeLdapFilter,unescapeDnValue,validateDnValue}from"../lib/utils.js";export{BadGatewayError,BadRequestError,ConflictError,ForbiddenError,GatewayTimeoutError,HttpError,NotFoundError,ServiceUnavailableError,TooManyRequestsError,UnauthorizedError,UriTooLongError}from"../lib/errors.js";class DmPlugin{constructor(r){this.registeredHooks={},this.server=r,this.config=r.config,this.logger=r.logger,this.registeredHooks=r.hooks}opNumber(){return this.server.operationSequence++}}export{DmPlugin as default};
@@ -1 +1 @@
1
- import t from"node-fetch";import r from"p-limit";import e from"./plugin.js";class TwakePlugin extends e{constructor(t,e,s,i){if(super(t),this.webadminUrl=this.config[e]||"",this.webadminToken=this.config[s]||"",!this.webadminUrl)throw new Error(`Twake plugin: ${e} is required`);const n="number"==typeof this.config[i]?this.config[i]:10;this.requestLimit=r(n),this.logger.info(`Twake plugin HTTP request concurrency limit set to ${n}`),this.mailAttr=this.config.mail_attribute||"mail",this.displayNameAttr=this.config.display_name_attribute||"displayName"}createHeaders(t){const r={};return this.webadminToken&&(r.Authorization=`Bearer ${this.webadminToken}`),t&&(r["Content-Type"]=t),r}async callWebAdminApi(r,e,s,i,n,a){return this.requestLimit(async()=>{const o={plugin:this.name,event:r,result:"error",dn:i,...a};try{const i={method:s,headers:this.createHeaders(n?"application/json":void 0)};n&&(i.body=n);const a=await t(e,i);a.ok?this.logger.info({...o,result:"success",http_status:a.status,url:e}):this.shouldIgnoreError(a.status,r)?this.logger.debug({...o,result:"ignored",http_status:a.status,http_status_text:a.statusText,url:e}):this.logger.error({...o,http_status:a.status,http_status_text:a.statusText,url:e})}catch(t){this.logger.error({...o,error:t,url:e})}})}shouldIgnoreError(t,r){return 409===t&&r.includes("Alias")}attributeToString(t){return t?Array.isArray(t)?t.length>0?String(t[0]):null:String(t):null}async ldapGetAttributes(t,r){try{const e=r&&r.length>0?r:void 0,s=await this.server.ldap.search({paged:!1,scope:"base",attributes:e},t);return s.searchEntries&&s.searchEntries.length>0?s.searchEntries[0]:null}catch(r){return this.logger.debug(`Could not fetch attributes from DN ${t}: ${r}`),null}}getAliases(t){if(!t)return[];return(Array.isArray(t)?t:[t]).map(t=>Buffer.isBuffer(t)?t.toString("utf-8"):String(t)).map(t=>this.normalizeAlias(t))}normalizeAlias(t){return t.toLowerCase().startsWith("smtp:")?t.substring(5):t}extractMailDomain(t){const r=t.split("@");return 2===r.length?r[1]:null}extractDomainFromDn(t){const r=t.match(/dc=([^,]+)/gi);return r?r.map(t=>t.substring(3)).join("."):"example.com"}}export{TwakePlugin,TwakePlugin as default};
1
+ import t from"node-fetch";import e from"p-limit";import r from"./plugin.js";class TwakePlugin extends r{constructor(t,r,s,i){if(super(t),this.webadminUrl=this.config[r]||"",this.webadminToken=this.config[s]||"",!this.webadminUrl)throw new Error(`Twake plugin: ${r} is required`);this.webadminToken||this.logger.warn(`Twake plugin: No authentication token configured (${s}) - requests will be unauthenticated`);const n="number"==typeof this.config[i]?this.config[i]:10;this.requestLimit=e(n),this.logger.info(`Twake plugin HTTP request concurrency limit set to ${n}`),this.mailAttr=this.config.mail_attribute||"mail",this.displayNameAttr=this.config.display_name_attribute||"displayName"}createHeaders(t){const e={};return this.webadminToken&&(e.Authorization=`Bearer ${this.webadminToken}`),t&&(e["Content-Type"]=t),e}async callWebAdminApi(e,r,s,i,n,a){return this.requestLimit(async()=>{const o={plugin:this.name,event:e,result:"error",dn:i,...a};try{const i={method:s,headers:this.createHeaders(n?"application/json":void 0)};n&&(i.body=n);const a=await t(r,i);a.ok?this.logger.info({...o,result:"success",http_status:a.status,url:r}):this.shouldIgnoreError(a.status,e)?this.logger.debug({...o,result:"ignored",http_status:a.status,http_status_text:a.statusText,url:r}):this.logger.error({...o,http_status:a.status,http_status_text:a.statusText,url:r})}catch(t){this.logger.error({...o,error:t,url:r})}})}shouldIgnoreError(t,e){return 409===t&&e.includes("Alias")}attributeToString(t){return t?Array.isArray(t)?t.length>0?String(t[0]):null:String(t):null}async ldapGetAttributes(t,e){try{const r=e&&e.length>0?e:void 0,s=await this.server.ldap.search({paged:!1,scope:"base",attributes:r},t);return s.searchEntries&&s.searchEntries.length>0?s.searchEntries[0]:null}catch(e){return this.logger.debug(`Could not fetch attributes from DN ${t}: ${e}`),null}}getAliases(t){if(!t)return[];return(Array.isArray(t)?t:[t]).map(t=>Buffer.isBuffer(t)?t.toString("utf-8"):String(t)).map(t=>this.normalizeAlias(t))}normalizeAlias(t){return t.toLowerCase().startsWith("smtp:")?t.substring(5):t}extractMailDomain(t){const e=t.split("@");return 2===e.length?e[1]:null}extractDomainFromDn(t){const e=t.match(/dc=([^,]+)/gi);return e?e.map(t=>t.substring(3)).join("."):"example.com"}}export{TwakePlugin,TwakePlugin as default};
package/dist/bin/index.js CHANGED
@@ -1 +1 @@
1
- import{fileURLToPath as e}from"url";import{join as r,dirname as i}from"path";import s from"express";import o from"body-parser";import{parseConfig as t}from"../lib/parseConfig.js";import n from"../config/args.js";import a from"../lib/ldapActions.js";import{buildLogger as l}from"../logger/winston.js";import{setLogger as d}from"../lib/expressFormatedResponses.js";import g from"../plugins/priority.json.js";export{asyncHandler,escapeDnValue,escapeLdapFilter,escapeRegex,getCompiledRegex,getParentDn,getRdn,isChildOf,launchHooks,launchHooksChained,parseDn,transformSchemas,validateDnValue}from"../lib/utils.js";export{BadGatewayError,BadRequestError,ConflictError,ForbiddenError,GatewayTimeoutError,HttpError,NotFoundError,ServiceUnavailableError,TooManyRequestsError,UnauthorizedError,UriTooLongError}from"../lib/errors.js";class DM{constructor(){this.hooks={},this.loadedPlugins={},this._errorMiddlewareSetup=!1,this.config=t(n),this.app=s(),this.app.use(o.json()),this.app.use(o.urlencoded({extended:!0})),this.logger=l(this.config),this.ldap=new a(this),d(this.logger),this.operationSequence=0;const e=[];if(this.config.plugin){const r=this.config.plugin.find(e=>e.includes("configApi"));let i=this.config.plugin.filter(e=>!e.includes("configApi"));const s=(async()=>{for(const e of g)i.includes(e)&&(i=i.filter(r=>r!==e),await this.loadPlugin(e))})();e.push(s),e.push(s.then(async()=>{const e=i.map(e=>this.loadPlugin(e));await Promise.all(e)})),r&&e.push(Promise.all(e).then(()=>this.loadPlugin(r)))}this.ready=new Promise((r,i)=>{e.length>0?Promise.all(e).then(()=>{this.setupErrorMiddleware(),r()}).catch(e=>i(new Error("Error loading plugins: "+e))):(this.setupErrorMiddleware(),r())})}setupErrorMiddleware(){if(this._errorMiddlewareSetup&&this._errorMiddleware){const e=this.app._router?.stack;if(e){const r=e.findIndex(e=>e.handle===this._errorMiddleware);-1!==r&&e.splice(r,1)}}this._errorMiddleware=(e,r,i,s)=>{const o="statusCode"in e?e.statusCode:500;if(o>=400&&o<500)return this.logger.warn(`Client error ${o} in request ${r.method} ${r.path}: ${e.message}`),i.headersSent?void 0:i.status(o).json({error:e.message});this.logger.error(`Server error ${o} in request ${r.method} ${r.path}: ${e.message}`,{stack:e.stack,url:r.url,method:r.method}),i.headersSent||i.status(o).json({error:"Internal Server Error",message:this.config.debug?e.message:"An error occurred"})},this.app.use(this._errorMiddleware),this._errorMiddlewareSetup=!0}run(){return process.on("uncaughtException",e=>{this.logger.error(`Uncaught exception: ${e.message}`,{stack:e.stack})}),process.on("unhandledRejection",(e,r)=>{this.logger.error(`Unhandled promise rejection: ${e}`,{reason:e,promise:r})}),new Promise((e,r)=>{this.server=this.app.listen(this.config.port,i=>{i?(this.logger.error(`Error starting server: ${i}`),r(i)):(this.logger.debug(`Server started on port ${this.config.port}`),e())})})}stop(){this.app.removeAllListeners(),this.server?.close(),this.logger.debug("Server stopped")}loadPlugin(s){let o,t;if(/:/.test(s)){let e=s.substring(s.indexOf(":")+1);if(s=s.substring(0,s.indexOf(":")),/:/.test(e)){o=e.substring(0,e.indexOf(":")),o||(o=void 0),e=e.substring(e.indexOf(":")+1);try{t=JSON.parse(e),"object"!=typeof t?(this.logger.error(`Overrides for plugin ${s} are not valid: ${e}`),t=void 0):this.logger.debug(`Overrides for plugin ${o||s}: ${e}`)}catch(r){this.logger.error(`Failed to parse overrides for plugin ${s}: ${r}, using ${e}`),t=void 0}}else o=e,o||(o=void 0)}else o=void 0;return this.logger.debug(`Loading plugin ${s}`),s.startsWith("core/")&&(s=s.replace("core/",r(i(e(import.meta.url)),"..","plugins")+"/").replace(/$/,".js")),new Promise((e,r)=>{import(s).then(async i=>{let n;if(i&&i.default&&(i=i.default),t){const e={...this.config,...t};n=new i({...this,config:e})}else n=new i(this);if(!n)return r(new Error(`Unable to load ${s}`));e(await this.registerPlugin(s,n,o)),this.logger.debug(`Plugin ${n.name} loaded`)}).catch(e=>r(new Error(`Failed to load plugin ${s}: ${e}`)))})}async registerPlugin(e,r,i){if(r.name||(r.name=e),i&&(r.name=i),this.loadedPlugins[r.name])return this.logger.info(`Plugin ${e} already loaded as ${r.name}`),!1;if(this.logger.debug(`Registering plugin ${e} as ${r.name}`),r.dependencies)for(const e in r.dependencies)this.loadedPlugins[e]||(this.logger.debug(`Plugin ${r.name} depends on ${e}, loading it first`),await this.loadPlugin(r.dependencies[e]));if(r.api&&(this.logger.debug(`Plugin ${r.name} has API, registering it`),await r.api(this.app),this._errorMiddlewareSetup&&this.setupErrorMiddleware()),r.hooks)for(const e in r.hooks){this.logger.debug(`Plugin ${r.name} has hook ${e}, registering it`);const i=r.hooks[e];if(this.hooks[e]||(this.hooks[e]=[]),!i||"function"!=typeof i)throw new Error(`Plugin ${r.name}: hook ${e} is invalid`);this.hooks[e].push(i)}return this.loadedPlugins[r.name]=r,!0}}export{DM};
1
+ import{fileURLToPath as e}from"url";import{join as r,dirname as i}from"path";import s from"express";import o from"body-parser";import{parseConfig as t}from"../lib/parseConfig.js";import n from"../config/args.js";import a from"../lib/ldapActions.js";import{buildLogger as l}from"../logger/winston.js";import{setLogger as d}from"../lib/expressFormatedResponses.js";import g from"../plugins/priority.json.js";export{asyncHandler,escapeDnValue,escapeLdapFilter,escapeRegex,getCompiledRegex,getParentDn,getRdn,isChildOf,launchHooks,launchHooksChained,parseDn,transformSchemas,unescapeDnValue,validateDnValue}from"../lib/utils.js";export{BadGatewayError,BadRequestError,ConflictError,ForbiddenError,GatewayTimeoutError,HttpError,NotFoundError,ServiceUnavailableError,TooManyRequestsError,UnauthorizedError,UriTooLongError}from"../lib/errors.js";class DM{constructor(){this.hooks={},this.loadedPlugins={},this._errorMiddlewareSetup=!1,this.config=t(n),this.app=s(),this.app.use(o.json()),this.app.use(o.urlencoded({extended:!0})),this.logger=l(this.config),this.ldap=new a(this),d(this.logger),this.operationSequence=0;const e=[];if(this.config.plugin){const r=this.config.plugin.find(e=>e.includes("configApi"));let i=this.config.plugin.filter(e=>!e.includes("configApi"));const s=(async()=>{for(const e of g)i.includes(e)&&(i=i.filter(r=>r!==e),await this.loadPlugin(e))})();e.push(s),e.push(s.then(async()=>{const e=i.map(e=>this.loadPlugin(e));await Promise.all(e)})),r&&e.push(Promise.all(e).then(()=>this.loadPlugin(r)))}this.ready=new Promise((r,i)=>{e.length>0?Promise.all(e).then(()=>{this.setupErrorMiddleware(),r()}).catch(e=>i(new Error("Error loading plugins: "+e))):(this.setupErrorMiddleware(),r())})}setupErrorMiddleware(){if(this._errorMiddlewareSetup&&this._errorMiddleware){const e=this.app._router?.stack;if(e){const r=e.findIndex(e=>e.handle===this._errorMiddleware);-1!==r&&e.splice(r,1)}}this._errorMiddleware=(e,r,i,s)=>{const o="statusCode"in e?e.statusCode:500;if(o>=400&&o<500)return this.logger.warn(`Client error ${o} in request ${r.method} ${r.path}: ${e.message}`),i.headersSent?void 0:i.status(o).json({error:e.message});this.logger.error(`Server error ${o} in request ${r.method} ${r.path}: ${e.message}`,{stack:e.stack,url:r.url,method:r.method}),i.headersSent||i.status(o).json({error:"Internal Server Error",message:this.config.debug?e.message:"An error occurred"})},this.app.use(this._errorMiddleware),this._errorMiddlewareSetup=!0}run(){return process.on("uncaughtException",e=>{this.logger.error(`Uncaught exception: ${e.message}`,{stack:e.stack})}),process.on("unhandledRejection",(e,r)=>{this.logger.error(`Unhandled promise rejection: ${e}`,{reason:e,promise:r})}),new Promise((e,r)=>{this.server=this.app.listen(this.config.port,i=>{i?(this.logger.error(`Error starting server: ${i}`),r(i)):(this.logger.debug(`Server started on port ${this.config.port}`),e())})})}stop(){this.app.removeAllListeners(),this.server?.close(),this.logger.debug("Server stopped")}loadPlugin(s){let o,t;if(/:/.test(s)){let e=s.substring(s.indexOf(":")+1);if(s=s.substring(0,s.indexOf(":")),/:/.test(e)){o=e.substring(0,e.indexOf(":")),o||(o=void 0),e=e.substring(e.indexOf(":")+1);try{t=JSON.parse(e),"object"!=typeof t?(this.logger.error(`Overrides for plugin ${s} are not valid: ${e}`),t=void 0):this.logger.debug(`Overrides for plugin ${o||s}: ${e}`)}catch(r){this.logger.error(`Failed to parse overrides for plugin ${s}: ${r}, using ${e}`),t=void 0}}else o=e,o||(o=void 0)}else o=void 0;return this.logger.debug(`Loading plugin ${s}`),s.startsWith("core/")&&(s=s.replace("core/",r(i(e(import.meta.url)),"..","plugins")+"/").replace(/$/,".js")),new Promise((e,r)=>{import(s).then(async i=>{let n;if(i&&i.default&&(i=i.default),t){const e={...this.config,...t};n=new i({...this,config:e})}else n=new i(this);if(!n)return r(new Error(`Unable to load ${s}`));e(await this.registerPlugin(s,n,o)),this.logger.debug(`Plugin ${n.name} loaded`)}).catch(e=>r(new Error(`Failed to load plugin ${s}: ${e}`)))})}async registerPlugin(e,r,i){if(r.name||(r.name=e),i&&(r.name=i),this.loadedPlugins[r.name])return this.logger.info(`Plugin ${e} already loaded as ${r.name}`),!1;if(this.logger.debug(`Registering plugin ${e} as ${r.name}`),r.dependencies)for(const e in r.dependencies)this.loadedPlugins[e]||(this.logger.debug(`Plugin ${r.name} depends on ${e}, loading it first`),await this.loadPlugin(r.dependencies[e]));if(r.api&&(this.logger.debug(`Plugin ${r.name} has API, registering it`),await r.api(this.app),this._errorMiddlewareSetup&&this.setupErrorMiddleware()),r.hooks)for(const e in r.hooks){this.logger.debug(`Plugin ${r.name} has hook ${e}, registering it`);const i=r.hooks[e];if(this.hooks[e]||(this.hooks[e]=[]),!i||"function"!=typeof i)throw new Error(`Plugin ${r.name}: hook ${e} is invalid`);this.hooks[e].push(i)}return this.loadedPlugins[r.name]=r,!0}}export{DM};
@@ -1 +1 @@
1
- import{fileURLToPath as a}from"url";import{join as _,dirname as e}from"path";const t=[["--port","DM_PORT",8081,"number"],["--plugin","DM_PLUGINS",[],"array","--plugins"],["--log-level","DM_LOG_LEVEL","notice"],["--logger","DM_LOGGER","console"],["--api-prefix","DM_API_PREFIX","/api"],["--mail-domain","DM_MAIL_DOMAIN",[],"array","--mail-domains"],["--ldap-base","DM_LDAP_BASE",""],["--ldap-dn","DM_LDAP_DN","cn=admin,dc=example,dc=com"],["--ldap-pwd","DM_LDAP_PWD","admin"],["--ldap-url","DM_LDAP_URL",["ldap://localhost"],"array","--ldap-urls"],["--ldap-user-main-attribute","DM_LDAP_USER_ATTRIBUTE","uid"],["--ldap-cache-max","DM_LDAP_CACHE_MAX",1e3,"number"],["--ldap-cache-ttl","DM_LDAP_CACHE_TTL",300,"number"],["--ldap-pool-size","DM_LDAP_POOL_SIZE",5,"number"],["--ldap-connection-ttl","DM_LDAP_CONNECTION_TTL",60,"number"],["--schemas-path","DM_SCHEMAS_PATH",_(e(a(import.meta.url)),"..","..","static","schemas")],["--mail-attribute","DM_MAIL_ATTRIBUTE","mail"],["--quota-attribute","DM_QUOTA_ATTRIBUTE","mailQuota"],["--delegation-attribute","DM_DELEGATION_ATTRIBUTE","twakeDelegatedUsers"],["--alias-attribute","DM_ALIAS_ATTRIBUTE","mailAlternateAddress"],["--forward-attribute","DM_FORWARD_ATTRIBUTE","mailForwardingAddress"],["--display-name-attribute","DM_DISPLAY_NAME_ATTRIBUTE","displayName"],["--user-class","DM_USER_CLASSES",["top","twakeAccount","twakeWhitePages"],"array","--user-classes"],["--ldap-top-organization","DM_LDAP_TOP_ORGANIZATION",""],["--ldap-organization-class","DM_LDAP_ORGANIZATION_CLASSES",["top","organizationalUnit","twakeDepartment"],"array","--ldap-organization-classes"],["--ldap-organization-link-attribute","DM_LDAP_ORGANIZATION_LINK_ATTRIBUTE","twakeDepartmentLink"],["--ldap-organization-path-attribute","DM_LDAP_ORGANIZATION_PATH_ATTRIBUTE","twakeDepartmentPath"],["--ldap-organization-path-separator","DM_LDAP_ORGANIZATION_PATH_SEPARATOR"," / "],["--ldap-organization-max-subnodes","DM_LDAP_ORGANIZATION_MAX_SUBNODES",50,"number"],["--ldap-group-base","DM_LDAP_GROUP_BASE",""],["--ldap-groups-main-attribute","DM_LDAP_GROUPS_MAIN_ATTRIBUTE","cn"],["--group-class","DM_GROUP_CLASSES",["top","groupOfNames"],"array","--group-classes"],["--group-allow-unexistent-members","DM_ALLOW_UNEXISTENT_MEMBERS",!1,"boolean"],["--group-default-attributes","DM_GROUP_DEFAULT_ATTRIBUTES",{},"json"],["--group-dummy-user","DM_GROUP_DUMMY_USER","cn=fakeuser"],["--group-schema","DM_GROUP_SCHEMA",_(e(a(import.meta.url)),"..","..","static","schemas","twake","groups.json")],["--external-members-branch","DM_EXTERNAL_MEMBERS_BRANCH","ou=contacts,dc=example,dc=com"],["--external-branch-class","DM_EXTERNAL_BRANCH_CLASSES",["top","inetOrgPerson"],"array","--external-branch-classes"],["--static-path","DM_STATIC_PATH",_(e(a(import.meta.url)),"..","..","static")],["--static-name","DM_STATIC_NAME","static"],["--ldap-flat-schema","DM_LDAP_FLAT_SCHEMA",[],"array","--ldap-flat-schemas"],["--bulk-import-schemas","DM_BULK_IMPORT_SCHEMAS",""],["--bulk-import-max-file-size","DM_BULK_IMPORT_MAX_FILE_SIZE","10485760"],["--bulk-import-batch-size","DM_BULK_IMPORT_BATCH_SIZE","100"],["--james-webadmin-url","DM_JAMES_WEBADMIN_URL","http://localhost:8000"],["--james-webadmin-token","DM_JAMES_WEBADMIN_TOKEN",""],["--james-signature-template","DM_JAMES_SIGNATURE_TEMPLATE",""],["--ldap-concurrency","DM_LDAP_CONCURRENCY",10,"number"],["--james-concurrency","DM_JAMES_CONCURRENCY",10,"number"],["--james-init-delay","DM_JAMES_INIT_DELAY",1e3,"number"],["--james-mailing-list-branch","DM_JAMES_MAILING_LIST_BRANCHES",[],"array","--james-mailing-list-branches"],["--james-mailbox-type-attribute","DM_JAMES_MAILBOX_TYPE_ATTRIBUTE","twakeMailboxType"],["--calendar-webadmin-url","DM_CALENDAR_WEBADMIN_URL","http://localhost:8080"],["--calendar-webadmin-token","DM_CALENDAR_WEBADMIN_TOKEN",""],["--calendar-concurrency","DM_CALENDAR_CONCURRENCY",10,"number"],["--calendar-resource-base","DM_CALENDAR_RESOURCE_BASE",""],["--calendar-resource-objectclass","DM_CALENDAR_RESOURCE_OBJECTCLASS",""],["--calendar-resource-creator","DM_CALENDAR_RESOURCE_CREATOR",""],["--calendar-resource-domain","DM_CALENDAR_RESOURCE_DOMAIN",""],["--applicative-account-base","DM_APPLICATIVE_ACCOUNT_BASE",""],["--max-app-accounts","DM_MAX_APP_ACCOUNTS",5,"number"],["--ldap-operational-attribute","DM_LDAP_OPERATIONAL_ATTRIBUTES",["dn","controls","structuralObjectClass","entryUUID","entryDN","subschemaSubentry","modifyTimestamp","modifiersName","createTimestamp","creatorsName","userPassword"],"array","--ldap-operational-attributes"],["--trash-base","DM_TRASH_BASE",""],["--trash-watched-bases","DM_TRASH_WATCHED_BASES",""],["--trash-add-metadata","DM_TRASH_ADD_METADATA","true"],["--trash-auto-create","DM_TRASH_AUTO_CREATE","true"],["--llng-ini","DM_LLNG_INI","/etc/lemonldap-ng/lemonldap-ng.ini"],["--auth-token","DM_AUTH_TOKENS",[],"array","--auth-tokens"],["--auth-totp","DM_AUTH_TOTP",[],"array","--auth-totps"],["--auth-totp-window","DM_AUTH_TOTP_WINDOW",1,"number"],["--auth-totp-step","DM_AUTH_TOTP_STEP",30,"number"],["--auth-hmac","DM_AUTH_HMAC",[],"array","--auth-hmacs"],["--auth-hmac-window","DM_AUTH_HMAC_WINDOW",12e4,"number"],["--authz-per-branch-config","DM_AUTHZ_PER_BRANCH_CONFIG",{default:{read:!0,write:!1,delete:!1}},"json"],["--authz-per-branch-cache-ttl","DM_AUTHZ_PER_BRANCH_CACHE_TTL",60,"number"],["--authz-local-admin-attribute","DM_AUTHZ_LOCAL_ADMIN_ATTRIBUTE","twakeLocalAdminLink"],["--oidc-server","DM_OIDC_SERVER",""],["--oidc-client-id","DM_OIDC_CLIENT_ID",""],["--oidc-client-secret","DM_OIDC_CLIENT_SECRET",""],["--base-url","DM_BASE_URL",""],["--rate-limit-window-ms","DM_RATE_LIMIT_WINDOW_MS",9e5,"number"],["--rate-limit-max","DM_RATE_LIMIT_MAX",100,"number"],["--crowdsec-url","DM_CROWDSEC_URL","http://localhost:8080/v1/decisions"],["--crowdsec-api-key","DM_CROWDSEC_API_KEY",""],["--crowdsec-cache-ttl","DM_CROWDSEC_CACHE_TTL",60,"number"],["--trusted-proxy","DM_TRUSTED_PROXIES",[],"array","--trusted-proxies"],["--trusted-proxy-auth-header","DM_TRUSTED_PROXY_AUTH_HEADER","Auth-User"],["--ppolicy-default-dn","DM_PPOLICY_DEFAULT_DN",""],["--ppolicy-warn-days","DM_PPOLICY_WARN_DAYS",14,"number"],["--ppolicy-validate-complexity","DM_PPOLICY_VALIDATE_COMPLEXITY",!1,"boolean"],["--ppolicy-min-length","DM_PPOLICY_MIN_LENGTH",12,"number"],["--ppolicy-require-uppercase","DM_PPOLICY_REQUIRE_UPPERCASE",!0,"boolean"],["--ppolicy-require-lowercase","DM_PPOLICY_REQUIRE_LOWERCASE",!0,"boolean"],["--ppolicy-require-digit","DM_PPOLICY_REQUIRE_DIGIT",!0,"boolean"],["--ppolicy-require-special","DM_PPOLICY_REQUIRE_SPECIAL",!0,"boolean"],["--ldap-users-base","DM_LDAP_USERS_BASE",""]];export{t as default};
1
+ import{fileURLToPath as a}from"url";import{join as _,dirname as e}from"path";const t=[["--port","DM_PORT",8081,"number"],["--plugin","DM_PLUGINS",[],"array","--plugins"],["--log-level","DM_LOG_LEVEL","notice"],["--logger","DM_LOGGER","console"],["--api-prefix","DM_API_PREFIX","/api"],["--mail-domain","DM_MAIL_DOMAIN",[],"array","--mail-domains"],["--ldap-base","DM_LDAP_BASE",""],["--ldap-dn","DM_LDAP_DN","cn=admin,dc=example,dc=com"],["--ldap-pwd","DM_LDAP_PWD","admin"],["--ldap-url","DM_LDAP_URL",["ldap://localhost"],"array","--ldap-urls"],["--ldap-user-main-attribute","DM_LDAP_USER_ATTRIBUTE","uid"],["--ldap-cache-max","DM_LDAP_CACHE_MAX",1e3,"number"],["--ldap-cache-ttl","DM_LDAP_CACHE_TTL",300,"number"],["--ldap-pool-size","DM_LDAP_POOL_SIZE",5,"number"],["--ldap-connection-ttl","DM_LDAP_CONNECTION_TTL",60,"number"],["--schemas-path","DM_SCHEMAS_PATH",_(e(a(import.meta.url)),"..","..","static","schemas")],["--mail-attribute","DM_MAIL_ATTRIBUTE","mail"],["--quota-attribute","DM_QUOTA_ATTRIBUTE","mailQuota"],["--delegation-attribute","DM_DELEGATION_ATTRIBUTE","twakeDelegatedUsers"],["--alias-attribute","DM_ALIAS_ATTRIBUTE","mailAlternateAddress"],["--forward-attribute","DM_FORWARD_ATTRIBUTE","mailForwardingAddress"],["--display-name-attribute","DM_DISPLAY_NAME_ATTRIBUTE","displayName"],["--drive-quota-attribute","DM_DRIVE_QUOTA_ATTRIBUTE","twakeDriveQuota"],["--user-class","DM_USER_CLASSES",["top","twakeAccount","twakeWhitePages"],"array","--user-classes"],["--ldap-top-organization","DM_LDAP_TOP_ORGANIZATION",""],["--ldap-organization-class","DM_LDAP_ORGANIZATION_CLASSES",["top","organizationalUnit","twakeDepartment"],"array","--ldap-organization-classes"],["--ldap-organization-link-attribute","DM_LDAP_ORGANIZATION_LINK_ATTRIBUTE","twakeDepartmentLink"],["--ldap-organization-path-attribute","DM_LDAP_ORGANIZATION_PATH_ATTRIBUTE","twakeDepartmentPath"],["--ldap-organization-path-separator","DM_LDAP_ORGANIZATION_PATH_SEPARATOR"," / "],["--ldap-organization-max-subnodes","DM_LDAP_ORGANIZATION_MAX_SUBNODES",50,"number"],["--ldap-group-base","DM_LDAP_GROUP_BASE",""],["--ldap-groups-main-attribute","DM_LDAP_GROUPS_MAIN_ATTRIBUTE","cn"],["--group-class","DM_GROUP_CLASSES",["top","groupOfNames"],"array","--group-classes"],["--group-allow-unexistent-members","DM_ALLOW_UNEXISTENT_MEMBERS",!1,"boolean"],["--group-default-attributes","DM_GROUP_DEFAULT_ATTRIBUTES",{},"json"],["--group-dummy-user","DM_GROUP_DUMMY_USER","cn=fakeuser"],["--group-schema","DM_GROUP_SCHEMA",_(e(a(import.meta.url)),"..","..","static","schemas","twake","groups.json")],["--external-members-branch","DM_EXTERNAL_MEMBERS_BRANCH","ou=contacts,dc=example,dc=com"],["--external-branch-class","DM_EXTERNAL_BRANCH_CLASSES",["top","inetOrgPerson"],"array","--external-branch-classes"],["--static-path","DM_STATIC_PATH",_(e(a(import.meta.url)),"..","..","static")],["--static-name","DM_STATIC_NAME","static"],["--ldap-flat-schema","DM_LDAP_FLAT_SCHEMA",[],"array","--ldap-flat-schemas"],["--bulk-import-schemas","DM_BULK_IMPORT_SCHEMAS",""],["--bulk-import-max-file-size","DM_BULK_IMPORT_MAX_FILE_SIZE","10485760"],["--bulk-import-batch-size","DM_BULK_IMPORT_BATCH_SIZE","100"],["--james-webadmin-url","DM_JAMES_WEBADMIN_URL","http://localhost:8000"],["--james-webadmin-token","DM_JAMES_WEBADMIN_TOKEN",""],["--james-signature-template","DM_JAMES_SIGNATURE_TEMPLATE",""],["--ldap-concurrency","DM_LDAP_CONCURRENCY",10,"number"],["--james-concurrency","DM_JAMES_CONCURRENCY",10,"number"],["--james-init-delay","DM_JAMES_INIT_DELAY",1e3,"number"],["--james-mailing-list-branch","DM_JAMES_MAILING_LIST_BRANCHES",[],"array","--james-mailing-list-branches"],["--james-mailbox-type-attribute","DM_JAMES_MAILBOX_TYPE_ATTRIBUTE","twakeMailboxType"],["--twake-drive-webadmin-url","DM_TWAKE_DRIVE_WEBADMIN_URL",""],["--twake-drive-webadmin-token","DM_TWAKE_DRIVE_WEBADMIN_TOKEN",""],["--twake-drive-concurrency","DM_TWAKE_DRIVE_CONCURRENCY",10,"number"],["--twake-drive-domain-attribute","DM_TWAKE_DRIVE_DOMAIN_ATTRIBUTE","twakeCozyDomain"],["--twake-drive-default-domain-template","DM_TWAKE_DRIVE_DEFAULT_DOMAIN_TEMPLATE",""],["--calendar-webadmin-url","DM_CALENDAR_WEBADMIN_URL","http://localhost:8080"],["--calendar-webadmin-token","DM_CALENDAR_WEBADMIN_TOKEN",""],["--calendar-concurrency","DM_CALENDAR_CONCURRENCY",10,"number"],["--calendar-resource-base","DM_CALENDAR_RESOURCE_BASE",""],["--calendar-resource-objectclass","DM_CALENDAR_RESOURCE_OBJECTCLASS",""],["--calendar-resource-creator","DM_CALENDAR_RESOURCE_CREATOR",""],["--calendar-resource-domain","DM_CALENDAR_RESOURCE_DOMAIN",""],["--applicative-account-base","DM_APPLICATIVE_ACCOUNT_BASE",""],["--max-app-accounts","DM_MAX_APP_ACCOUNTS",5,"number"],["--ldap-operational-attribute","DM_LDAP_OPERATIONAL_ATTRIBUTES",["dn","controls","structuralObjectClass","entryUUID","entryDN","subschemaSubentry","modifyTimestamp","modifiersName","createTimestamp","creatorsName","userPassword"],"array","--ldap-operational-attributes"],["--trash-base","DM_TRASH_BASE",""],["--trash-watched-bases","DM_TRASH_WATCHED_BASES",""],["--trash-add-metadata","DM_TRASH_ADD_METADATA","true"],["--trash-auto-create","DM_TRASH_AUTO_CREATE","true"],["--llng-ini","DM_LLNG_INI","/etc/lemonldap-ng/lemonldap-ng.ini"],["--auth-token","DM_AUTH_TOKENS",[],"array","--auth-tokens"],["--auth-totp","DM_AUTH_TOTP",[],"array","--auth-totps"],["--auth-totp-window","DM_AUTH_TOTP_WINDOW",1,"number"],["--auth-totp-step","DM_AUTH_TOTP_STEP",30,"number"],["--auth-hmac","DM_AUTH_HMAC",[],"array","--auth-hmacs"],["--auth-hmac-window","DM_AUTH_HMAC_WINDOW",12e4,"number"],["--authz-per-branch-config","DM_AUTHZ_PER_BRANCH_CONFIG",{default:{read:!0,write:!1,delete:!1}},"json"],["--authz-per-branch-cache-ttl","DM_AUTHZ_PER_BRANCH_CACHE_TTL",60,"number"],["--authz-local-admin-attribute","DM_AUTHZ_LOCAL_ADMIN_ATTRIBUTE","twakeLocalAdminLink"],["--oidc-server","DM_OIDC_SERVER",""],["--oidc-client-id","DM_OIDC_CLIENT_ID",""],["--oidc-client-secret","DM_OIDC_CLIENT_SECRET",""],["--base-url","DM_BASE_URL",""],["--rate-limit-window-ms","DM_RATE_LIMIT_WINDOW_MS",9e5,"number"],["--rate-limit-max","DM_RATE_LIMIT_MAX",100,"number"],["--crowdsec-url","DM_CROWDSEC_URL","http://localhost:8080/v1/decisions"],["--crowdsec-api-key","DM_CROWDSEC_API_KEY",""],["--crowdsec-cache-ttl","DM_CROWDSEC_CACHE_TTL",60,"number"],["--trusted-proxy","DM_TRUSTED_PROXIES",[],"array","--trusted-proxies"],["--trusted-proxy-auth-header","DM_TRUSTED_PROXY_AUTH_HEADER","Auth-User"],["--ppolicy-default-dn","DM_PPOLICY_DEFAULT_DN",""],["--ppolicy-warn-days","DM_PPOLICY_WARN_DAYS",14,"number"],["--ppolicy-validate-complexity","DM_PPOLICY_VALIDATE_COMPLEXITY",!1,"boolean"],["--ppolicy-min-length","DM_PPOLICY_MIN_LENGTH",12,"number"],["--ppolicy-require-uppercase","DM_PPOLICY_REQUIRE_UPPERCASE",!0,"boolean"],["--ppolicy-require-lowercase","DM_PPOLICY_REQUIRE_LOWERCASE",!0,"boolean"],["--ppolicy-require-digit","DM_PPOLICY_REQUIRE_DIGIT",!0,"boolean"],["--ppolicy-require-special","DM_PPOLICY_REQUIRE_SPECIAL",!0,"boolean"],["--ldap-users-base","DM_LDAP_USERS_BASE",""]];export{t as default};
package/dist/lib/utils.js CHANGED
@@ -1 +1 @@
1
- import{getLogger as e}from"./expressFormatedResponses.js";const r=e(),t=new Map;function getCompiledRegex(e,r){const n=r?`${e}:${r}`:e;let a=t.get(n);return a||(a=new RegExp(e,r),t.set(n,a)),a}function escapeRegex(e){return e.replace(/[.*+?^${}()|[\]\\]/g,"\\$&")}const launchHooks=async(e,...t)=>{if(e)for(const n of e)if(n)try{await n(...t)}catch(e){r.error("Hook error",e)}},launchHooksChained=async(e,r)=>{if(e)for(const t of e)t&&(r=await t(r));return r},transformSchemas=(e,r)=>e.toString().replace(/__(\S+)__/g,(e,t)=>{if(!t||"string"!=typeof t)return e;const n=t.trim().toLowerCase();return r[n]?"object"!=typeof r[n]?r[n]:JSON.stringify(r[n]):e});function escapeLdapFilter(e){return e.replace(/\\/g,"\\5c").replace(/\*/g,"\\2a").replace(/\(/g,"\\28").replace(/\)/g,"\\29").replace(/\0/g,"\\00")}function escapeDnValue(e){return e.replace(/\\/g,"\\\\").replace(/,/g,"\\,").replace(/\+/g,"\\+").replace(/"/g,'\\"').replace(/</g,"\\<").replace(/>/g,"\\>").replace(/;/g,"\\;").replace(/=/g,"\\=").replace(/\0/g,"\\00").replace(/^\s/,"\\ ").replace(/\s$/,"\\ ").replace(/^#/,"\\#")}function validateDnValue(e,r){if(null==e||"string"!=typeof e)throw new Error(`${r} must be a string`);if(0===e.trim().length)throw new Error(`${r} must be a non-empty string`);if(/[\x00-\x1F\x7F]/.test(e))throw new Error(`${r} contains invalid control characters`);if(/[\u200B-\u200F\uFEFF]/.test(e))throw new Error(`${r} contains invalid invisible characters`)}function parseDn(e){const r=[];let t="",n=!1;for(let a=0;a<e.length;a++){const o=e[a];n?(t+=o,n=!1):"\\"===o?(t+=o,n=!0):","===o?(r.push(t.trim()),t=""):t+=o}return t&&r.push(t.trim()),r}function getParentDn(e){const r=parseDn(e);return r.length<=1?e:r.slice(1).join(",")}function getRdn(e){return parseDn(e)[0]||""}function isChildOf(e,r){const t=e.toLowerCase(),n=r.toLowerCase();if(!t.endsWith(n))return!1;if(t.length===n.length)return!1;return t.substring(0,t.length-n.length).endsWith(",")}const asyncHandler=e=>(r,t,n)=>{Promise.resolve(e(r,t,n)).catch(n)};export{asyncHandler,escapeDnValue,escapeLdapFilter,escapeRegex,getCompiledRegex,getParentDn,getRdn,isChildOf,launchHooks,launchHooksChained,parseDn,transformSchemas,validateDnValue};
1
+ import{getLogger as e}from"./expressFormatedResponses.js";const t=e(),r=new Map;function getCompiledRegex(e,t){const n=t?`${e}:${t}`:e;let a=r.get(n);return a||(a=new RegExp(e,t),r.set(n,a)),a}function escapeRegex(e){return e.replace(/[.*+?^${}()|[\]\\]/g,"\\$&")}const launchHooks=async(e,...r)=>{if(e)for(const n of e)if(n)try{await n(...r)}catch(e){t.error("Hook error",e)}},launchHooksChained=async(e,t)=>{if(e)for(const r of e)r&&(t=await r(t));return t},transformSchemas=(e,t)=>e.toString().replace(/__(\S+)__/g,(e,r)=>{if(!r||"string"!=typeof r)return e;const n=r.trim().toLowerCase();return t[n]?"object"!=typeof t[n]?t[n]:JSON.stringify(t[n]):e});function escapeLdapFilter(e){return e.replace(/\\/g,"\\5c").replace(/\*/g,"\\2a").replace(/\(/g,"\\28").replace(/\)/g,"\\29").replace(/\0/g,"\\00")}function escapeDnValue(e){return e.replace(/\\/g,"\\\\").replace(/,/g,"\\,").replace(/\+/g,"\\+").replace(/"/g,'\\"').replace(/</g,"\\<").replace(/>/g,"\\>").replace(/;/g,"\\;").replace(/=/g,"\\=").replace(/\0/g,"\\00").replace(/^\s/,"\\ ").replace(/\s$/,"\\ ").replace(/^#/,"\\#")}function unescapeDnValue(e){let t="",r=0;for(;r<e.length;)if("\\"===e[r]&&r+1<e.length){const n=e[r+1];if(/[0-9a-fA-F]/.test(n)&&r+2<e.length){const n=e.substring(r+1,r+3);if(/^[0-9a-fA-F]{2}$/.test(n)){t+=String.fromCharCode(parseInt(n,16)),r+=3;continue}}t+=n,r+=2}else t+=e[r],r++;return t}function validateDnValue(e,t){if(null==e||"string"!=typeof e)throw new Error(`${t} must be a string`);if(0===e.trim().length)throw new Error(`${t} must be a non-empty string`);if(/[\x00-\x1F\x7F]/.test(e))throw new Error(`${t} contains invalid control characters`);if(/[\u200B-\u200F\uFEFF]/.test(e))throw new Error(`${t} contains invalid invisible characters`)}function parseDn(e){const t=[];let r="",n=!1;for(let a=0;a<e.length;a++){const o=e[a];n?(r+=o,n=!1):"\\"===o?(r+=o,n=!0):","===o?(t.push(r.trim()),r=""):r+=o}return r&&t.push(r.trim()),t}function getParentDn(e){const t=parseDn(e);return t.length<=1?e:t.slice(1).join(",")}function getRdn(e){return parseDn(e)[0]||""}function isChildOf(e,t){const r=e.toLowerCase(),n=t.toLowerCase();if(!r.endsWith(n))return!1;if(r.length===n.length)return!1;return r.substring(0,r.length-n.length).endsWith(",")}const asyncHandler=e=>(t,r,n)=>{Promise.resolve(e(t,r,n)).catch(n)};export{asyncHandler,escapeDnValue,escapeLdapFilter,escapeRegex,getCompiledRegex,getParentDn,getRdn,isChildOf,launchHooks,launchHooksChained,parseDn,transformSchemas,unescapeDnValue,validateDnValue};
@@ -1 +1 @@
1
- import t from"../../abstract/plugin.js";import{launchHooks as e}from"../../lib/utils.js";const n={mail_attribute:"onLdapMailChange",quota_attribute:"onLdapQuotaChange",alias_attribute:"onLdapAliasChange",forward_attribute:"onLdapForwardChange",display_name_attribute:"onLdapDisplayNameChange"};class OnLdapChange extends t{constructor(){super(...arguments),this.name="onLdapChange",this.roles=["consistency"],this.stack={},this.pendingDeletions=new Map,this.hooks={ldapadddone:t=>{const[e,n]=t,r={};for(const[t,e]of Object.entries(n))r[t]=[null,e];this.notify(e,r)},ldapmodifyrequest:async([t,e,n])=>{const r=await this.server.ldap.search({paged:!1},t);return 1==r.searchEntries.length?this.stack[n]=r.searchEntries[0]:this.logger.warn(`Could not find unique entry ${t} before modification, got ${r.searchEntries.length} entries`),[t,e,n]},ldapmodifydone:([t,e,n])=>{const r=this.stack[n];if(!r)return delete this.stack[n],void this.logger.warn(`Received a ldapmodifydone for an unknown operation (${n})`);const i={};if(e.add)for(const[t,n]of Object.entries(e.add))i[t]=[null,n];if(e.delete)if(Array.isArray(e.delete))for(const t of e.delete)i[t]=[r[t],null];else for(const[t,n]of Object.entries(e.delete))i[t]=[n,null];if(e.replace)for(const[t,n]of Object.entries(e.replace))i[t]=[r[t],n];this.notify(t,i)},ldapdeleterequest:async t=>{const e=Array.isArray(t)?t:[t];for(const t of e)try{const e=await this.server.ldap.search({scope:"base",paged:!1},t),n=e.searchEntries?.[0];n&&this.pendingDeletions.set(t,n)}catch(t){}return t},ldapdeletedone:t=>{const e=Array.isArray(t)?t:[t];for(const t of e){const e=this.pendingDeletions.get(t);if(e){this.pendingDeletions.delete(t);const n={};for(const[t,r]of Object.entries(e))"dn"!==t&&(n[t]=[r,null]);this.notify(t,n)}}}}}notify(t,r){e(this.server.hooks.onLdapChange,t,r);for(const[e,i]of Object.entries(n))this.config[e]&&r[this.config[e]]&&("onLdapQuotaChange"===i||"onLdapForwardChange"===i||"onLdapAliasChange"===i?this.notifyAttributeChangeWithMail(this.config[e],i,t,r):"onLdapMailChange"===i&&this.notifyAttributeChange(this.config[e],i,t,r));if(r.cn||r.givenName||r.sn){const n=this.reconstructDisplayName(r,0),i=this.reconstructDisplayName(r,1);e(this.server.hooks.onLdapDisplayNameChange,t,n,i)}}notifyAttributeChange(t,n,r,i,s=!1){const[a,o]=i[t]||[];void 0===a&&void 0===o||(s&&(Array.isArray(a)||Array.isArray(o))?this.logger.error(`Attribute ${t} change detected but one of the values is an array, cannot handle that`):a!==o&&e(this.server.hooks[n],r,a,o))}async notifyAttributeChangeWithMail(t,n,r,i){const[s,a]=i[t]||[];if(void 0===s&&void 0===a)return;const o=this.config.mail_attribute||"mail",h=i[o];let c;if(h)c=Array.isArray(h[1])?String(h[1][0]):String(h[1]);else try{const t=await this.server.ldap.search({paged:!1,scope:"base",attributes:[o]},r);if(1!==t.searchEntries.length)return void this.logger.warn(`Could not find mail for ${r}, skipping ${n} notification`);{const e=t.searchEntries[0][o];c=Array.isArray(e)?String(e[0]):String(e)}}catch(t){return void this.logger.error(`Error fetching mail for ${r}:`,t)}if("onLdapQuotaChange"===n){const t=s?Number(s):0,i=a?Number(a):0;t!==i&&e(this.server.hooks[n],r,c,t,i)}else if("onLdapForwardChange"===n||"onLdapAliasChange"===n){const t=s?Array.isArray(s)?s:[s]:[],i=a?Array.isArray(a)?a:[a]:[];(t.length>0||i.length>0)&&e(this.server.hooks[n],r,c,t,i)}}reconstructDisplayName(t,e){const getValue=n=>{if(!t[n])return null;const r=t[n][e];return r?Array.isArray(r)?r.length>0?String(r[0]):null:String(r):null},n=getValue("cn");if(n)return n;const r=getValue("givenName"),i=getValue("sn");if(r||i){const t=[];return r&&t.push(r),i&&t.push(i),t.join(" ")}return null}}export{OnLdapChange as default};
1
+ import t from"../../abstract/plugin.js";import{launchHooks as e}from"../../lib/utils.js";const n={mail_attribute:"onLdapMailChange",quota_attribute:"onLdapQuotaChange",alias_attribute:"onLdapAliasChange",forward_attribute:"onLdapForwardChange",display_name_attribute:"onLdapDisplayNameChange",drive_quota_attribute:"onLdapDriveQuotaChange"};class OnLdapChange extends t{constructor(){super(...arguments),this.name="onLdapChange",this.roles=["consistency"],this.stack={},this.pendingDeletions=new Map,this.hooks={ldapadddone:t=>{const[e,n]=t,r={};for(const[t,e]of Object.entries(n))r[t]=[null,e];this.notify(e,r)},ldapmodifyrequest:async([t,e,n])=>{const r=await this.server.ldap.search({paged:!1},t);return 1==r.searchEntries.length?this.stack[n]=r.searchEntries[0]:this.logger.warn(`Could not find unique entry ${t} before modification, got ${r.searchEntries.length} entries`),[t,e,n]},ldapmodifydone:([t,e,n])=>{const r=this.stack[n];if(!r)return delete this.stack[n],void this.logger.warn(`Received a ldapmodifydone for an unknown operation (${n})`);const i={};if(e.add)for(const[t,n]of Object.entries(e.add))i[t]=[null,n];if(e.delete)if(Array.isArray(e.delete))for(const t of e.delete)i[t]=[r[t],null];else for(const[t,n]of Object.entries(e.delete))i[t]=[n,null];if(e.replace)for(const[t,n]of Object.entries(e.replace))i[t]=[r[t],n];this.notify(t,i)},ldapdeleterequest:async t=>{const e=Array.isArray(t)?t:[t];for(const t of e)try{const e=await this.server.ldap.search({scope:"base",paged:!1},t),n=e.searchEntries?.[0];n&&this.pendingDeletions.set(t,n)}catch(t){}return t},ldapdeletedone:t=>{const e=Array.isArray(t)?t:[t];for(const t of e){const e=this.pendingDeletions.get(t);if(e){this.pendingDeletions.delete(t);const n={};for(const[t,r]of Object.entries(e))"dn"!==t&&(n[t]=[r,null]);this.notify(t,n)}}}}}notify(t,r){e(this.server.hooks.onLdapChange,t,r);for(const[i,a]of Object.entries(n))if(this.config[i]&&r[this.config[i]])if("onLdapQuotaChange"===a||"onLdapForwardChange"===a||"onLdapAliasChange"===a)this.notifyAttributeChangeWithMail(this.config[i],a,t,r);else if("onLdapMailChange"===a)this.notifyAttributeChange(this.config[i],a,t,r);else if("onLdapDriveQuotaChange"===a){const[n,a]=r[this.config[i]]||[],s=n?Number(n):null,o=a?Number(a):null;s!==o&&e(this.server.hooks.onLdapDriveQuotaChange,t,s,o)}if(r.cn||r.givenName||r.sn){const n=this.reconstructDisplayName(r,0),i=this.reconstructDisplayName(r,1);e(this.server.hooks.onLdapDisplayNameChange,t,n,i)}}notifyAttributeChange(t,n,r,i,a=!1){const[s,o]=i[t]||[];void 0===s&&void 0===o||(a&&(Array.isArray(s)||Array.isArray(o))?this.logger.error(`Attribute ${t} change detected but one of the values is an array, cannot handle that`):s!==o&&e(this.server.hooks[n],r,s,o))}async notifyAttributeChangeWithMail(t,n,r,i){const[a,s]=i[t]||[];if(void 0===a&&void 0===s)return;const o=this.config.mail_attribute||"mail",h=i[o];let l;if(h)l=Array.isArray(h[1])?String(h[1][0]):String(h[1]);else try{const t=await this.server.ldap.search({paged:!1,scope:"base",attributes:[o]},r);if(1!==t.searchEntries.length)return void this.logger.warn(`Could not find mail for ${r}, skipping ${n} notification`);{const e=t.searchEntries[0][o];l=Array.isArray(e)?String(e[0]):String(e)}}catch(t){return void this.logger.error(`Error fetching mail for ${r}:`,t)}if("onLdapQuotaChange"===n){const t=a?Number(a):0,i=s?Number(s):0;t!==i&&e(this.server.hooks[n],r,l,t,i)}else if("onLdapForwardChange"===n||"onLdapAliasChange"===n){const t=a?Array.isArray(a)?a:[a]:[],i=s?Array.isArray(s)?s:[s]:[];(t.length>0||i.length>0)&&e(this.server.hooks[n],r,l,t,i)}}reconstructDisplayName(t,e){const getValue=n=>{if(!t[n])return null;const r=t[n][e];return r?Array.isArray(r)?r.length>0?String(r[0]):null:String(r):null},n=getValue("cn");if(n)return n;const r=getValue("givenName"),i=getValue("sn");if(r||i){const t=[];return r&&t.push(r),i&&t.push(i),t.join(" ")}return null}}export{OnLdapChange as default};
@@ -0,0 +1 @@
1
+ import t from"node-fetch";import{TwakePlugin as i}from"../../abstract/twakePlugin.js";class Drive extends i{constructor(t){super(t,"twake_drive_webadmin_url","twake_drive_webadmin_token","twake_drive_concurrency"),this.name="drive",this.roles=["consistency"],this.dependencies={onLdapChange:"core/ldap/onChange"},this.hooks={onLdapMailChange:async(t,i,e)=>{const a=i?Array.isArray(i)?String(i[0]):String(i):null,n=e?Array.isArray(e)?String(e[0]):String(e):null;if(!a)return void this.logger.debug(`Skipping mail change for ${t}: oldmail is empty (mail attribute was added, not changed)`);if(!n)return void this.logger.debug(`Skipping mail change for ${t}: newmail is empty (mail attribute was deleted)`);const r=await this.getCozyDomain(t);r?await this.updateCozyInstance("onLdapMailChange",r,t,{Email:n}):this.logger.debug(`Skipping mail change for ${t}: no Cozy domain attribute (${this.cozyDomainAttr})`)},onLdapDisplayNameChange:async(t,i,e)=>{const a=await this.ldapGetAttributes(t,[this.cozyDomainAttr,this.displayNameAttr,"cn","givenName","sn"]);if(!a)return void this.logger.warn(`Cannot update Cozy display name: entry not found for ${t}`);const n=this.attributeToString(a[this.cozyDomainAttr]);if(!n)return void this.logger.debug(`Skipping display name change for ${t}: no Cozy domain attribute (${this.cozyDomainAttr})`);const r=this.getDisplayNameFromAttributes(a);r?await this.updateCozyInstance("onLdapDisplayNameChange",n,t,{PublicName:r}):this.logger.warn(`Cannot update Cozy display name: no display name found for ${t}`)},onLdapDriveQuotaChange:async(t,i,e)=>{if(null===e)return void this.logger.debug(`Skipping drive quota change for ${t}: quota was deleted`);const a=await this.getCozyDomain(t);a?await this.updateCozyInstance("onLdapDriveQuotaChange",a,t,{DiskQuota:String(e)}):this.logger.debug(`Skipping drive quota change for ${t}: no Twake Drive domain attribute (${this.cozyDomainAttr})`)}},this.cozyDomainAttr=this.config.twake_drive_domain_attribute||"twakeCozyDomain",this.defaultDomainTemplate=this.config.twake_drive_default_domain_template||"",this.templateAttrs=this.defaultDomainTemplate?[...this.defaultDomainTemplate.matchAll(/\{(\w+)\}/g)].map(t=>t[1]):[],this.driveQuotaAttr=this.config.drive_quota_attribute||"twakeDriveQuota"}createHeaders(t){const i={};if(this.webadminToken){const t=Buffer.from(`:${this.webadminToken}`).toString("base64");i.Authorization=`Basic ${t}`}return t&&(i["Content-Type"]=t),i}isValidCozyDomain(t){if(!t||t.length>253)return!1;if(t.includes("/")||t.includes("\\")||t.includes("?")||t.includes("#"))return!1;return/^[a-zA-Z0-9]([a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?(\.[a-zA-Z0-9]([a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?)*$/.test(t)}async getCozyDomain(t){const i=[this.cozyDomainAttr,...this.templateAttrs],e=await this.ldapGetAttributes(t,i),a=e?this.attributeToString(e[this.cozyDomainAttr]):null;if(a)return a;if(this.defaultDomainTemplate&&e){let t=this.defaultDomainTemplate;for(const i of this.templateAttrs){const a=this.attributeToString(e[i]);if(!a)return null;t=t.replace(`{${i}}`,a)}return t}return null}async updateCozyInstance(i,e,a,n){if(!this.isValidCozyDomain(e))return void this.logger.error({plugin:this.name,event:i,result:"error",dn:a,cozyDomain:e,error:"Invalid Cozy domain format (potential URL injection attempt)"});const r=new URL(`${this.webadminUrl}/instances/${e}`);r.searchParams.set("FromCloudery","true");for(const[t,i]of Object.entries(n))r.searchParams.set(t,i);return this.requestLimit(async()=>{const s={plugin:this.name,event:i,result:"error",dn:a,cozyDomain:e,...n};try{const i=await t(r.toString(),{method:"PATCH",headers:this.createHeaders("application/json")});i.ok?this.logger.info({...s,result:"success",http_status:i.status}):404===i.status?this.logger.debug({...s,result:"ignored",http_status:i.status,http_status_text:i.statusText,message:"Cozy instance not found (may not be provisioned yet)"}):this.logger.error({...s,http_status:i.status,http_status_text:i.statusText,url:r.toString()})}catch(t){this.logger.error({...s,error:t,url:r.toString()})}})}getDisplayNameFromAttributes(t){const i=this.attributeToString(t[this.displayNameAttr]);if(i)return i;const e=this.attributeToString(t.cn);if(e)return e;const a=this.attributeToString(t.givenName),n=this.attributeToString(t.sn);if(a||n){const t=[];return a&&t.push(a),n&&t.push(n),t.join(" ")}return null}async getDisplayNameFromDN(t){const i=[this.displayNameAttr,"cn","givenName","sn"],e=await this.ldapGetAttributes(t,i);return e?this.getDisplayNameFromAttributes(e):null}async getMailFromDN(t){const i=await this.ldapGetAttributes(t,[this.mailAttr]);return i?this.attributeToString(i[this.mailAttr]):null}async getDriveQuotaFromDN(t){const i=await this.ldapGetAttributes(t,[this.driveQuotaAttr]);if(!i)return null;const e=this.attributeToString(i[this.driveQuotaAttr]);return e?Number(e):null}async syncUserToCozy(t){const i=await this.ldapGetAttributes(t,[this.cozyDomainAttr,this.mailAttr,this.displayNameAttr,this.driveQuotaAttr,"cn","givenName","sn"]);if(!i)return this.logger.error(`Cannot sync user: entry not found for ${t}`),!1;const e=this.attributeToString(i[this.cozyDomainAttr]);if(!e)return this.logger.error(`Cannot sync user: no Twake Drive domain attribute for ${t}`),!1;const a=this.attributeToString(i[this.mailAttr]),n=this.getDisplayNameFromAttributes(i),r=this.attributeToString(i[this.driveQuotaAttr]),s={};return a&&(s.Email=a),n&&(s.PublicName=n),r&&(s.DiskQuota=r),0===Object.keys(s).length?(this.logger.warn(`No attributes to sync for ${t}`),!1):(await this.updateCozyInstance("syncUserToCozy",e,t,s),!0)}async blockInstance(t,i){const e=await this.getCozyDomain(t);if(!e)return this.logger.error(`Cannot block instance: no Cozy domain for ${t}`),!1;const a={Blocked:"true"};return i&&(a.BlockingReason=i),await this.updateCozyInstance("blockInstance",e,t,a),!0}async unblockInstance(t){const i=await this.getCozyDomain(t);return i?(await this.updateCozyInstance("unblockInstance",i,t,{Blocked:"false"}),!0):(this.logger.error(`Cannot unblock instance: no Cozy domain for ${t}`),!1)}}export{Drive as default};
@@ -6,7 +6,7 @@ import type { Express } from 'express';
6
6
  import type winston from 'winston';
7
7
  import type { Config, DM } from '../bin';
8
8
  import type { Hooks, MaybePromise } from '../hooks';
9
- export { asyncHandler, escapeDnValue, escapeLdapFilter, validateDnValue, } from '../lib/utils';
9
+ export { asyncHandler, escapeDnValue, unescapeDnValue, escapeLdapFilter, validateDnValue, } from '../lib/utils';
10
10
  export { HttpError, BadRequestError, UnauthorizedError, ForbiddenError, NotFoundError, ConflictError, UriTooLongError, TooManyRequestsError, BadGatewayError, ServiceUnavailableError, GatewayTimeoutError, } from '../lib/errors';
11
11
  export type Role = 'auth' | 'authz' | 'protect' | 'api' | 'logging' | 'demo' | 'consistency' | 'configurable';
12
12
  export default abstract class DmPlugin {
@@ -92,6 +92,7 @@ export interface Config {
92
92
  alias_attribute?: string;
93
93
  forward_attribute?: string;
94
94
  display_name_attribute?: string;
95
+ drive_quota_attribute?: string;
95
96
  james_webadmin_url?: string;
96
97
  james_webadmin_token?: string;
97
98
  james_signature_template?: string;
@@ -100,6 +101,11 @@ export interface Config {
100
101
  james_init_delay?: number;
101
102
  james_mailing_list_branch?: string[];
102
103
  james_mailbox_type_attribute?: string;
104
+ twake_drive_webadmin_url?: string;
105
+ twake_drive_webadmin_token?: string;
106
+ twake_drive_concurrency?: number;
107
+ twake_drive_domain_attribute?: string;
108
+ twake_drive_default_domain_template?: string;
103
109
  calendar_webadmin_url?: string;
104
110
  calendar_webadmin_token?: string;
105
111
  calendar_concurrency?: number;
@@ -53,6 +53,7 @@ export interface Hooks {
53
53
  onLdapQuotaChange?: (dn: string, mail: string, oldQuota: number, newQuota: number) => MaybePromise<void>;
54
54
  onLdapForwardChange?: (dn: string, mail: string, oldForwards: string[], newForwards: string[]) => MaybePromise<void>;
55
55
  onLdapDisplayNameChange?: (dn: string, oldDisplayName: string | null, newDisplayName: string | null) => MaybePromise<void>;
56
+ onLdapDriveQuotaChange?: (dn: string, oldDriveQuota: number | null, newDriveQuota: number | null) => MaybePromise<void>;
56
57
  /** externalUsersInGroup */
57
58
  externaluserentry?: ChainedHook<[string, AttributesList]>;
58
59
  externaluseradded?: (dn: string, mail: string) => MaybePromise<void>;
@@ -58,6 +58,23 @@ export declare function escapeLdapFilter(value: string): string;
58
58
  * ```
59
59
  */
60
60
  export declare function escapeDnValue(value: string): string;
61
+ /**
62
+ * Unescape special characters in LDAP DN attribute values according to RFC 4514
63
+ * Reverses the escaping done by escapeDnValue
64
+ *
65
+ * @param value - The escaped value to unescape
66
+ * @returns The unescaped original value
67
+ *
68
+ * @example
69
+ * ```typescript
70
+ * unescapeDnValue('Smith\\, John')
71
+ * // => 'Smith, John'
72
+ *
73
+ * unescapeDnValue('user\\+admin')
74
+ * // => 'user+admin'
75
+ * ```
76
+ */
77
+ export declare function unescapeDnValue(value: string): string;
61
78
  /**
62
79
  * Validate a value intended for use in LDAP DN attribute values
63
80
  * Rejects control characters and other problematic inputs
@@ -0,0 +1,86 @@
1
+ import TwakePlugin from '../../abstract/twakePlugin';
2
+ import { type Role } from '../../abstract/plugin';
3
+ import type { DM } from '../../bin';
4
+ import { Hooks } from '../../hooks';
5
+ export default class Drive extends TwakePlugin {
6
+ name: string;
7
+ roles: Role[];
8
+ dependencies: {
9
+ onLdapChange: string;
10
+ };
11
+ private cozyDomainAttr;
12
+ private defaultDomainTemplate;
13
+ private templateAttrs;
14
+ private driveQuotaAttr;
15
+ constructor(server: DM);
16
+ /**
17
+ * Override createHeaders to use Basic Auth for Cozy Admin API
18
+ * Cozy uses Basic Auth with empty username and password as the token
19
+ * @param contentType Optional Content-Type header value
20
+ * @returns Headers object with Basic Auth
21
+ */
22
+ protected createHeaders(contentType?: string): {
23
+ Authorization?: string;
24
+ 'Content-Type'?: string;
25
+ };
26
+ /**
27
+ * Validate Cozy domain format to prevent URL injection attacks
28
+ * @param domain Cozy domain to validate
29
+ * @returns true if domain format is valid
30
+ */
31
+ private isValidCozyDomain;
32
+ /**
33
+ * Get Cozy domain for a user from LDAP
34
+ * Falls back to template-based domain if attribute not found
35
+ * @param dn User's LDAP DN
36
+ * @returns Cozy domain or null if not found
37
+ */
38
+ getCozyDomain(dn: string): Promise<string | null>;
39
+ /**
40
+ * Update Cozy instance via Admin API
41
+ * @param hookname Hook name for logging
42
+ * @param cozyDomain Cozy instance domain
43
+ * @param dn User's LDAP DN
44
+ * @param params Query parameters to update
45
+ */
46
+ private updateCozyInstance;
47
+ hooks: Hooks;
48
+ /**
49
+ * Extract display name from LDAP attributes
50
+ * Fallback logic: displayName → cn → givenName+sn
51
+ */
52
+ private getDisplayNameFromAttributes;
53
+ /**
54
+ * Public method to get display name from DN
55
+ * Useful for external integrations
56
+ */
57
+ getDisplayNameFromDN(dn: string): Promise<string | null>;
58
+ /**
59
+ * Public method to get mail from DN
60
+ * Useful for external integrations
61
+ */
62
+ getMailFromDN(dn: string): Promise<string | null>;
63
+ /**
64
+ * Public method to get drive quota from DN (in bytes)
65
+ * Useful for external integrations
66
+ */
67
+ getDriveQuotaFromDN(dn: string): Promise<number | null>;
68
+ /**
69
+ * Public method to manually sync a user to Twake Drive
70
+ * Useful for initial sync or manual refresh
71
+ */
72
+ syncUserToCozy(dn: string): Promise<boolean>;
73
+ /**
74
+ * Block a Cozy instance
75
+ * @param dn User's LDAP DN
76
+ * @param reason Optional blocking reason (e.g., 'PAYMENT_FAILED', 'LOGIN_FAILED', 'SUSPENDED')
77
+ * @returns true if successful, false otherwise
78
+ */
79
+ blockInstance(dn: string, reason?: string): Promise<boolean>;
80
+ /**
81
+ * Unblock a Cozy instance
82
+ * @param dn User's LDAP DN
83
+ * @returns true if successful, false otherwise
84
+ */
85
+ unblockInstance(dn: string): Promise<boolean>;
86
+ }
@@ -0,0 +1,646 @@
1
+ # Twake Drive Plugin
2
+
3
+ Synchronize LDAP user changes with Twake Drive via Admin API.
4
+
5
+ ## Overview
6
+
7
+ The `twake/drive` plugin automatically synchronizes email address, display name, and disk quota changes from LDAP to [Twake Drive](https://twake.app/).
8
+ It listens to `onChange` hooks and updates Twake Drive instances via the Admin API.
9
+
10
+ ## Prerequisites
11
+
12
+ 1. **Twake Drive / Cozy** instance with Admin API enabled
13
+ 2. **onChange plugin** loaded to detect LDAP changes
14
+ 3. **Cozy domain** - either:
15
+ - Per-user attribute in LDAP schema (e.g., `twakeCozyDomain`), or
16
+ - Default domain template configured (e.g., `{uid}.mycompany.cloud`)
17
+
18
+ ## Configuration
19
+
20
+ ```bash
21
+ npx ldap-rest \
22
+ --plugin core/ldap/onChange \
23
+ --plugin core/ldap/flatGeneric \
24
+ --plugin twake/drive \
25
+ --ldap-flat-schema ./static/schemas/twake/users.json \
26
+ --mail-attribute mail \
27
+ --twake-drive-domain-attribute twakeCozyDomain \
28
+ --twake-drive-webadmin-url http://cozy-admin:6060 \
29
+ --twake-drive-webadmin-token "your-admin-token" \
30
+ ...
31
+ ```
32
+
33
+ **Environment Variables:**
34
+
35
+ ```bash
36
+ DM_TWAKE_DRIVE_WEBADMIN_URL="http://cozy-admin:6060"
37
+ DM_TWAKE_DRIVE_WEBADMIN_TOKEN="your-admin-token"
38
+ DM_TWAKE_DRIVE_DOMAIN_ATTRIBUTE="twakeCozyDomain"
39
+ ```
40
+
41
+ ### Parameters
42
+
43
+ | Parameter | Description | Default |
44
+ | --------------------------------------- | ------------------------------------------------------------- | ----------------- |
45
+ | `--twake-drive-webadmin-url` | Twake Drive Admin API base URL (required) | - |
46
+ | `--twake-drive-webadmin-token` | Bearer token for Admin authentication | - |
47
+ | `--mail-attribute` | LDAP attribute for email | `mail` |
48
+ | `--display-name-attribute` | LDAP attribute for display name | `displayName` |
49
+ | `--drive-quota-attribute` | LDAP attribute for disk quota (in bytes) | `twakeDriveQuota` |
50
+ | `--twake-drive-domain-attribute` | LDAP attribute storing user's Twake Drive domain | `twakeCozyDomain` |
51
+ | `--twake-drive-default-domain-template` | Template for Twake Drive domain (e.g., `{uid}.company.cloud`) | - |
52
+ | `--twake-drive-concurrency` | Maximum concurrent API requests | `10` |
53
+
54
+ ## Cozy Domain Resolution
55
+
56
+ The plugin determines each user's Cozy domain using the following logic:
57
+
58
+ 1. **Per-user attribute** (if present): Use the `twakeCozyDomain` LDAP attribute value
59
+ 2. **Default template** (fallback): Build domain from template with LDAP attribute placeholders
60
+
61
+ ### Per-user Cozy domain attribute
62
+
63
+ Users can have an explicit `twakeCozyDomain` attribute for custom domains:
64
+
65
+ ```ldif
66
+ dn: uid=jdoe,ou=users,dc=example,dc=com
67
+ uid: jdoe
68
+ mail: jdoe@example.com
69
+ twakeCozyDomain: john-doe.partner.cloud
70
+ ```
71
+
72
+ ### Default domain template
73
+
74
+ Configure a template for users without an explicit attribute. Use `{attribute}` placeholders:
75
+
76
+ ```bash
77
+ --twake-drive-default-domain-template "{uid}.mycompany.cloud"
78
+ ```
79
+
80
+ Users without `twakeCozyDomain` will have their domain built from the template:
81
+
82
+ - User `uid=jdoe` → Cozy domain `jdoe.mycompany.cloud`
83
+
84
+ Other template examples:
85
+
86
+ - `"{mail}"` → use mail attribute directly as domain
87
+ - `"{cn}.cloud.example.com"` → use common name
88
+
89
+ ### Combined usage
90
+
91
+ Both can be used together. The per-user attribute takes precedence:
92
+
93
+ ```ldif
94
+ # User with custom domain (uses twakeCozyDomain)
95
+ dn: uid=partner1,ou=users,dc=example,dc=com
96
+ uid: partner1
97
+ twakeCozyDomain: partner1.external.cloud
98
+
99
+ # Regular user (uses default suffix → jdoe.mycompany.cloud)
100
+ dn: uid=jdoe,ou=users,dc=example,dc=com
101
+ uid: jdoe
102
+ ```
103
+
104
+ ## How It Works
105
+
106
+ ### Mail Address Changes
107
+
108
+ When a user's mail attribute changes:
109
+
110
+ 1. **LDAP modify operation**
111
+
112
+ ```bash
113
+ PUT /api/v1/ldap/users/jdoe
114
+ {
115
+ "replace": {
116
+ "mail": "john.doe@company.com"
117
+ }
118
+ }
119
+ ```
120
+
121
+ 2. **onChange plugin** detects mail change and triggers `onLdapMailChange` hook
122
+
123
+ 3. **Drive plugin** receives hook and calls Cozy Admin API:
124
+
125
+ ```http
126
+ PATCH /instances/jdoe.mycompany.cloud?Email=john.doe@company.com&FromCloudery=true
127
+ ```
128
+
129
+ 4. **Cozy** updates the instance owner's email address
130
+
131
+ ### Display Name Changes
132
+
133
+ When a user's display name changes:
134
+
135
+ 1. **LDAP modify operation**
136
+
137
+ ```bash
138
+ PUT /api/v1/ldap/users/jdoe
139
+ {
140
+ "replace": {
141
+ "displayName": "John M. Doe"
142
+ }
143
+ }
144
+ ```
145
+
146
+ 2. **onChange plugin** triggers `onLdapDisplayNameChange` hook
147
+
148
+ 3. **Drive plugin** updates Cozy instance:
149
+
150
+ ```http
151
+ PATCH /instances/jdoe.mycompany.cloud?PublicName=John%20M.%20Doe&FromCloudery=true
152
+ ```
153
+
154
+ ### Display Name Fallback Logic
155
+
156
+ If `displayName` is not set, the plugin uses fallback logic:
157
+
158
+ 1. `displayName` attribute (first choice)
159
+ 2. `cn` (Common Name)
160
+ 3. `givenName` + `sn` (First Name + Last Name)
161
+
162
+ ### Disk Quota Changes
163
+
164
+ When a user's drive quota changes:
165
+
166
+ 1. **LDAP modify operation**
167
+
168
+ ```bash
169
+ PUT /api/v1/ldap/users/jdoe
170
+ {
171
+ "replace": {
172
+ "twakeDriveQuota": "5368709120"
173
+ }
174
+ }
175
+ ```
176
+
177
+ 2. **onChange plugin** triggers `onLdapDriveQuotaChange` hook
178
+
179
+ 3. **Drive plugin** updates Twake Drive instance:
180
+
181
+ ```http
182
+ PATCH /instances/jdoe.company.cloud?DiskQuota=5368709120&FromCloudery=true
183
+ ```
184
+
185
+ 4. **Twake Drive** updates the instance disk quota
186
+
187
+ **Note:** The quota value is in bytes. Common values:
188
+
189
+ - 1 GB = `1073741824`
190
+ - 5 GB = `5368709120`
191
+ - 10 GB = `10737418240`
192
+
193
+ ## Public Methods
194
+
195
+ Public methods available on the Drive plugin instance:
196
+
197
+ ### Usage
198
+
199
+ ```typescript
200
+ import type { DM } from 'ldap-rest';
201
+ import type Drive from 'ldap-rest/plugin-twake-drive';
202
+
203
+ // Get the Drive plugin instance
204
+ const drive = server.getPlugin('drive') as Drive;
205
+ ```
206
+
207
+ ### getCozyDomain(dn)
208
+
209
+ Retrieve the Cozy domain for a user given their LDAP DN.
210
+
211
+ **Signature:**
212
+
213
+ ```typescript
214
+ async getCozyDomain(dn: string): Promise<string | null>
215
+ ```
216
+
217
+ **Example:**
218
+
219
+ ```typescript
220
+ const domain = await drive.getCozyDomain('uid=jdoe,ou=users,dc=example,dc=com');
221
+ // Returns: "jdoe.mycompany.cloud"
222
+ ```
223
+
224
+ ---
225
+
226
+ ### getDisplayNameFromDN(dn)
227
+
228
+ Retrieve the display name for a user using fallback logic.
229
+
230
+ **Signature:**
231
+
232
+ ```typescript
233
+ async getDisplayNameFromDN(dn: string): Promise<string | null>
234
+ ```
235
+
236
+ **Example:**
237
+
238
+ ```typescript
239
+ const name = await drive.getDisplayNameFromDN(
240
+ 'uid=jdoe,ou=users,dc=example,dc=com'
241
+ );
242
+ // Returns: "John Doe"
243
+ ```
244
+
245
+ ---
246
+
247
+ ### getMailFromDN(dn)
248
+
249
+ Retrieve the email address for a user.
250
+
251
+ **Signature:**
252
+
253
+ ```typescript
254
+ async getMailFromDN(dn: string): Promise<string | null>
255
+ ```
256
+
257
+ **Example:**
258
+
259
+ ```typescript
260
+ const mail = await drive.getMailFromDN('uid=jdoe,ou=users,dc=example,dc=com');
261
+ // Returns: "jdoe@example.com"
262
+ ```
263
+
264
+ ---
265
+
266
+ ### getDriveQuotaFromDN(dn)
267
+
268
+ Retrieve the drive quota for a user (in bytes).
269
+
270
+ **Signature:**
271
+
272
+ ```typescript
273
+ async getDriveQuotaFromDN(dn: string): Promise<number | null>
274
+ ```
275
+
276
+ **Example:**
277
+
278
+ ```typescript
279
+ const quota = await drive.getDriveQuotaFromDN(
280
+ 'uid=jdoe,ou=users,dc=example,dc=com'
281
+ );
282
+ // Returns: 5368709120 (5 GB)
283
+ ```
284
+
285
+ ---
286
+
287
+ ### syncUserToCozy(dn)
288
+
289
+ Manually synchronize a user's attributes (email, display name, disk quota) to their Twake Drive instance. Useful for initial sync or manual refresh.
290
+
291
+ **Signature:**
292
+
293
+ ```typescript
294
+ async syncUserToCozy(dn: string): Promise<boolean>
295
+ ```
296
+
297
+ **Example:**
298
+
299
+ ```typescript
300
+ const success = await drive.syncUserToCozy(
301
+ 'uid=jdoe,ou=users,dc=example,dc=com'
302
+ );
303
+ if (success) {
304
+ console.log('User synced successfully');
305
+ }
306
+ ```
307
+
308
+ ---
309
+
310
+ ### blockInstance(dn, reason?)
311
+
312
+ Block a user's Cozy instance with an optional reason.
313
+
314
+ **Signature:**
315
+
316
+ ```typescript
317
+ async blockInstance(dn: string, reason?: string): Promise<boolean>
318
+ ```
319
+
320
+ **Parameters:**
321
+
322
+ - `dn` - User's LDAP DN
323
+ - `reason` - Optional blocking reason. Common values:
324
+ - `PAYMENT_FAILED` - Payment issue
325
+ - `LOGIN_FAILED` - Too many failed login attempts
326
+ - `SUSPENDED` - Account suspended by admin
327
+ - Any custom string
328
+
329
+ **Example:**
330
+
331
+ ```typescript
332
+ // Block with reason
333
+ await drive.blockInstance(
334
+ 'uid=jdoe,ou=users,dc=example,dc=com',
335
+ 'PAYMENT_FAILED'
336
+ );
337
+
338
+ // Block without reason
339
+ await drive.blockInstance('uid=jdoe,ou=users,dc=example,dc=com');
340
+ ```
341
+
342
+ ---
343
+
344
+ ### unblockInstance(dn)
345
+
346
+ Unblock a user's Cozy instance.
347
+
348
+ **Signature:**
349
+
350
+ ```typescript
351
+ async unblockInstance(dn: string): Promise<boolean>
352
+ ```
353
+
354
+ **Example:**
355
+
356
+ ```typescript
357
+ await drive.unblockInstance('uid=jdoe,ou=users,dc=example,dc=com');
358
+ ```
359
+
360
+ ## Cozy Admin API
361
+
362
+ ### Endpoints Used
363
+
364
+ | Operation | Endpoint | Method |
365
+ | --------------- | ----------------------------------------------------------------------------------------- | ------ |
366
+ | Update email | `/instances/{domain}?Email={email}&FromCloudery=true` | PATCH |
367
+ | Update name | `/instances/{domain}?PublicName={name}&FromCloudery=true` | PATCH |
368
+ | Update quota | `/instances/{domain}?DiskQuota={bytes}&FromCloudery=true` | PATCH |
369
+ | Combined update | `/instances/{domain}?Email={email}&PublicName={name}&DiskQuota={bytes}&FromCloudery=true` | PATCH |
370
+
371
+ ### Available Cozy Parameters
372
+
373
+ The Cozy Admin API supports the following parameters (query string):
374
+
375
+ | Parameter | Type | Description |
376
+ | ---------------- | ------- | ---------------------------------------------------- |
377
+ | `Email` | string | Owner email address (supported by this plugin) |
378
+ | `PublicName` | string | Display name (supported by this plugin) |
379
+ | `Locale` | string | Locale (e.g. `fr`, `en`) |
380
+ | `Timezone` | string | Timezone (e.g. `Europe/Paris`) |
381
+ | `Phone` | string | Phone number |
382
+ | `DiskQuota` | integer | Disk quota in bytes |
383
+ | `Blocked` | bool | Block or unblock the instance |
384
+ | `BlockingReason` | string | Reason code (e.g. `PAYMENT_FAILED`, `LOGIN_FAILED`) |
385
+ | `FromCloudery` | bool | Skip Cloudery callback (always true for this plugin) |
386
+
387
+ See the [Cozy Admin Documentation](https://docs.cozy.io/en/cozy-stack/admin/#patch-instancesdomain) for the complete list.
388
+
389
+ ### FromCloudery Parameter
390
+
391
+ The `FromCloudery=true` query parameter is always included to:
392
+
393
+ - Prevent the Cozy stack from sending callback notifications
394
+ - Avoid notification loops when changes originate from the LDAP directory
395
+ - Indicate the change is administrative (from the cloudery/provisioning system)
396
+
397
+ ### Authentication
398
+
399
+ The Cozy Admin API uses bearer token authentication:
400
+
401
+ ```bash
402
+ --twake-drive-webadmin-token "your-admin-token"
403
+ ```
404
+
405
+ The plugin automatically adds the `Authorization: Bearer {token}` header to all requests.
406
+
407
+ **Note:** The Cozy Admin API typically runs on port 6060 and should be protected at the network level.
408
+
409
+ ## Examples
410
+
411
+ ### Example 1: Basic Setup
412
+
413
+ ```bash
414
+ --plugin core/ldap/onChange \
415
+ --plugin core/ldap/flatGeneric \
416
+ --plugin twake/drive \
417
+ --ldap-flat-schema ./schemas/twake/users.json \
418
+ --mail-attribute mail \
419
+ --twake-drive-webadmin-url http://cozy-admin:6060
420
+ ```
421
+
422
+ ### Example 2: With Authentication
423
+
424
+ ```bash
425
+ --plugin core/ldap/onChange \
426
+ --plugin core/ldap/flatGeneric \
427
+ --plugin twake/drive \
428
+ --ldap-flat-schema ./schemas/twake/users.json \
429
+ --mail-attribute mail \
430
+ --twake-drive-webadmin-url http://cozy-admin:6060 \
431
+ --twake-drive-webadmin-token "cozy-admin-secret"
432
+ ```
433
+
434
+ ### Example 3: Complete Twake Setup (James + Drive)
435
+
436
+ ```bash
437
+ --plugin core/auth/token \
438
+ --plugin core/ldap/onChange \
439
+ --plugin core/ldap/flatGeneric \
440
+ --plugin core/ldap/groups \
441
+ --plugin twake/james \
442
+ --plugin twake/drive \
443
+ --ldap-flat-schema ./schemas/twake/users.json \
444
+ --mail-attribute mail \
445
+ --quota-attribute mailQuotaSize \
446
+ --james-webadmin-url http://james:8000 \
447
+ --james-webadmin-token "james-admin-token" \
448
+ --twake-drive-webadmin-url http://cozy-admin:6060 \
449
+ --twake-drive-webadmin-token "cozy-admin-token" \
450
+ --twake-drive-domain-attribute twakeCozyDomain \
451
+ --auth-token "api-admin-token"
452
+ ```
453
+
454
+ ## Logging
455
+
456
+ The plugin logs all operations:
457
+
458
+ ### Successful Operations
459
+
460
+ ```json
461
+ {
462
+ "level": "info",
463
+ "plugin": "drive",
464
+ "event": "onLdapMailChange",
465
+ "result": "success",
466
+ "http_status": 200,
467
+ "dn": "uid=jdoe,ou=users,dc=example,dc=com",
468
+ "cozyDomain": "jdoe.mycompany.cloud",
469
+ "Email": "john.doe@company.com"
470
+ }
471
+ ```
472
+
473
+ ### Skipped Operations
474
+
475
+ ```json
476
+ {
477
+ "level": "debug",
478
+ "message": "Skipping mail change for uid=jdoe,...: no Cozy domain attribute (twakeCozyDomain)"
479
+ }
480
+ ```
481
+
482
+ ### Failed Operations
483
+
484
+ ```json
485
+ {
486
+ "level": "error",
487
+ "plugin": "drive",
488
+ "event": "onLdapMailChange",
489
+ "result": "error",
490
+ "http_status": 500,
491
+ "http_status_text": "Internal Server Error",
492
+ "dn": "uid=jdoe,ou=users,dc=example,dc=com",
493
+ "cozyDomain": "jdoe.mycompany.cloud"
494
+ }
495
+ ```
496
+
497
+ ### Instance Not Found
498
+
499
+ ```json
500
+ {
501
+ "level": "debug",
502
+ "plugin": "drive",
503
+ "event": "onLdapMailChange",
504
+ "result": "ignored",
505
+ "http_status": 404,
506
+ "message": "Cozy instance not found (may not be provisioned yet)"
507
+ }
508
+ ```
509
+
510
+ ## Error Handling
511
+
512
+ ### Non-Existent Instance (404)
513
+
514
+ If the Cozy instance doesn't exist yet:
515
+
516
+ - Error is logged at DEBUG level (not ERROR)
517
+ - LDAP operation succeeds
518
+ - This is normal during user provisioning flow
519
+
520
+ ### Cozy Server Down
521
+
522
+ If the Cozy Admin API is unreachable:
523
+
524
+ - Error is logged
525
+ - LDAP operation succeeds
526
+ - Manual sync may be required later using `syncUserToCozy()`
527
+
528
+ ### Invalid Token
529
+
530
+ If the admin token is invalid:
531
+
532
+ - 401 or 403 error is logged
533
+ - LDAP operation succeeds
534
+ - Check token configuration
535
+
536
+ ## Synchronization Scenarios
537
+
538
+ ### Scenario 1: User Creation
539
+
540
+ 1. Create user in LDAP with `twakeCozyDomain` attribute
541
+ 2. Provision Cozy instance separately (via cloudery or API)
542
+ 3. Future mail/name changes are synced automatically
543
+ 4. Use `syncUserToCozy()` for initial sync if needed
544
+
545
+ ### Scenario 2: Email Change
546
+
547
+ 1. User changes email in LDAP: `old@company.com` → `new@company.com`
548
+ 2. Drive plugin updates Cozy instance email
549
+ 3. Cozy updates instance owner's email
550
+ 4. User receives notifications at new email
551
+
552
+ ### Scenario 3: Name Change
553
+
554
+ 1. Admin updates user's display name in LDAP
555
+ 2. Drive plugin updates Cozy instance PublicName
556
+ 3. User's name is updated across Cozy apps
557
+
558
+ ### Scenario 4: Manual Sync
559
+
560
+ ```typescript
561
+ // Sync all attributes for a user
562
+ const drive = server.getPlugin('drive') as Drive;
563
+ await drive.syncUserToCozy('uid=jdoe,ou=users,dc=example,dc=com');
564
+ ```
565
+
566
+ ## Limitations
567
+
568
+ 1. **One-way sync**: LDAP → Cozy only. Cozy changes don't sync back to LDAP.
569
+ 2. **No instance creation**: Plugin doesn't create Cozy instances, only updates existing ones.
570
+ 3. **No user deletion**: Plugin doesn't delete Cozy instances when LDAP users are deleted.
571
+ 4. **Domain required**: Users must have the `twakeCozyDomain` attribute to be synced.
572
+
573
+ ## Troubleshooting
574
+
575
+ ### Problem: Changes Not Syncing
576
+
577
+ **Solutions:**
578
+
579
+ 1. Verify onChange plugin is loaded:
580
+
581
+ ```bash
582
+ --plugin core/ldap/onChange
583
+ ```
584
+
585
+ 2. Check Cozy domain attribute is set for the user:
586
+
587
+ ```bash
588
+ ldapsearch -b "uid=jdoe,ou=users,dc=example,dc=com" twakeCozyDomain
589
+ ```
590
+
591
+ 3. Enable debug logging:
592
+
593
+ ```bash
594
+ --log-level debug
595
+ ```
596
+
597
+ 4. Verify Cozy Admin API is reachable:
598
+
599
+ ```bash
600
+ curl http://cozy-admin:6060/instances
601
+ ```
602
+
603
+ ### Problem: 404 Errors
604
+
605
+ **Symptoms:**
606
+
607
+ ```json
608
+ { "level": "debug", "message": "Cozy instance not found" }
609
+ ```
610
+
611
+ **Solutions:**
612
+
613
+ 1. Provision Cozy instance for the user first
614
+ 2. Verify `twakeCozyDomain` attribute matches the actual Cozy domain
615
+ 3. Check Cozy logs for instance existence
616
+
617
+ ### Problem: Authentication Errors
618
+
619
+ **Symptoms:**
620
+
621
+ ```json
622
+ { "http_status": 401, "http_status_text": "Unauthorized" }
623
+ ```
624
+
625
+ **Solutions:**
626
+
627
+ 1. Verify admin token is correct
628
+ 2. Check token hasn't expired
629
+ 3. Ensure Admin API is configured to accept bearer tokens
630
+
631
+ ## Integration with James
632
+
633
+ When using both James and Drive plugins, email changes are propagated to both services:
634
+
635
+ ```bash
636
+ --plugin twake/james \
637
+ --plugin twake/drive
638
+ ```
639
+
640
+ Both plugins listen to `onLdapMailChange` and update their respective services in parallel.
641
+
642
+ ## See Also
643
+
644
+ - [onChange.md](onChange.md) - LDAP change detection
645
+ - [james-mail.md](james-mail.md) - James mail server integration
646
+ - [Cozy Admin Documentation](https://docs.cozy.io/en/cozy-stack/admin/) - Admin API reference
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ldap-rest",
3
- "version": "0.1.9",
3
+ "version": "0.2.1",
4
4
  "description": "Light directory manager",
5
5
  "keywords": [
6
6
  "Identity-Manager"
@@ -136,6 +136,10 @@
136
136
  "types": "./dist/src/plugins/twake/calendarResources.d.ts",
137
137
  "import": "./dist/plugins/twake/calendarResources.js"
138
138
  },
139
+ "./plugin-twake-drive": {
140
+ "types": "./dist/src/plugins/twake/drive.d.ts",
141
+ "import": "./dist/plugins/twake/drive.js"
142
+ },
139
143
  "./plugin-twake-james": {
140
144
  "types": "./dist/src/plugins/twake/james.d.ts",
141
145
  "import": "./dist/plugins/twake/james.js"
@@ -507,7 +511,8 @@
507
511
  "diff": ">=8.0.3",
508
512
  "fast-xml-parser": ">=5.3.6",
509
513
  "glob": ">=11.0.4",
510
- "minimatch": "^10.2.1"
514
+ "minimatch": "^10.2.1",
515
+ "serialize-javascript": ">=7.0.3"
511
516
  },
512
517
  "dependencies": {
513
518
  "body-parser": "^2.2.0",
@@ -1,4 +1,4 @@
1
- import { toTitleCase, escapeHtml } from './dom.js';
1
+ import { escapeHtml, toTitleCase } from './dom.js';
2
2
 
3
3
  /**
4
4
  * Form utility functions