├── src ├── design │ ├── updates.js │ ├── filters.js │ ├── audit.js │ └── app.js ├── client │ ├── components │ │ ├── NoMatch.jsx │ │ ├── Allowed.jsx │ │ ├── ResponsiveTable.jsx │ │ ├── DatabaseAdministration.jsx │ │ ├── DisplayGroup.jsx │ │ ├── SidebarLink.jsx │ │ ├── pages │ │ │ ├── ManageDatabasePage.jsx │ │ │ ├── GroupsPage.jsx │ │ │ ├── LoginPage.jsx │ │ │ └── GroupMembershipsPage.jsx │ │ ├── DisplayRight.jsx │ │ ├── DisplayGroupList.jsx │ │ ├── GroupDataElement.jsx │ │ ├── DisplayRightList.jsx │ │ ├── LoginButton.jsx │ │ ├── GlobalRights.jsx │ │ ├── DefaultGroups.jsx │ │ ├── DatabaseSelector.jsx │ │ ├── Home.jsx │ │ ├── GlobalRightsEditor.jsx │ │ ├── DefaultGroupsEditor.jsx │ │ ├── LoginGoogle.jsx │ │ ├── EnterTextField.jsx │ │ ├── Groups.jsx │ │ ├── Sidebar.jsx │ │ ├── GroupCreator.jsx │ │ ├── GroupDataEditor.jsx │ │ ├── LoginGeneric.jsx │ │ ├── Login.jsx │ │ ├── EditableTextField.jsx │ │ ├── CreateUser.jsx │ │ └── ChangePassword.jsx │ ├── constants.js │ ├── styles │ │ └── index.css │ ├── reducers │ │ ├── dbName.js │ │ ├── main.js │ │ └── login.js │ ├── index.jsx │ ├── actions │ │ ├── main.js │ │ └── login.js │ ├── api.js │ ├── store.js │ └── dbManager.js ├── config │ ├── global.mjs │ ├── cli.js │ ├── env.js │ ├── home.js │ └── config.js ├── server │ ├── middleware │ │ ├── respondOk.js │ │ ├── decorateError.js │ │ └── util.js │ ├── error.js │ └── auth │ │ ├── ldap │ │ └── index.js │ │ ├── couchdb │ │ └── index.js │ │ ├── facebook │ │ └── index.js │ │ ├── oidc │ │ └── index.js │ │ └── google │ │ └── index.js ├── util │ ├── CouchError.js │ ├── isEmail.js │ ├── simpleMerge.js │ ├── die.js │ ├── ensureStringArray.js │ ├── load.js │ ├── tryMove.js │ ├── debug.js │ ├── groups.js │ ├── orcid.js │ ├── array_sets.js │ ├── getConfiguredDbs.js │ ├── token.js │ └── LDAP.js ├── index.js ├── initCouch.js ├── connect.js ├── import │ ├── ImportContext.js │ ├── index.js │ └── saveResult.js ├── init │ └── auditActions.js ├── constants.js ├── audit │ └── actions.js └── couch │ ├── find.js │ ├── imports.js │ ├── token.js │ ├── log.js │ ├── util.js │ ├── nano.js │ └── doc.js ├── test ├── homeDirectories │ ├── dev │ │ ├── test │ │ │ └── config.js │ │ ├── test-new-import │ │ │ └── config.js │ │ └── config.js │ ├── package.json │ ├── main │ │ ├── test-new-import │ │ │ ├── changeFilename │ │ │ │ ├── to_process │ │ │ │ │ └── test.txt │ │ │ │ └── import.js │ │ │ ├── error │ │ │ │ └── import.js │ │ │ ├── config.js │ │ │ ├── esm_mjs │ │ │ │ └── import.mjs │ │ │ ├── noReference │ │ │ │ └── import.js │ │ │ ├── full │ │ │ │ └── import.js │ │ │ └── separate │ │ │ │ └── import.js │ │ ├── test-ldap │ │ │ └── config.js │ │ ├── test-by-owner-unicity │ │ │ ├── lib.js │ │ │ ├── views │ │ │ │ └── testCustom.js │ │ │ ├── indexes │ │ │ │ └── testIndex.js │ │ │ └── config.js │ │ ├── test-global-unicity │ │ │ └── config.js │ │ ├── test │ │ │ └── indexes │ │ │ │ └── testIndex.js │ │ └── config.js │ ├── failUnallowedOverride │ │ ├── config.js │ │ └── testDatabase │ │ │ └── config.js │ ├── failDuplicateView │ │ └── testDatabase │ │ │ └── views │ │ │ ├── v1.js │ │ │ └── v2.js │ ├── failShareName │ │ └── testDatabase │ │ │ ├── views │ │ │ └── v0.js │ │ │ └── indexes │ │ │ └── i0.js │ ├── failShareDesignDoc │ │ └── testDatabase │ │ │ ├── views │ │ │ └── v0.js │ │ │ └── indexes │ │ │ └── i0.js │ ├── failDuplicateIndex │ │ └── testDatabase │ │ │ └── indexes │ │ │ ├── i1.js │ │ │ └── i2.js │ ├── failEsmWrongExport │ │ └── testDatabase │ │ │ └── wrongExport │ │ │ └── import.mjs │ ├── failEsmInJsFile │ │ └── testDatabase │ │ │ └── esm │ │ │ └── import.js │ └── constants.js ├── package.json ├── data │ ├── test.json │ ├── constants.js │ ├── anyuser.js │ ├── byOwnerEntryUnicity.js │ ├── insertDocument.js │ ├── globalEntryUnicity.js │ └── noRights.js ├── utils │ ├── testUtils.js │ ├── agent.js │ ├── couch.js │ ├── authenticate.js │ └── utils.js ├── unit │ ├── orcid.test.js │ ├── config │ │ ├── global_config.test.js │ │ ├── env_config.test.js │ │ └── load_db_config_errors.test.js │ ├── server │ │ ├── routes │ │ │ └── auth.test.js │ │ └── file_drop.test.js │ ├── import │ │ └── import_context.test.js │ ├── rights │ │ ├── default_groups.test.js │ │ ├── global.test.js │ │ ├── no_rights.test.js │ │ └── groups.test.js │ ├── rest-api │ │ ├── owners.test.js │ │ └── couchdb_user.test.js │ ├── attachments.test.js │ ├── token2.test.js │ ├── user.test.js │ ├── global_entry_unicity.test.js │ ├── by_owner_entry_unicity.test.js │ └── basic.test.js └── setup.js ├── codecov.yml ├── .dockerignore ├── .prettierignore ├── public └── assets │ ├── img │ ├── favicon.ico │ └── logo │ │ └── google_signin.png │ └── fonts │ ├── FontAwesome.otf │ ├── fontawesome-webfont.eot │ ├── fontawesome-webfont.ttf │ ├── fontawesome-webfont.woff │ └── fontawesome-webfont.woff2 ├── .gitignore ├── .prettierrc.json ├── bin ├── rest-on-couch-file-drop.js ├── rest-on-couch-server.js └── rest-on-couch-log.js ├── process.json ├── .ncurc.yml ├── vite.config.mjs ├── compose.yaml ├── Dockerfile ├── .env.test ├── node_test_coverage.config.json ├── .env.dev ├── index.html ├── Building.md ├── .github └── workflows │ ├── docker-image.yml │ ├── release.yml │ ├── codeql-analysis.yml │ ├── docker_run.yml │ └── nodejs.yml ├── LICENSE ├── tools └── batch │ └── addGroupToEntryByKind.js ├── eslint.config.mjs ├── scripts └── setup_database.mjs └── views └── login.hbs /src/design/updates.js: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /test/homeDirectories/dev/test/config.js: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /test/homeDirectories/dev/test-new-import/config.js: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /test/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "type": "module" 3 | } 4 | -------------------------------------------------------------------------------- /test/data/test.json: -------------------------------------------------------------------------------- 1 | { 2 | "database": "test" 3 | } 4 | -------------------------------------------------------------------------------- /test/homeDirectories/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "type": "commonjs" 3 | } 4 | -------------------------------------------------------------------------------- /codecov.yml: -------------------------------------------------------------------------------- 1 | ignore: 2 | - 'test/homeDirectories/' # ignore folders and all its contents 3 | -------------------------------------------------------------------------------- /.dockerignore: -------------------------------------------------------------------------------- 1 | conf 2 | coverage 3 | docs 4 | node_modules 5 | public/bundle* 6 | test 7 | tools 8 | .git 9 | -------------------------------------------------------------------------------- /.prettierignore: -------------------------------------------------------------------------------- 1 | /public 2 | /CHANGELOG.md 3 | /views 4 | /coverage 5 | /dist 6 | /src/client/styles/lib 7 | -------------------------------------------------------------------------------- /test/homeDirectories/main/test-new-import/changeFilename/to_process/test.txt: -------------------------------------------------------------------------------- 1 | changeFilename test import file -------------------------------------------------------------------------------- /public/assets/img/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/cheminfo/rest-on-couch/HEAD/public/assets/img/favicon.ico -------------------------------------------------------------------------------- /public/assets/fonts/FontAwesome.otf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/cheminfo/rest-on-couch/HEAD/public/assets/fonts/FontAwesome.otf -------------------------------------------------------------------------------- /src/client/components/NoMatch.jsx: -------------------------------------------------------------------------------- 1 | export default function NoMatch() { 2 | return
Error: route not found
; 3 | } 4 | -------------------------------------------------------------------------------- /public/assets/img/logo/google_signin.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/cheminfo/rest-on-couch/HEAD/public/assets/img/logo/google_signin.png -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Coverage files 2 | lcov.info 3 | 4 | 5 | 6 | node_modules 7 | 8 | .eslintcache 9 | 10 | # Front build 11 | /dist 12 | -------------------------------------------------------------------------------- /public/assets/fonts/fontawesome-webfont.eot: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/cheminfo/rest-on-couch/HEAD/public/assets/fonts/fontawesome-webfont.eot -------------------------------------------------------------------------------- /public/assets/fonts/fontawesome-webfont.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/cheminfo/rest-on-couch/HEAD/public/assets/fonts/fontawesome-webfont.ttf -------------------------------------------------------------------------------- /public/assets/fonts/fontawesome-webfont.woff: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/cheminfo/rest-on-couch/HEAD/public/assets/fonts/fontawesome-webfont.woff -------------------------------------------------------------------------------- /public/assets/fonts/fontawesome-webfont.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/cheminfo/rest-on-couch/HEAD/public/assets/fonts/fontawesome-webfont.woff2 -------------------------------------------------------------------------------- /.prettierrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "arrowParens": "always", 3 | "semi": true, 4 | "singleQuote": true, 5 | "tabWidth": 2, 6 | "trailingComma": "all" 7 | } 8 | -------------------------------------------------------------------------------- /bin/rest-on-couch-file-drop.js: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | 3 | 'use strict'; 4 | 5 | const server = require('../src/file-drop/server'); 6 | 7 | server.start(); 8 | -------------------------------------------------------------------------------- /process.json: -------------------------------------------------------------------------------- 1 | { 2 | "apps": [ 3 | { 4 | "name": "rest-on-couch", 5 | "post_update": ["npm install", "npm run compile"] 6 | } 7 | ] 8 | } 9 | -------------------------------------------------------------------------------- /test/utils/testUtils.js: -------------------------------------------------------------------------------- 1 | export default { 2 | uuidReg: /^([a-f0-9]){32}$/, 3 | revReg: /^\d+-([a-f0-9]){32}$/, 4 | tokenReg: /^[a-zA-Z0-9]{32}$/, 5 | }; 6 | -------------------------------------------------------------------------------- /src/config/global.mjs: -------------------------------------------------------------------------------- 1 | export const globalConfigSymbol = Symbol('global_config'); 2 | 3 | export function getConfigGlobal() { 4 | return global[globalConfigSymbol]; 5 | } 6 | -------------------------------------------------------------------------------- /test/homeDirectories/main/test-new-import/error/import.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | module.exports = async function errorImport() { 4 | throw new Error('this import is wrong'); 5 | }; 6 | -------------------------------------------------------------------------------- /src/design/filters.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | /* istanbul ignore file */ 4 | 5 | const filters = module.exports; 6 | 7 | filters.logs = function (doc) { 8 | return doc.$type === 'log'; 9 | }; 10 | -------------------------------------------------------------------------------- /test/homeDirectories/failUnallowedOverride/config.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | module.exports = { 4 | customDesign: { 5 | updates: { 6 | updateFun: function () {}, 7 | }, 8 | }, 9 | }; 10 | -------------------------------------------------------------------------------- /test/homeDirectories/failDuplicateView/testDatabase/views/v1.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | module.exports = { 4 | viewTest: { 5 | map: function (doc) { 6 | emit(doc._id); 7 | }, 8 | }, 9 | }; 10 | -------------------------------------------------------------------------------- /test/homeDirectories/failDuplicateView/testDatabase/views/v2.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | module.exports = { 4 | viewTest: { 5 | map: function (doc) { 6 | emit(doc._id); 7 | }, 8 | }, 9 | }; 10 | -------------------------------------------------------------------------------- /test/homeDirectories/failUnallowedOverride/testDatabase/config.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | module.exports = { 4 | customDesign: { 5 | updates: { 6 | updateFun: function () {}, 7 | }, 8 | }, 9 | }; 10 | -------------------------------------------------------------------------------- /test/homeDirectories/main/test-new-import/config.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | module.exports = { 4 | rights: { 5 | read: ['anonymous'], 6 | createGroup: ['anyuser'], 7 | create: ['anyuser'], 8 | }, 9 | }; 10 | -------------------------------------------------------------------------------- /src/server/middleware/respondOk.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const OK = { ok: true }; 4 | 5 | function respondOk(ctx, status = 200) { 6 | ctx.status = status; 7 | ctx.body = OK; 8 | } 9 | 10 | module.exports = respondOk; 11 | -------------------------------------------------------------------------------- /src/util/CouchError.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | class CouchError extends Error { 4 | constructor(message, reason) { 5 | super(message); 6 | this.reason = reason || ''; 7 | } 8 | } 9 | 10 | module.exports = CouchError; 11 | -------------------------------------------------------------------------------- /test/homeDirectories/failShareName/testDatabase/views/v0.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | module.exports = { 4 | test: { 5 | map: function (doc) { 6 | emit(doc._id); 7 | }, 8 | designDoc: 'viewTest', 9 | }, 10 | }; 11 | -------------------------------------------------------------------------------- /src/client/components/Allowed.jsx: -------------------------------------------------------------------------------- 1 | export default function Allowed(props) { 2 | if (props.allowed) { 3 | return
{props.children}
; 4 | } else { 5 | return
You are not allowed to access this page
; 6 | } 7 | } 8 | -------------------------------------------------------------------------------- /src/util/isEmail.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | // do not forget to update the same regex in design/validateDocUpdate 4 | const isEmailRegExp = /^.+@.+$/; 5 | 6 | module.exports = function isEmail(str) { 7 | return isEmailRegExp.test(str); 8 | }; 9 | -------------------------------------------------------------------------------- /test/homeDirectories/failShareDesignDoc/testDatabase/views/v0.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | module.exports = { 4 | viewTest: { 5 | map: function (doc) { 6 | emit(doc._id); 7 | }, 8 | designDoc: 'foo', 9 | }, 10 | }; 11 | -------------------------------------------------------------------------------- /test/homeDirectories/main/test-ldap/config.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | module.exports = { 4 | ldapSync: false, 5 | ldapUrl: 'ldap://127.0.0.1:1389', 6 | ldapBindDN: 'cn=Manager,dc=test,dc=lan', 7 | ldapBindPassword: 'xxxxxxxxx', 8 | }; 9 | -------------------------------------------------------------------------------- /test/homeDirectories/failShareName/testDatabase/indexes/i0.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | module.exports = { 4 | test: { 5 | index: { 6 | fields: ['foo'], 7 | }, 8 | type: 'json', 9 | ddoc: 'indexTest', 10 | }, 11 | }; 12 | -------------------------------------------------------------------------------- /test/homeDirectories/failDuplicateIndex/testDatabase/indexes/i1.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | module.exports = { 4 | indexTest: { 5 | index: { 6 | fields: ['foo'], 7 | }, 8 | type: 'json', 9 | ddoc: 'foo', 10 | }, 11 | }; 12 | -------------------------------------------------------------------------------- /test/homeDirectories/failDuplicateIndex/testDatabase/indexes/i2.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | module.exports = { 4 | indexTest: { 5 | index: { 6 | fields: ['foo'], 7 | }, 8 | type: 'json', 9 | ddoc: 'foo', 10 | }, 11 | }; 12 | -------------------------------------------------------------------------------- /test/homeDirectories/failShareDesignDoc/testDatabase/indexes/i0.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | module.exports = { 4 | modDate: { 5 | index: { 6 | fields: ['foo'], 7 | }, 8 | type: 'json', 9 | ddoc: 'foo', 10 | }, 11 | }; 12 | -------------------------------------------------------------------------------- /src/client/components/ResponsiveTable.jsx: -------------------------------------------------------------------------------- 1 | const ResponsiveTable = ({ children }) => ( 2 |
3 | {children}
4 |
5 | ); 6 | 7 | export default ResponsiveTable; 8 | -------------------------------------------------------------------------------- /src/client/constants.js: -------------------------------------------------------------------------------- 1 | export const globalRightTypes = [ 2 | 'delete', 3 | 'read', 4 | 'write', 5 | 'create', 6 | 'readGroup', 7 | 'writeGroup', 8 | 'createGroup', 9 | 'readImport', 10 | 'owner', 11 | 'addAttachment', 12 | ]; 13 | -------------------------------------------------------------------------------- /.ncurc.yml: -------------------------------------------------------------------------------- 1 | reject: 2 | # v3 of ldapjs is a large refactoring followed closely by maintainer abandoning the project 3 | # I am concerned it might not be stable enough 4 | # Possible alternative more actively maintained is https://github.com/ldapts/ldapts 5 | - ldapjs 6 | -------------------------------------------------------------------------------- /src/util/simpleMerge.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | module.exports = function simpleMerge(source, target) { 4 | for (var key in source) { 5 | if (Object.hasOwn(source, key)) { 6 | target[key] = source[key]; 7 | } 8 | } 9 | return target; 10 | }; 11 | -------------------------------------------------------------------------------- /vite.config.mjs: -------------------------------------------------------------------------------- 1 | import react from '@vitejs/plugin-react'; 2 | import { defineConfig } from 'vite'; 3 | 4 | export default defineConfig({ 5 | plugins: [react()], 6 | base: './', 7 | server: { 8 | host: '127.0.0.1', 9 | port: 3309, 10 | }, 11 | }); 12 | -------------------------------------------------------------------------------- /src/client/styles/index.css: -------------------------------------------------------------------------------- 1 | @import 'bootstrap/dist/css/bootstrap.min.css'; 2 | @import './lib/animate.min.css'; 3 | @import './lib/light-bootstrap-dashboard.css'; 4 | @import 'font-awesome/css/font-awesome.min.css'; 5 | 6 | @import '@fontsource-variable/roboto/standard.css'; 7 | -------------------------------------------------------------------------------- /test/homeDirectories/main/test-by-owner-unicity/lib.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | // Alternative way 4 | // exports.fortyTwo = function () { 5 | // return 42; 6 | // }; 7 | 8 | module.exports = { 9 | fortyTwo: function () { 10 | return 'forty two'; 11 | }, 12 | }; 13 | -------------------------------------------------------------------------------- /test/homeDirectories/main/test-global-unicity/config.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | module.exports = { 4 | customDesign: { 5 | version: 8, 6 | views: { 7 | lib: {}, 8 | }, 9 | updates: {}, 10 | filters: {}, 11 | }, 12 | entryUnicity: 'global', 13 | }; 14 | -------------------------------------------------------------------------------- /test/homeDirectories/main/test-by-owner-unicity/views/testCustom.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | module.exports = { 4 | testCustom: { 5 | map: function (doc) { 6 | if (doc.$type === 'entry') { 7 | emit(doc._id); 8 | } 9 | }, 10 | designDoc: 'custom', 11 | }, 12 | }; 13 | -------------------------------------------------------------------------------- /compose.yaml: -------------------------------------------------------------------------------- 1 | services: 2 | db: 3 | image: couchdb:3.5 4 | environment: 5 | COUCHDB_USER: admin 6 | COUCHDB_PASSWORD: admin 7 | ports: 8 | - 127.0.0.1:5984:5984 9 | ldap: 10 | image: ghcr.io/zakodium/ldap-with-users:1 11 | ports: 12 | - 127.0.0.1:1389:389 13 | -------------------------------------------------------------------------------- /src/client/reducers/dbName.js: -------------------------------------------------------------------------------- 1 | import { SET_DB_NAME } from '../actions/db'; 2 | 3 | const dbNameReducer = (state = '', action = {}) => { 4 | if (action.type === SET_DB_NAME) { 5 | return action.payload; 6 | } else { 7 | return state; 8 | } 9 | }; 10 | 11 | export default dbNameReducer; 12 | -------------------------------------------------------------------------------- /src/index.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const Couch = require('./couch'); 4 | const debug = require('./util/debug')('main'); 5 | 6 | process.on('unhandledRejection', function handleUnhandledRejection(err) { 7 | debug.error('unhandled rejection: %s', err.stack); 8 | }); 9 | 10 | module.exports = Couch; 11 | -------------------------------------------------------------------------------- /test/homeDirectories/main/test-new-import/esm_mjs/import.mjs: -------------------------------------------------------------------------------- 1 | export function importFile(ctx, result) { 2 | result.kind = 'sample'; 3 | result.id = 'esm_import'; 4 | result.reference = 'esm_import'; 5 | result.owner = 'a@a.com'; 6 | result.jpath = ['main', 'jpath']; 7 | result.field = 'field'; 8 | } 9 | -------------------------------------------------------------------------------- /src/util/die.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | /** 4 | * Prints an optional message and exits the process 5 | * @param {string} message 6 | */ 7 | module.exports = function die(message) { 8 | if (message) { 9 | process.stderr.write(`rest-on-couch: ${message}\n`); 10 | } 11 | process.exit(1); 12 | }; 13 | -------------------------------------------------------------------------------- /test/data/constants.js: -------------------------------------------------------------------------------- 1 | const constants = { 2 | newEntry: { 3 | $id: 'E', 4 | $content: { 5 | test: true, 6 | }, 7 | }, 8 | newEntryWithId: { 9 | $id: 'D', 10 | $content: { 11 | test: true, 12 | }, 13 | _id: 'D', 14 | }, 15 | }; 16 | 17 | export default constants; 18 | -------------------------------------------------------------------------------- /src/client/components/DatabaseAdministration.jsx: -------------------------------------------------------------------------------- 1 | import DefaultGroups from './DefaultGroups'; 2 | import GlobalRights from './GlobalRights'; 3 | 4 | export default function DatabaseAdministration() { 5 | return ( 6 |
7 | 8 | 9 |
10 | ); 11 | } 12 | -------------------------------------------------------------------------------- /src/util/ensureStringArray.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | module.exports = function ensureStringArray(value, name = 'value') { 4 | if (typeof value === 'string') { 5 | return [value]; 6 | } else if (Array.isArray(value)) { 7 | return value; 8 | } 9 | throw new Error(`${name} must be a string or array`); 10 | }; 11 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM node:24 2 | 3 | WORKDIR /rest-on-couch-source 4 | COPY ./ ./ 5 | RUN npm ci && NODE_ENV=production npm run build && rm -rf node_modules 6 | 7 | ENV NODE_ENV=production 8 | ENV REST_ON_COUCH_HOME_DIR=/rest-on-couch 9 | 10 | RUN npm ci && rm -rf /root/.npm 11 | 12 | CMD ["node", "bin/rest-on-couch-server.js"] 13 | -------------------------------------------------------------------------------- /src/client/components/DisplayGroup.jsx: -------------------------------------------------------------------------------- 1 | import DisplayRightList from './DisplayRightList'; 2 | 3 | const DisplayGroup = (props) => { 4 | return ( 5 |
6 |

{props.group.name}

7 | 8 |
9 | ); 10 | }; 11 | 12 | export default DisplayGroup; 13 | -------------------------------------------------------------------------------- /test/homeDirectories/failEsmWrongExport/testDatabase/wrongExport/import.mjs: -------------------------------------------------------------------------------- 1 | export function importFileWrongNamedExport(ctx, result) { 2 | result.kind = 'sample'; 3 | result.id = 'esm_import'; 4 | result.reference = 'esm_import'; 5 | result.owner = 'a@a.com'; 6 | result.jpath = ['main', 'jpath']; 7 | result.field = 'field'; 8 | } 9 | -------------------------------------------------------------------------------- /.env.test: -------------------------------------------------------------------------------- 1 | 2 | # Debug log level for rest-on-couch services 3 | # DEBUG=couch:* 4 | 5 | REST_ON_COUCH_LDAP_URL="ldap://127.0.0.1:1389" 6 | REST_ON_COUCH_LDAP_BIND_D_N="cn=admin,dc=zakodium,dc=com" 7 | REST_ON_COUCH_LDAP_BIND_PASSWORD=admin 8 | REST_ON_COUCH_HOME_DIR=test/homeDirectories/main 9 | REST_ON_COUCH_URL=http://127.0.0.1:5984 10 | -------------------------------------------------------------------------------- /src/client/components/SidebarLink.jsx: -------------------------------------------------------------------------------- 1 | import { NavLink } from 'react-router-dom'; 2 | 3 | export default function SidebarLink({ to, icon, text }) { 4 | return ( 5 |
  • 6 | (props.isActive ? 'active' : '')}> 7 | 8 |

    {text}

    9 |
    10 |
  • 11 | ); 12 | } 13 | -------------------------------------------------------------------------------- /test/homeDirectories/failEsmInJsFile/testDatabase/esm/import.js: -------------------------------------------------------------------------------- 1 | export default function importFile(ctx, result) { 2 | result.kind = 'sample'; 3 | result.id = 'esm_import'; 4 | result.reference = 'esm_import'; 5 | result.owner = 'a@a.com'; 6 | result.jpath = ['main', 'jpath']; 7 | result.field = 'field'; 8 | result.metadata = { 9 | noRefMetadata: true, 10 | }; 11 | } 12 | -------------------------------------------------------------------------------- /test/homeDirectories/main/test-new-import/noReference/import.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | module.exports = async function noReferenceImport(ctx, result) { 4 | result.kind = 'sample'; 5 | result.id = 'noReference'; 6 | result.owner = 'a@a.com'; 7 | result.jpath = ['main', 'jpath']; 8 | result.field = 'field'; 9 | result.metadata = { 10 | noRefMetadata: true, 11 | }; 12 | }; 13 | -------------------------------------------------------------------------------- /src/client/components/pages/ManageDatabasePage.jsx: -------------------------------------------------------------------------------- 1 | import Allowed from '../Allowed'; 2 | import DatabaseAdministration from '../DatabaseAdministration'; 3 | 4 | export default function ManageDatabasePage(props) { 5 | return ( 6 | 7 | ; 8 | 9 | ); 10 | } 11 | -------------------------------------------------------------------------------- /test/unit/orcid.test.js: -------------------------------------------------------------------------------- 1 | import { it } from 'node:test'; 2 | import { expect } from 'chai'; 3 | 4 | import okORCID from '../../src/util/orcid.js'; 5 | 6 | it('test ORCID checksum calculation', () => { 7 | expect(okORCID('0000-0003-4894-4660')).toBe(true); 8 | expect(okORCID(undefined)).toBe(false); 9 | expect(okORCID(39070)).toBe(false); 10 | expect(okORCID('0000-0003-4894-4661')).toBe(false); 11 | }); 12 | -------------------------------------------------------------------------------- /src/client/components/pages/GroupsPage.jsx: -------------------------------------------------------------------------------- 1 | import Allowed from '../Allowed'; 2 | import Groups from '../Groups'; 3 | 4 | export default function GroupsPage(props) { 5 | if (!props.hasDb) return
    Please select a database
    ; 6 | return ( 7 | 10 | 11 | 12 | ); 13 | } 14 | -------------------------------------------------------------------------------- /src/client/reducers/main.js: -------------------------------------------------------------------------------- 1 | import { ROC_ONLINE } from '../actions/main'; 2 | 3 | const initialState = { 4 | rocOnline: null, 5 | }; 6 | 7 | const mainReducer = (state = initialState, action = {}) => { 8 | switch (action.type) { 9 | case ROC_ONLINE: 10 | return { ...state, rocOnline: action.payload }; 11 | default: 12 | return state; 13 | } 14 | }; 15 | 16 | export default mainReducer; 17 | -------------------------------------------------------------------------------- /test/utils/agent.js: -------------------------------------------------------------------------------- 1 | import supertest from 'supertest'; 2 | import { getApp as getMainApp } from '../../src/server/server.js'; 3 | import { getApp as getFileDropApp } from '../../src/file-drop/server.js'; 4 | 5 | export function getAgent() { 6 | return supertest.agent(getMainApp().callback()); 7 | } 8 | 9 | export function getFileDropAgent() { 10 | return supertest.agent(getFileDropApp().callback()); 11 | } 12 | -------------------------------------------------------------------------------- /src/client/index.jsx: -------------------------------------------------------------------------------- 1 | import { StrictMode } from 'react'; 2 | import { createRoot } from 'react-dom/client'; 3 | import { Provider } from 'react-redux'; 4 | 5 | import App from './components/App'; 6 | import store from './store'; 7 | 8 | const root = createRoot(document.getElementById('root')); 9 | 10 | root.render( 11 | 12 | 13 | 14 | 15 | , 16 | ); 17 | -------------------------------------------------------------------------------- /bin/rest-on-couch-server.js: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | 3 | 'use strict'; 4 | 5 | const { program } = require('commander'); 6 | 7 | const server = require('../src/server/server'); 8 | const debug = require('../src/util/debug')('bin:server'); 9 | 10 | program 11 | .option('-c --config ', 'Path to custom config file') 12 | .parse(process.argv); 13 | 14 | server.start().then(() => { 15 | debug('server started successfully'); 16 | }); 17 | -------------------------------------------------------------------------------- /src/util/load.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const Couch = require('../index'); 4 | 5 | const debug = require('./debug')('util:load'); 6 | const getConfiguredDbs = require('./getConfiguredDbs'); 7 | 8 | async function loadCouch() { 9 | debug.trace('preload databases that have a configuration file'); 10 | const configuredDbs = await getConfiguredDbs(); 11 | configuredDbs.forEach((db) => Couch.get(db)); 12 | } 13 | 14 | module.exports = loadCouch; 15 | -------------------------------------------------------------------------------- /src/client/components/pages/LoginPage.jsx: -------------------------------------------------------------------------------- 1 | import { useEffect } from 'react'; 2 | import { useNavigate } from 'react-router-dom'; 3 | 4 | import Login from '../Login'; 5 | 6 | export function LoginPage(props) { 7 | const navigate = useNavigate(); 8 | useEffect(() => { 9 | if (props.loggedIn) { 10 | navigate('/'); 11 | } 12 | }, [props.loggedIn, navigate]); 13 | 14 | if (props.loggedIn) return null; 15 | return ; 16 | } 17 | -------------------------------------------------------------------------------- /test/homeDirectories/main/test-by-owner-unicity/indexes/testIndex.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | module.exports = { 4 | modDate: { 5 | index: { 6 | fields: [ 7 | { 8 | '\\$modificationDate': 'asc', 9 | }, 10 | ], 11 | partial_filter_selector: { 12 | '\\$kind': { 13 | $eq: 'sample', 14 | }, 15 | }, 16 | }, 17 | type: 'json', 18 | ddoc: 'modDateIndex', 19 | }, 20 | }; 21 | -------------------------------------------------------------------------------- /src/client/components/DisplayRight.jsx: -------------------------------------------------------------------------------- 1 | const labelTypes = { 2 | create: 'success', 3 | read: 'info', 4 | write: 'warning', 5 | delete: 'danger', 6 | }; 7 | 8 | const DisplayRight = (props) => { 9 | const labelType = labelTypes[props.right] || 'default'; 10 | return ( 11 | 12 | {` ${props.right} `} 13 | 14 | ); 15 | }; 16 | 17 | export default DisplayRight; 18 | -------------------------------------------------------------------------------- /src/initCouch.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const { getGlobalConfig } = require('./config/config'); 4 | const { open } = require('./connect'); 5 | const setupAuditActions = require('./init/auditActions'); 6 | const loadCouch = require('./util/load'); 7 | 8 | async function initCouch() { 9 | const config = getGlobalConfig(); 10 | const nano = await open(); 11 | if (config.auditActions) { 12 | await setupAuditActions(nano); 13 | } 14 | loadCouch(); 15 | } 16 | 17 | module.exports = initCouch; 18 | -------------------------------------------------------------------------------- /src/design/audit.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | /* istanbul ignore file */ 4 | 5 | const auditDesignDoc = { 6 | version: 2, // CHANGE THIS NUMBER IF YOU UPDATE THE DESIGN DOC 7 | _id: '_design/audit', 8 | views: { 9 | byDate: { 10 | map: function (doc) { 11 | emit(doc.date); 12 | }, 13 | }, 14 | byUsername: { 15 | map: function (doc) { 16 | emit([doc.username, doc.date]); 17 | }, 18 | }, 19 | }, 20 | }; 21 | 22 | module.exports = auditDesignDoc; 23 | -------------------------------------------------------------------------------- /node_test_coverage.config.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://nodejs.org/dist/v24.11.1/docs/node-config-schema.json", 3 | "nodeOptions": { 4 | "import": "./test/setup.js" 5 | }, 6 | "testRunner": { 7 | "experimental-test-coverage": true, 8 | "test-coverage-include": "src/**/*", 9 | "test-coverage-exclude": "src/client/**/*", 10 | "test-concurrency": 1, 11 | "test-timeout": 20000, 12 | "test-reporter": ["lcov", "spec"], 13 | "test-reporter-destination": ["lcov.info", "stdout"] 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /src/client/components/DisplayGroupList.jsx: -------------------------------------------------------------------------------- 1 | import DisplayGroup from './DisplayGroup'; 2 | 3 | const DisplayGroupList = (props) => { 4 | if (!props.groups) return null; 5 | if (props.groups.length === 0) { 6 | return

    No group memberships

    ; 7 | } 8 | return ( 9 |
    10 |

    List of group memberships

    11 | {props.groups.map((group) => { 12 | return ; 13 | })} 14 |
    15 | ); 16 | }; 17 | 18 | export default DisplayGroupList; 19 | -------------------------------------------------------------------------------- /src/client/components/GroupDataElement.jsx: -------------------------------------------------------------------------------- 1 | const GroupDataElement = ({ value, removeValue, editable }) => ( 2 | 3 | {value} 4 | 5 | {editable ? ( 6 | 13 | ) : null} 14 | 15 | 16 | ); 17 | 18 | export default GroupDataElement; 19 | -------------------------------------------------------------------------------- /test/homeDirectories/main/test-new-import/changeFilename/import.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | module.exports = async function importFunction(ctx, result) { 4 | result.kind = 'sample'; 5 | result.id = ctx.filename; 6 | result.filename = 'newFilename.txt'; 7 | result.owner = 'a@a.com'; 8 | result.reference = ctx.filename; 9 | result.field = 'field'; 10 | result.jpath = ['jpath', 'in', 'document']; 11 | if (ctx.fileExt === '.txt') { 12 | result.content_type = 'text/plain'; 13 | } 14 | result.metadata = { 15 | hasMetadata: true, 16 | }; 17 | }; 18 | -------------------------------------------------------------------------------- /.env.dev: -------------------------------------------------------------------------------- 1 | OIDC_CLIENT_ID=351594866792271875 2 | # To test OIDC, set the Zakodium rest-on-couch app secret 3 | # Secret is available in 1password 4 | # It should be set in your IDE's run configuration to avoid editing this file 5 | OIDC_CLIENT_SECRET= 6 | 7 | REST_ON_COUCH_HOME_DIR=test/homeDirectories/dev 8 | 9 | # Debug log level for rest-on-couch services 10 | DEBUG=couch:error,couch:warn,couch:debug 11 | 12 | REST_ON_COUCH_LDAP_URL="ldap://127.0.0.1:1389" 13 | REST_ON_COUCH_LDAP_BIND_D_N="cn=admin,dc=zakodium,dc=com" 14 | REST_ON_COUCH_LDAP_BIND_PASSWORD=admin 15 | REST_ON_COUCH_URL=http://127.0.0.1:5984 16 | -------------------------------------------------------------------------------- /test/homeDirectories/main/test-by-owner-unicity/config.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | module.exports = { 4 | customDesign: { 5 | views: { 6 | lib: { 7 | test: ['lib.js'], 8 | }, 9 | test: { 10 | map: function (doc) { 11 | const libTest = require('views/lib/test'); 12 | if (doc.$type === 'entry') { 13 | emit(doc._id, libTest.fortyTwo()); 14 | } 15 | }, 16 | }, 17 | }, 18 | updates: {}, 19 | filters: { 20 | abc: function (doc) { 21 | return doc.$type === 'log'; 22 | }, 23 | }, 24 | }, 25 | }; 26 | -------------------------------------------------------------------------------- /index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | rest-on-couch 8 | 9 | 13 | 14 | 15 | 16 | 17 | 18 | 19 |
    20 | 21 | 22 | 23 | -------------------------------------------------------------------------------- /src/config/cli.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const path = require('path'); 4 | 5 | const cliArguments = require('minimist')(process.argv.slice(2)); 6 | 7 | const debug = require('../util/debug')('config:cli'); 8 | const die = require('../util/die'); 9 | 10 | module.exports = loadCliConfig(cliArguments.c || cliArguments.config); 11 | 12 | function loadCliConfig(source) { 13 | if (!source) { 14 | debug('no cli config'); 15 | return {}; 16 | } 17 | source = path.resolve(source); 18 | try { 19 | return require(source); 20 | } catch { 21 | return die(`could not load custom config from ${source}`); 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /test/homeDirectories/main/test/indexes/testIndex.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | module.exports = { 4 | modDate: { 5 | index: { 6 | fields: [ 7 | { 8 | '\\$modificationDate': 'asc', 9 | }, 10 | ], 11 | partial_filter_selector: { 12 | '\\$kind': { 13 | $eq: 'sample', 14 | }, 15 | }, 16 | }, 17 | type: 'json', 18 | ddoc: 'modDateIndex', 19 | }, 20 | x: { 21 | index: { 22 | fields: [ 23 | { 24 | '\\$content.x': 'asc', 25 | }, 26 | ], 27 | }, 28 | type: 'json', 29 | ddoc: 'xIndex', 30 | }, 31 | }; 32 | -------------------------------------------------------------------------------- /Building.md: -------------------------------------------------------------------------------- 1 | ## Building 2 | 3 | Install cheminfo-tools: 4 | `npm install -g cheminfo-tools` 5 | 6 | Make a release: 7 | `cheminfo publish` https://github.com/cheminfo/tools#publish 8 | 9 | ## Testing 10 | 11 | You are able to test the importation file direct from the terminal 12 | 13 | Install globally rest-on-couch 14 | 15 | `npm install --global rest-on-couch` 16 | 17 | Execute the following instruction: 18 | 19 | `DEBUG=couch* REST_ON_COUCH_HOME_DIR=/usr/local/rest-on-couch rest-on-couch-import fileToImport.jdx eln nmr --dry-run` 20 | 21 | - eln: name of the couchDB database 22 | - nmr: name of the folder in which the `import.js` is defined 23 | -------------------------------------------------------------------------------- /src/client/components/DisplayRightList.jsx: -------------------------------------------------------------------------------- 1 | import DisplayRight from './DisplayRight'; 2 | 3 | const rightImportance = { 4 | read: 1, 5 | create: 2, 6 | write: 3, 7 | delete: 4, 8 | }; 9 | 10 | const DisplayRightList = (props) => { 11 | if (!props.rights) return null; 12 | const rights = props.rights.slice().sort((a, b) => { 13 | return (rightImportance[a] || 0) - (rightImportance[b] || 0); 14 | }); 15 | 16 | return ( 17 |
    18 | {rights.map((right) => ( 19 | 20 | ))} 21 |
    22 | ); 23 | }; 24 | 25 | export default DisplayRightList; 26 | -------------------------------------------------------------------------------- /src/util/tryMove.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const fs = require('fs-extra'); 4 | 5 | module.exports = async function tryMove(from, to, suffix = 0) { 6 | if (suffix > 1000) { 7 | throw new Error('tryMove: too many retries'); 8 | } 9 | let newTo = to; 10 | if (suffix > 0) { 11 | newTo += `.${suffix}`; 12 | } 13 | try { 14 | await fs.move(from, newTo); 15 | } catch (error) { 16 | if (error.code !== 'EEXIST' && error.message !== 'dest already exists.') { 17 | throw new Error(`Could not rename ${from} to ${newTo}`, { 18 | cause: error, 19 | }); 20 | } 21 | await tryMove(from, to, ++suffix); 22 | } 23 | }; 24 | -------------------------------------------------------------------------------- /test/unit/config/global_config.test.js: -------------------------------------------------------------------------------- 1 | import { describe, it } from 'node:test'; 2 | import { expect } from 'chai'; 3 | import { getGlobalConfig } from '../../../src/config/config.js'; 4 | 5 | describe('initialization of global configuration properties', () => { 6 | it('should init proxyPrefix', () => { 7 | const config = getGlobalConfig({ proxyPrefix: 'roc/' }); 8 | expect(config.proxyPrefix).toBe('/roc'); 9 | }); 10 | 11 | it('should init publicAddress', () => { 12 | const config = getGlobalConfig({ 13 | publicAddress: 'http://127.0.0.1:3300/roc/', 14 | }); 15 | expect(config.publicAddress).toBe('http://127.0.0.1:3300/roc'); 16 | }); 17 | }); 18 | -------------------------------------------------------------------------------- /src/config/env.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const prefix = 'REST_ON_COUCH_'; 4 | const debug = require('../util/debug')('roc:env-config'); 5 | 6 | function getEnvConfig() { 7 | const envConfig = {}; 8 | for (const name in process.env) { 9 | if (name.startsWith(prefix)) { 10 | const realName = name 11 | .substring(prefix.length) 12 | .toLowerCase() 13 | .replace(/_(?[a-z])/g, (value) => { 14 | return value[1].toUpperCase(); 15 | }); 16 | 17 | debug('setting config from env, %s', realName); 18 | envConfig[realName] = process.env[name]; 19 | } 20 | } 21 | return envConfig; 22 | } 23 | 24 | module.exports = getEnvConfig; 25 | -------------------------------------------------------------------------------- /test/utils/couch.js: -------------------------------------------------------------------------------- 1 | export async function skipIfCouchV1(context) { 2 | const version = await getVersion(); 3 | if (version.startsWith('1.')) { 4 | context.skip('CouchDB 1.x does not support mango queries'); 5 | } 6 | } 7 | 8 | export async function getCouchMajorVersion() { 9 | const version = await getVersion(); 10 | return parseInt(version[0], 10); 11 | } 12 | 13 | export const getVersion = (() => { 14 | let version; 15 | return function getVersion() { 16 | if (!version) { 17 | version = fetch(process.env.REST_ON_COUCH_URL) 18 | .then((couchRes) => couchRes.json()) 19 | .then((value) => value.version); 20 | } 21 | return version; 22 | }; 23 | })(); 24 | -------------------------------------------------------------------------------- /src/server/middleware/decorateError.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const statusMessages = { 4 | 400: 'bad request', 5 | 401: 'unauthorized', 6 | 403: 'forbidden', 7 | 404: 'not found', 8 | 409: 'conflict', 9 | 500: 'internal server error', 10 | }; 11 | 12 | function decorateError(ctx, status, error = true) { 13 | ctx.status = status; 14 | if (responseHasBody(ctx)) { 15 | ctx.body = { 16 | error, 17 | code: statusMessages[status] || `error ${status}`, 18 | }; 19 | } 20 | } 21 | 22 | function responseHasBody(ctx) { 23 | const method = ctx.method; 24 | if (method === 'HEAD' || method === 'OPTIONS') return false; 25 | return true; 26 | } 27 | 28 | module.exports = { decorateError, responseHasBody }; 29 | -------------------------------------------------------------------------------- /src/util/debug.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const debugPkg = require('debug'); 4 | 5 | const logError = debugPkg('couch:error'); 6 | const logWarning = debugPkg('couch:warn'); 7 | const logDebug = debugPkg('couch:debug'); 8 | const logTrace = debugPkg('couch:trace'); 9 | 10 | module.exports = function debug(prefix) { 11 | const func = (message, ...args) => 12 | logDebug(`(${prefix}) ${message}`, ...args); 13 | func.error = (message, ...args) => 14 | logError(`(${prefix}) ${message}`, ...args); 15 | func.warn = (message, ...args) => 16 | logWarning(`(${prefix}) ${message}`, ...args); 17 | func.debug = func; 18 | func.trace = (message, ...args) => 19 | logTrace(`(${prefix}) ${message}`, ...args); 20 | return func; 21 | }; 22 | -------------------------------------------------------------------------------- /test/setup.js: -------------------------------------------------------------------------------- 1 | import { 2 | JestAsymmetricMatchers, 3 | JestChaiExpect, 4 | JestExtend, 5 | } from '@vitest/expect'; 6 | import * as chai from 'chai'; 7 | import { globalConfigSymbol } from '../src/config/global.mjs'; 8 | 9 | // allows using expect.extend instead of chai.use to extend plugins 10 | chai.use(JestExtend); 11 | // adds all jest matchers to expect 12 | chai.use(JestChaiExpect); 13 | // adds asymmetric matchers like stringContaining, objectContaining 14 | chai.use(JestAsymmetricMatchers); 15 | 16 | // Global parameters we do not want to repeat in every configuration file 17 | global[globalConfigSymbol] = { 18 | keys: ['app-key'], 19 | username: 'rest-on-couch', 20 | password: 'roc-123', 21 | adminPassword: 'admin', 22 | }; 23 | -------------------------------------------------------------------------------- /src/util/groups.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | async function getUserGroups(ctx, user, right, groups, mine) { 4 | var userGroups = await ctx.getGroupsByRight(user, right); 5 | userGroups.push(user); 6 | if (groups) { 7 | var groupsToUse = []; 8 | if (!Array.isArray(groups)) { 9 | groups = [groups]; 10 | } 11 | for (var i = 0; i < userGroups.length; i++) { 12 | if (groups.indexOf(userGroups[i]) >= 0) { 13 | groupsToUse.push(userGroups[i]); 14 | } 15 | } 16 | userGroups = groupsToUse; 17 | if (userGroups.indexOf(user) === -1 && mine) { 18 | userGroups.push(user); 19 | } 20 | } else if (mine) { 21 | userGroups = [user]; 22 | } 23 | return userGroups; 24 | } 25 | 26 | module.exports = { 27 | getUserGroups, 28 | }; 29 | -------------------------------------------------------------------------------- /.github/workflows/docker-image.yml: -------------------------------------------------------------------------------- 1 | name: Docker Image CI 2 | 3 | on: 4 | workflow_dispatch: 5 | inputs: 6 | ref: 7 | description: 'GitHub ref to checkout' 8 | required: true 9 | default: main 10 | tag: 11 | description: 'Docker tag to push (do NOT use "latest" or version numbers)' 12 | required: true 13 | default: 'HEAD' 14 | push: 15 | tags: 16 | - v* 17 | 18 | jobs: 19 | docker-image: 20 | # Documentation: https://github.com/zakodium/workflows#docker-image 21 | uses: zakodium/workflows/.github/workflows/docker-image.yml@docker-image-v1 22 | with: 23 | ref: ${{ inputs.ref }} 24 | tag: ${{ inputs.tag }} 25 | tag-version: ${{ github.event_name == 'push' && github.ref_name || '' }} 26 | -------------------------------------------------------------------------------- /test/homeDirectories/dev/config.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const { ldapAuthConfig, oidcAuthConfig } = require('../constants'); 4 | 5 | module.exports = { 6 | port: 3300, 7 | auth: { 8 | couchdb: { 9 | showLogin: true, 10 | }, 11 | ldap: ldapAuthConfig, 12 | oidc: oidcAuthConfig, 13 | }, 14 | keys: ['app-key'], 15 | password: 'roc-123', 16 | adminPassword: 'admin', 17 | // Already administrator from global configuration 18 | superAdministrators: ['admin@a.com', 'a@a.com', 'admin@zakodium.com'], 19 | publicAddress: 'http://127.0.0.1:3300', 20 | allowedOrigins: ['http://127.0.0.1:3309', 'http://localhost:3309'], 21 | getPublicUserInfo(user) { 22 | return { 23 | displayName: user.displayName, 24 | email: user.mail, 25 | }; 26 | }, 27 | }; 28 | -------------------------------------------------------------------------------- /test/data/anyuser.js: -------------------------------------------------------------------------------- 1 | import { resetDatabase } from '../utils/utils.js'; 2 | 3 | import insertDocument from './insertDocument.js'; 4 | 5 | function populate(db) { 6 | const prom = []; 7 | 8 | prom.push( 9 | insertDocument(db, { 10 | $type: 'entry', 11 | $owners: ['a@a.com', 'groupA', 'groupB'], 12 | $id: 'A', 13 | $content: {}, 14 | }), 15 | insertDocument(db, { 16 | $type: 'entry', 17 | $owners: ['b@b.com'], 18 | $id: 'B', 19 | $content: {}, 20 | }), 21 | ); 22 | 23 | return Promise.all(prom); 24 | } 25 | 26 | export default async function populateAnyUser() { 27 | global.couch = await resetDatabase('test', { 28 | database: 'test', 29 | rights: { 30 | read: ['anyuser'], 31 | }, 32 | }); 33 | await populate(global.couch._db); 34 | } 35 | -------------------------------------------------------------------------------- /src/client/components/LoginButton.jsx: -------------------------------------------------------------------------------- 1 | import PropTypes from 'prop-types'; 2 | import { connect } from 'react-redux'; 3 | import { Link } from 'react-router-dom'; 4 | 5 | import { logout as logoutAction } from '../actions/login'; 6 | 7 | const LoginButtonImpl = ({ message = 'Login', logout, username }) => { 8 | if (username) { 9 | return ( 10 | 11 | {`${username} - Logout`} 12 | 13 | ); 14 | } else { 15 | return {message}; 16 | } 17 | }; 18 | 19 | LoginButtonImpl.propTypes = { 20 | logout: PropTypes.func.isRequired, 21 | username: PropTypes.string, 22 | }; 23 | 24 | const LoginButton = connect((state) => ({ username: state.login.username }), { 25 | logout: logoutAction, 26 | })(LoginButtonImpl); 27 | 28 | export default LoginButton; 29 | -------------------------------------------------------------------------------- /src/client/components/GlobalRights.jsx: -------------------------------------------------------------------------------- 1 | import { connect } from 'react-redux'; 2 | 3 | import { addGlobalRight, removeGlobalRight } from '../actions/db'; 4 | 5 | import GlobalRightsEditor from './GlobalRightsEditor'; 6 | 7 | function GlobalRightsImpl(props) { 8 | if (!props.globalRights) return null; 9 | return ( 10 |
    11 |

    Global rights

    12 | 17 |
    18 | ); 19 | } 20 | 21 | const mapStateToProps = (state) => { 22 | return { 23 | globalRights: state.db.globalRights, 24 | }; 25 | }; 26 | 27 | const GlobalRights = connect(mapStateToProps, { 28 | addGlobalRight, 29 | removeGlobalRight, 30 | })(GlobalRightsImpl); 31 | 32 | export default GlobalRights; 33 | -------------------------------------------------------------------------------- /src/client/components/DefaultGroups.jsx: -------------------------------------------------------------------------------- 1 | import { connect } from 'react-redux'; 2 | 3 | import { addDefaultGroup, removeDefaultGroup } from '../actions/db'; 4 | 5 | import DefaultGroupsEditor from './DefaultGroupsEditor'; 6 | 7 | function DefaultGroupsImpl(props) { 8 | if (!props.defaultGroups) return null; 9 | return ( 10 |
    11 |

    Default groups

    12 | 17 |
    18 | ); 19 | } 20 | 21 | const mapStateToProps = (state) => { 22 | return { 23 | defaultGroups: state.db.defaultGroups, 24 | }; 25 | }; 26 | 27 | const DefaultGroups = connect(mapStateToProps, { 28 | addDefaultGroup, 29 | removeDefaultGroup, 30 | })(DefaultGroupsImpl); 31 | 32 | export default DefaultGroups; 33 | -------------------------------------------------------------------------------- /test/data/byOwnerEntryUnicity.js: -------------------------------------------------------------------------------- 1 | import { resetDatabase } from '../utils/utils.js'; 2 | 3 | import insertDocument from './insertDocument.js'; 4 | 5 | function populate(db) { 6 | const prom = []; 7 | 8 | prom.push( 9 | insertDocument(db, { 10 | $type: 'entry', 11 | $owners: ['a@a.com', 'groupA', 'groupB'], 12 | $id: 'X', 13 | $content: {}, 14 | }), 15 | insertDocument(db, { 16 | $type: 'entry', 17 | $owners: ['b@b.com', 'groupA', 'groupB'], 18 | $id: 'Y', 19 | $content: {}, 20 | }), 21 | ); 22 | 23 | return Promise.all(prom); 24 | } 25 | 26 | export default async function populateByOwnerUnicity() { 27 | global.couch = await resetDatabase('test-by-owner-unicity', { 28 | database: 'test-by-owner-unicity', 29 | rights: { 30 | create: ['anyuser'], 31 | }, 32 | }); 33 | await populate(global.couch._db); 34 | } 35 | -------------------------------------------------------------------------------- /src/client/components/DatabaseSelector.jsx: -------------------------------------------------------------------------------- 1 | export default function DatabaseSelector({ dbName, dbList, onDbSelected }) { 2 | return ( 3 |
    9 | 31 |
    32 | ); 33 | } 34 | -------------------------------------------------------------------------------- /src/client/components/pages/GroupMembershipsPage.jsx: -------------------------------------------------------------------------------- 1 | import { Component } from 'react'; 2 | import { connect } from 'react-redux'; 3 | 4 | import { dbManager } from '../../store'; 5 | import DisplayGroupList from '../DisplayGroupList'; 6 | 7 | class GroupMembershipsImpl extends Component { 8 | componentDidMount() { 9 | // Because if the user changed groups, then memberships need to be updated 10 | // Easier to do here than each time groups are updated 11 | dbManager.syncMemberships(); 12 | } 13 | 14 | render() { 15 | return ( 16 |
    17 | 18 |
    19 | ); 20 | } 21 | } 22 | 23 | const mapStateToProps = (state) => { 24 | return { 25 | groups: state.db.memberships, 26 | }; 27 | }; 28 | 29 | const GroupMembershipsPage = connect(mapStateToProps)(GroupMembershipsImpl); 30 | 31 | export default GroupMembershipsPage; 32 | -------------------------------------------------------------------------------- /test/data/insertDocument.js: -------------------------------------------------------------------------------- 1 | export default function insertDocument(db, entry) { 2 | processEntry(entry); 3 | return db.insertDocument(entry); 4 | } 5 | 6 | function processEntry(entry) { 7 | if (entry.$type === 'entry') { 8 | if (entry.$id === undefined) { 9 | entry.$id = null; 10 | } 11 | if (entry.$kind === undefined) { 12 | entry.$kind = null; 13 | } 14 | if (typeof entry.$id === 'string' && !entry._id) { 15 | entry._id = entry.$id; 16 | } 17 | } 18 | if (entry.$type === 'group') { 19 | if (typeof entry.name === 'string' && !entry._id) { 20 | entry._id = entry.name; 21 | } 22 | } 23 | if (entry.$type === 'entry' || entry.$type === 'group') { 24 | if (!entry.$creationDate) entry.$creationDate = 0; 25 | if (!entry.$modificationDate) entry.$modificationDate = 0; 26 | if (!entry.$lastModification) { 27 | entry.$lastModification = 'test@example.com'; 28 | } 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /test/homeDirectories/main/test-new-import/full/import.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | module.exports = async function fullImport(ctx, result) { 4 | result.kind = 'sample'; 5 | result.id = ctx.filename; 6 | result.owner = 'a@a.com'; 7 | result.reference = ctx.filename; 8 | result.field = 'field'; 9 | result.jpath = ['jpath', 'in', 'document']; 10 | if (ctx.fileExt === '.txt') { 11 | result.content_type = 'text/plain'; 12 | } 13 | result.content = { 14 | sideEffect: true, 15 | }; 16 | result.addAttachment({ 17 | jpath: ['other', 'jpath'], 18 | metadata: { hasMetadata: true }, 19 | reference: 'testRef', 20 | contents: Buffer.from('other attachment content', 'utf-8'), 21 | field: 'testField', 22 | filename: 'testFilename.txt', 23 | content_type: 'text/plain', 24 | }); 25 | result.metadata = { 26 | hasMetadata: true, 27 | }; 28 | result.addGroup('group1'); 29 | result.addGroups(['group2', 'group3']); 30 | }; 31 | -------------------------------------------------------------------------------- /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | name: Release 2 | 3 | on: 4 | push: 5 | branches: 6 | - main 7 | 8 | jobs: 9 | release-please: 10 | runs-on: ubuntu-latest 11 | steps: 12 | - uses: googleapis/release-please-action@v4 13 | id: release 14 | with: 15 | token: ${{ secrets.BOT_TOKEN }} 16 | release-type: node 17 | package-name: 'rest-on-couch' 18 | - uses: actions/checkout@v5 19 | # These if statements ensure that a publication only occurs when a new release is created 20 | if: ${{ steps.release.outputs.release_created }} 21 | - uses: actions/setup-node@v5 22 | with: 23 | node-version-file: package.json 24 | registry-url: 'https://registry.npmjs.org' 25 | if: ${{ steps.release.outputs.release_created }} 26 | - run: npm publish 27 | env: 28 | NODE_AUTH_TOKEN: ${{ secrets.NPM_BOT_TOKEN }} 29 | if: ${{ steps.release.outputs.release_created }} 30 | -------------------------------------------------------------------------------- /src/util/orcid.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | function generateCheckDigit(noHyphenOrcid) { 4 | // https://support.orcid.org/hc/en-us/articles/360006897674-Structure-of-the-ORCID-Identifier 5 | let total = 0; 6 | let zero = '0'.charCodeAt(0); 7 | for (let i = 0; i < 15; i++) { 8 | let digit = noHyphenOrcid.charCodeAt(i) - zero; 9 | total = (total + digit) * 2; 10 | } 11 | let result = (12 - (total % 11)) % 11; 12 | return result === 10 ? 'X' : String(result); 13 | } 14 | 15 | function okOrcid(orcid) { 16 | // based on https://github.com/zimeon/orcid-feed-js 17 | if (typeof orcid !== 'string') { 18 | return false; 19 | } 20 | let noHyphenOrcid = orcid.replace( 21 | /^(\d{4})-(\d{4})-(\d{4})-(\d\d\d[\dX])$/, 22 | '$1$2$3$4', 23 | ); 24 | if (noHyphenOrcid === orcid) { 25 | return false; 26 | } 27 | if (noHyphenOrcid.charAt(15) !== generateCheckDigit(noHyphenOrcid)) { 28 | return false; 29 | } 30 | return true; 31 | } 32 | 33 | module.exports = okOrcid; 34 | -------------------------------------------------------------------------------- /src/util/array_sets.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | /** 4 | * 5 | * @template T 6 | * @param A {Array} - First array 7 | * @param B {Array} - Second array 8 | * @returns {Array} - Union of A and B 9 | */ 10 | function union(A, B) { 11 | return Array.from(new Set(A).union(new Set(B))); 12 | } 13 | 14 | /** 15 | * 16 | * @template T 17 | * @param A {Array} - First array 18 | * @param B {Array} - Second array 19 | * @returns {Array} - All the elements in A which are not in B without any duplicates 20 | */ 21 | function difference(A, B) { 22 | return Array.from(new Set(A).difference(new Set(B))); 23 | } 24 | 25 | /** 26 | * 27 | * @template T 28 | * @param A {Array} - First array 29 | * @param B {Array} - Second array 30 | * @returns {Array} - Intersection between A and B, removing the duplicates. 31 | */ 32 | function intersection(A, B) { 33 | return Array.from(new Set(A).intersection(new Set(B))); 34 | } 35 | 36 | module.exports = { union, difference, intersection }; 37 | -------------------------------------------------------------------------------- /src/client/actions/main.js: -------------------------------------------------------------------------------- 1 | import { apiFetchJSON } from '../api'; 2 | 3 | import { getDbList } from './db'; 4 | import { checkLogin, getLoginProviders } from './login'; 5 | 6 | export const ROC_ONLINE = 'ROC_ONLINE'; 7 | 8 | export function getRocStatus() { 9 | return async (dispatch, getState) => { 10 | try { 11 | const result = await apiFetchJSON('auth/session'); 12 | if (result.ok) { 13 | if (!getState().main.rocOnline) { 14 | // first time we are online or went from offline to online 15 | // do various queries to initialize the view 16 | dispatch({ type: ROC_ONLINE, payload: true }); 17 | checkLogin(dispatch); 18 | getLoginProviders(dispatch); 19 | getDbList(dispatch); 20 | } 21 | } else { 22 | dispatch({ type: ROC_ONLINE, payload: false }); 23 | } 24 | } catch { 25 | dispatch({ type: ROC_ONLINE, payload: false }); 26 | } 27 | setTimeout(() => dispatch(getRocStatus()), 10000); 28 | }; 29 | } 30 | -------------------------------------------------------------------------------- /src/server/error.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | exports.handleError = function handleError(ctx, code, error) { 4 | if (code instanceof Error) { 5 | error = code; 6 | code = null; 7 | } 8 | error = error || {}; 9 | var err; 10 | var errCode; 11 | switch (code) { 12 | case 'private': 13 | err = { 14 | error: 'unauthorized', 15 | reason: 'The resource is private', 16 | }; 17 | errCode = 401; 18 | break; 19 | case 'readonly': 20 | err = { 21 | error: 'unauthorized', 22 | reason: 'The resource is readonly', 23 | }; 24 | errCode = 401; 25 | break; 26 | default: 27 | err = { 28 | error: 'unknown', 29 | reason: 'Unknown', 30 | }; 31 | errCode = 520; 32 | break; 33 | } 34 | errCode = error.statusCode || errCode; 35 | err.reason = error.reason || err.reason; 36 | err.error = error.error || err.error; 37 | ctx.response.body = JSON.stringify(err); 38 | ctx.response.status = errCode; 39 | }; 40 | -------------------------------------------------------------------------------- /test/data/globalEntryUnicity.js: -------------------------------------------------------------------------------- 1 | import { resetDatabase } from '../utils/utils.js'; 2 | 3 | import insertDocument from './insertDocument.js'; 4 | 5 | function populate(db) { 6 | const prom = []; 7 | 8 | prom.push( 9 | insertDocument(db, { 10 | $type: 'entry', 11 | $owners: ['a@a.com'], 12 | $id: 'X', 13 | $content: {}, 14 | }), 15 | insertDocument(db, { 16 | $type: 'entry', 17 | $owners: ['b@b.com', 'group1'], 18 | $id: 'Y', 19 | $content: {}, 20 | }), 21 | insertDocument(db, { 22 | $type: 'group', 23 | $owners: ['b@b.com'], 24 | name: 'group1', 25 | users: ['a@a.com'], 26 | rights: ['owner'], 27 | }), 28 | ); 29 | 30 | return Promise.all(prom); 31 | } 32 | 33 | export default async function populateGlobalUnicity() { 34 | global.couch = await resetDatabase('test-global-unicity', { 35 | database: 'test-global-unicity', 36 | rights: { 37 | create: ['anyuser'], 38 | }, 39 | }); 40 | await populate(global.couch._db); 41 | } 42 | -------------------------------------------------------------------------------- /src/util/getConfiguredDbs.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const path = require('path'); 4 | 5 | const fs = require('fs-extra'); 6 | 7 | const { getGlobalConfig } = require('../config/config'); 8 | 9 | const debug = require('./debug')('util:getConfiguredDbs'); 10 | 11 | let configuredDbsPromise; 12 | 13 | function getConfiguredDbs() { 14 | if (configuredDbsPromise) return configuredDbsPromise; 15 | debug.trace('get list of databases that have a configuration file'); 16 | const homeDir = getGlobalConfig().homeDir; 17 | 18 | configuredDbsPromise = readConfiguredDbs(homeDir); 19 | return configuredDbsPromise; 20 | } 21 | 22 | async function readConfiguredDbs(homeDir) { 23 | const result = []; 24 | const files = await fs.readdir(homeDir); 25 | for (const file of files) { 26 | const stat = await fs.stat(path.join(homeDir, file)); 27 | if (stat.isDirectory()) { 28 | if (await fs.exists(path.join(homeDir, file, 'config.js'))) { 29 | debug.trace('found database config file: %s', file); 30 | result.push(file); 31 | } 32 | } 33 | } 34 | return result; 35 | } 36 | 37 | module.exports = getConfiguredDbs; 38 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2015 cheminfo 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | 23 | -------------------------------------------------------------------------------- /src/client/components/Home.jsx: -------------------------------------------------------------------------------- 1 | import DatabaseSelector from './DatabaseSelector'; 2 | import LoginButton from './LoginButton'; 3 | 4 | export default function Home(props) { 5 | if (!props.user) { 6 | return ( 7 |
    8 |

    Welcome to the dashboard!

    9 |

    10 | {'Please '} 11 | 12 | {'.'} 13 |

    14 |
    15 | ); 16 | } else { 17 | let dbContent; 18 | if (!props.dbName) { 19 | dbContent = ( 20 | 25 | ); 26 | } else { 27 | dbContent = ( 28 |

    29 | Currently selected database is 30 | {props.dbName} 31 |

    32 | ); 33 | } 34 | return ( 35 |
    36 |

    {`You are logged in as ${props.user}.`}

    37 | {dbContent} 38 |

    39 | {props.isAdmin && You are an admin of this database.} 40 |

    41 |
    42 | ); 43 | } 44 | } 45 | -------------------------------------------------------------------------------- /test/unit/server/routes/auth.test.js: -------------------------------------------------------------------------------- 1 | import { describe, it } from 'node:test'; 2 | import { expect } from 'chai'; 3 | 4 | import { getAgent } from '../../../utils/agent.js'; 5 | 6 | const request = getAgent(); 7 | 8 | describe('server/routes/auth', () => { 9 | describe('couchdb login', () => { 10 | it('should return 401 for wrong login credentials', () => { 11 | return request 12 | .post('/auth/login/couchdb') 13 | .send({ 14 | username: 'bad', 15 | password: 'robot', 16 | }) 17 | .expect(401) 18 | .then((res) => { 19 | expect(res.body).toEqual({ ok: true }); 20 | }); 21 | }); 22 | }); 23 | describe('session', () => { 24 | it('should return anonymous for unauthenticated users', () => { 25 | return request 26 | .get('/auth/session') 27 | .expect(200) 28 | .then((res) => { 29 | expect(res.body).toEqual({ 30 | ok: true, 31 | authenticated: false, 32 | username: 'anonymous', 33 | provider: null, 34 | profile: null, 35 | admin: false, 36 | }); 37 | }); 38 | }); 39 | }); 40 | }); 41 | -------------------------------------------------------------------------------- /src/config/home.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const path = require('node:path'); 4 | const fs = require('node:fs'); 5 | 6 | const debug = require('../util/debug')('config:home'); 7 | 8 | function getHomeDir() { 9 | let homeDir = process.env.REST_ON_COUCH_HOME_DIR; 10 | if (!homeDir) { 11 | debug('no home dir'); 12 | return null; 13 | } 14 | return path.resolve(homeDir); 15 | } 16 | 17 | function getHomeConfig(homeDir) { 18 | const result = {}; 19 | if (homeDir) { 20 | homeDir = path.resolve(homeDir); 21 | } else { 22 | homeDir = getHomeDir(); 23 | } 24 | if (!homeDir) { 25 | debug('no home dir specified'); 26 | return null; 27 | } 28 | 29 | debug('get home dir config from %s', homeDir); 30 | result.homeDir = homeDir; 31 | const files = fs.readdirSync(homeDir); 32 | const configFile = files.find((file) => /config\.m?js/.test(file)); 33 | if (configFile) { 34 | let config = require(path.join(homeDir, configFile)); 35 | debug('loaded main config file'); 36 | return config; 37 | } else { 38 | debug(`no config found in home directory ${homeDir}`); 39 | return {}; 40 | } 41 | } 42 | 43 | exports.getHomeDir = getHomeDir; 44 | exports.getHomeConfig = getHomeConfig; 45 | -------------------------------------------------------------------------------- /test/homeDirectories/main/test-new-import/separate/import.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | module.exports = async function separateImport(ctx, result) { 4 | result.kind = 'sample'; 5 | result.id = 'separate'; 6 | result.owner = 'a@a.com'; 7 | result.reference = ctx.filename; 8 | result.skipAttachment(); 9 | result.skipMetadata(); 10 | result.field = 'field'; 11 | if (ctx.fileExt === '.txt') { 12 | result.content_type = 'text/plain'; 13 | } 14 | result.content = { 15 | sideEffect: true, 16 | }; 17 | result.addAttachment({ 18 | jpath: ['other', 'jpath'], 19 | metadata: { hasMetadata: true }, 20 | reference: 'testRef', 21 | contents: await ctx.getContents(), 22 | field: 'testField', 23 | filename: ctx.filename, 24 | content_type: 'text/plain', 25 | }); 26 | result.addAttachment({ 27 | jpath: ['other2', 'jpath'], 28 | reference: 'ref2', 29 | // no metadata 30 | contents: Uint8Array.of(116, 101, 115, 116, 50), 31 | field: 'testField', 32 | filename: 'test2.txt', 33 | content_type: 'text/plain', 34 | }); 35 | result.metadata = { 36 | hasMetadata2: true, 37 | }; 38 | result.addGroup('group1'); 39 | result.addGroups(['group2', 'group3']); 40 | }; 41 | -------------------------------------------------------------------------------- /test/unit/import/import_context.test.js: -------------------------------------------------------------------------------- 1 | import fs from 'node:fs'; 2 | import path from 'node:path'; 3 | 4 | import { describe, it } from 'node:test'; 5 | import { expect } from 'chai'; 6 | 7 | import ImportContext from '../../../src/import/ImportContext.js'; 8 | 9 | describe('ImportContext', () => { 10 | it('should instanciate a new import context', async () => { 11 | const file = path.join( 12 | import.meta.dirname, 13 | '../../homeDirectories/main/test-new-import/full/to_process/test.txt', 14 | ); 15 | const fileContents = await fs.readFileSync(file, 'utf-8'); 16 | const databaseName = 'test-new-import'; 17 | const ctx = new ImportContext(file, databaseName); 18 | expect(ctx.filename).toBe('test.txt'); 19 | expect(ctx.fileExt).toBe('.txt'); 20 | expect(ctx.fileDir).toMatch( 21 | path.normalize('homeDirectories/main/test-new-import/full/to_process'), 22 | ); 23 | expect(ctx.couch).toBeDefined(); 24 | await ctx.couch.open(); 25 | const fileContentsUtf8 = await ctx.getContents('utf-8'); 26 | expect(fileContentsUtf8).toBe(fileContents); 27 | 28 | const dataBuffer = await ctx.getContents(); 29 | expect(dataBuffer).toEqual(Buffer.from(fileContents, 'utf-8')); 30 | }); 31 | }); 32 | -------------------------------------------------------------------------------- /src/client/components/GlobalRightsEditor.jsx: -------------------------------------------------------------------------------- 1 | import PropTypes from 'prop-types'; 2 | 3 | import { globalRightTypes } from '../constants'; 4 | 5 | import GroupDataEditor from './GroupDataEditor'; 6 | 7 | const GlobalRightsEditor = ({ globalRights, addRight, removeRight }) => ( 8 |
    9 |
    10 |
    11 | {globalRightTypes.map((right, idx) => { 12 | return ( 13 |
    14 |
    15 | addRight(right, value)} 19 | removeValue={(value) => removeRight(right, value)} 20 | /> 21 |
    22 | {idx % 2 === 1 ?
    : null} 23 |
    24 | ); 25 | })} 26 |
    27 |
    28 |
    29 | ); 30 | 31 | GlobalRightsEditor.propTypes = { 32 | globalRights: PropTypes.object.isRequired, 33 | addRight: PropTypes.func.isRequired, 34 | removeRight: PropTypes.func.isRequired, 35 | }; 36 | 37 | export default GlobalRightsEditor; 38 | -------------------------------------------------------------------------------- /src/connect.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const { getGlobalConfig } = require('./config/config'); 4 | const CouchError = require('./util/CouchError'); 5 | const debug = require('./util/debug')('main:connect'); 6 | const getNano = require('./util/nanoShim'); 7 | 8 | let globalNano; 9 | let lastAuthentication = 0; 10 | 11 | function open() { 12 | const authRenewal = getGlobalConfig().authRenewal * 1000; 13 | const currentDate = Date.now(); 14 | if (currentDate - lastAuthentication > authRenewal) { 15 | if (lastAuthentication === 0) { 16 | debug('initialize connection to CouchDB'); 17 | } 18 | globalNano = getGlobalNano(); 19 | lastAuthentication = currentDate; 20 | } 21 | return globalNano; 22 | } 23 | 24 | async function getGlobalNano() { 25 | debug.trace('renew CouchDB cookie'); 26 | const config = getGlobalConfig(); 27 | if (config.url && config.username && config.password) { 28 | return getNano(config.url, config.username, config.password); 29 | } else { 30 | throw new CouchError( 31 | 'rest-on-couch cannot be used without url, username and password', 32 | 'fatal', 33 | ); 34 | } 35 | } 36 | 37 | function close() { 38 | globalNano = null; 39 | } 40 | 41 | module.exports = { 42 | open, 43 | close, 44 | }; 45 | -------------------------------------------------------------------------------- /src/import/ImportContext.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const path = require('path'); 4 | 5 | const fs = require('fs-extra'); 6 | 7 | const Couch = require('..'); 8 | 9 | const kFilePath = Symbol('filePath'); 10 | const kContents = Symbol('contents'); 11 | const kDB = Symbol('db'); 12 | 13 | module.exports = class ImportContext { 14 | constructor(filePath, database) { 15 | this[kFilePath] = filePath; 16 | this[kContents] = {}; 17 | this[kDB] = database; 18 | } 19 | 20 | static getSource() { 21 | return []; 22 | } 23 | 24 | get filename() { 25 | return path.parse(this[kFilePath]).base; 26 | } 27 | 28 | get fileDir() { 29 | return path.parse(this[kFilePath]).dir; 30 | } 31 | 32 | get fileExt() { 33 | return path.parse(this[kFilePath]).ext; 34 | } 35 | 36 | get couch() { 37 | return Couch.get(this[kDB]); 38 | } 39 | 40 | async getContents(encoding = null, cache = true) { 41 | if (cache) { 42 | if (!this[kContents][encoding]) { 43 | this[kContents][encoding] = await fs.readFile( 44 | this[kFilePath], 45 | encoding, 46 | ); 47 | } 48 | return this[kContents][encoding]; 49 | } else { 50 | return fs.readFile(this[kFilePath], encoding); 51 | } 52 | } 53 | }; 54 | -------------------------------------------------------------------------------- /src/init/auditActions.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const { getGlobalConfig } = require('../config/config'); 4 | const auditDesignDoc = require('../design/audit'); 5 | const debug = require('../util/debug')('main:initCouch'); 6 | 7 | async function setupAuditActions(nano) { 8 | debug('setup audit actions'); 9 | const config = getGlobalConfig(); 10 | const auditActionsDb = config.auditActionsDb; 11 | // Check if database is accessible 12 | try { 13 | const dbExists = await nano.hasDatabase(auditActionsDb); 14 | if (!dbExists) { 15 | throw new Error( 16 | `audit actions database does not exist: ${auditActionsDb}`, 17 | ); 18 | } 19 | } catch (e) { 20 | debug.error('failed to get audit actions database: %s', auditActionsDb); 21 | throw e; 22 | } 23 | 24 | // Check design docs 25 | const db = nano.useDb(config.auditActionsDb); 26 | const oldDesignDoc = await db.getDocument('_design/audit'); 27 | if (!oldDesignDoc || oldDesignDoc.version !== auditDesignDoc.version) { 28 | debug('updating audit design doc'); 29 | const newDesignDoc = { ...auditDesignDoc }; 30 | if (oldDesignDoc) { 31 | newDesignDoc._rev = oldDesignDoc._rev; 32 | } 33 | await db.insertDocument(newDesignDoc); 34 | } 35 | } 36 | 37 | module.exports = setupAuditActions; 38 | -------------------------------------------------------------------------------- /test/unit/rights/default_groups.test.js: -------------------------------------------------------------------------------- 1 | import { beforeEach, describe, it } from 'node:test'; 2 | import { expect } from 'chai'; 3 | 4 | import data from '../../data/noRights.js'; 5 | 6 | describe('entry reads, database with default groups', () => { 7 | beforeEach(data); 8 | 9 | it('should grant read access to owner', () => { 10 | return expect( 11 | couch.getEntry('entryWithDefaultAnonymousRead', 'x@x.com'), 12 | ).resolves.toBeDefined(); 13 | }); 14 | 15 | it('should grant read access to anonymous user', () => { 16 | return expect( 17 | couch.getEntry('entryWithDefaultAnonymousRead', 'anonymous'), 18 | ).resolves.toBeDefined(); 19 | }); 20 | 21 | it('should grant read access to logged in user', () => { 22 | return expect( 23 | couch.getEntry('entryWithDefaultAnyuserRead', 'a@a.com'), 24 | ).resolves.toBeDefined(); 25 | }); 26 | 27 | it('should not grant read access to anonymous user', () => { 28 | return expect( 29 | couch.getEntry('entryWithDefaultAnyuserRead', 'anonymous'), 30 | ).rejects.toThrow(/no access/); 31 | }); 32 | 33 | it('should grant read access to anonymous user (multiple groups)', () => { 34 | return expect( 35 | couch.getEntry('entryWithDefaultMultiRead', 'anonymous'), 36 | ).resolves.toBeDefined(); 37 | }); 38 | }); 39 | -------------------------------------------------------------------------------- /src/client/components/DefaultGroupsEditor.jsx: -------------------------------------------------------------------------------- 1 | import PropTypes from 'prop-types'; 2 | 3 | import GroupDataEditor from './GroupDataEditor'; 4 | 5 | const DefaultGroupsEditor = ({ defaultGroups, addGroup, removeGroup }) => ( 6 |
    7 |
    8 |
    9 |
    10 |
    11 | addGroup('anonymous', value)} 16 | removeValue={(value) => removeGroup('anonymous', value)} 17 | /> 18 |
    19 |
    20 | addGroup('anyuser', value)} 24 | removeValue={(value) => removeGroup('anyuser', value)} 25 | /> 26 |
    27 |
    28 |
    29 |
    30 |
    31 | ); 32 | 33 | DefaultGroupsEditor.propTypes = { 34 | defaultGroups: PropTypes.object.isRequired, 35 | addGroup: PropTypes.func.isRequired, 36 | removeGroup: PropTypes.func.isRequired, 37 | }; 38 | 39 | export default DefaultGroupsEditor; 40 | -------------------------------------------------------------------------------- /test/utils/authenticate.js: -------------------------------------------------------------------------------- 1 | import { expect } from 'chai'; 2 | 3 | export async function authenticateAs(agent, username, password) { 4 | await logout(agent); 5 | 6 | const loginRes = await agent 7 | .post('/auth/login/couchdb') 8 | .type('form') 9 | .send({ username, password }); 10 | expect(loginRes.statusCode).toBe(200); 11 | 12 | const sessionRes = await agent.get('/auth/session'); 13 | if (!sessionRes.body.authenticated) { 14 | throw new Error( 15 | `Could not authenticate on CouchDB as ${username}:${password}`, 16 | ); 17 | } 18 | } 19 | 20 | export async function logout(agent) { 21 | const res = await agent.get('/auth/logout'); 22 | expect(res.statusCode).toBe(200); 23 | } 24 | 25 | export function authenticateLDAP(agent, username, password) { 26 | return agent.get('/auth/logout').then((res) => { 27 | expect(res.statusCode).toBe(200); 28 | return agent 29 | .post('/auth/login/ldap') 30 | .type('form') 31 | .send({ username, password }) 32 | .then((res) => { 33 | expect(res.statusCode).toBe(200); 34 | return agent.get('/auth/session'); 35 | }) 36 | .then((res) => { 37 | if (!res.body.authenticated) { 38 | throw new Error( 39 | `Could not authenticate on LDAP as ${username}:${password}`, 40 | ); 41 | } 42 | }); 43 | }); 44 | } 45 | -------------------------------------------------------------------------------- /src/constants.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | // super administrators have all these rights 4 | const { z } = require('zod'); 5 | const { globalRightType } = require('./config/schema.mjs'); 6 | 7 | const globalTypesSchema = z.array(globalRightType); 8 | 9 | // This list does not include the 'admin' right 10 | const globalRightTypes = globalTypesSchema.parse([ 11 | 'delete', 12 | 'read', 13 | 'write', 14 | 'create', 15 | 'readGroup', 16 | 'writeGroup', 17 | 'createGroup', 18 | 'readImport', 19 | 'owner', 20 | 'addAttachment', 21 | ]); 22 | 23 | // administrators only have these rights 24 | const globalAdminRightTypes = globalTypesSchema.parse([ 25 | 'admin', 26 | 'create', 27 | 'createGroup', 28 | ]); 29 | 30 | const allowedFirstLevelKeys = ['$deleted']; 31 | 32 | const IMPORT_UPDATE_FULL = 'IMPORT_UPDATE_FULL'; 33 | const IMPORT_UPDATE_WITHOUT_ATTACHMENT = 'IMPORT_UPDATE_WITHOUT_ATTACHMENT'; 34 | const IMPORT_UPDATE_$CONTENT_ONLY = 'IMPORT_UPDATE_$CONTENT_ONLY'; 35 | 36 | module.exports = { 37 | CUSTOM_DESIGN_DOC_NAME: 'customApp', 38 | DESIGN_DOC_NAME: 'app', 39 | DESIGN_DOC_ID: '_design/app', 40 | RIGHTS_DOC_ID: 'rights', 41 | DEFAULT_GROUPS_DOC_ID: 'defaultGroups', 42 | 43 | globalRightTypes, 44 | globalAdminRightTypes, 45 | allowedFirstLevelKeys, 46 | 47 | kEntryUnicity: Symbol('entryUnicity'), 48 | IMPORT_UPDATE_FULL, 49 | IMPORT_UPDATE_WITHOUT_ATTACHMENT, 50 | IMPORT_UPDATE_$CONTENT_ONLY, 51 | }; 52 | -------------------------------------------------------------------------------- /test/unit/config/env_config.test.js: -------------------------------------------------------------------------------- 1 | import { describe, it } from 'node:test'; 2 | import { expect } from 'chai'; 3 | import { getGlobalConfig } from '../../../src/config/config.js'; 4 | 5 | describe('REST_ON_COUCH_* environment properties', () => { 6 | it('environment booleans', () => { 7 | let config = getGlobalConfig(); 8 | expect(config.proxy).toBe(true); 9 | expect(config.sessionSecure).toBe(false); 10 | 11 | process.env.REST_ON_COUCH_SESSION_SECURE = 'tru'; 12 | expect(() => getGlobalConfig({})).toThrow(/Value must be a boolean/); 13 | 14 | process.env.REST_ON_COUCH_SESSION_SECURE = 'True'; 15 | config = getGlobalConfig({}); 16 | expect(config.sessionSecure).toBe(true); 17 | 18 | process.env.REST_ON_COUCH_PROXY = 'false'; 19 | // We pass an empty config object to force reloading the configuration and not use the store 20 | config = getGlobalConfig({}); 21 | expect(config.proxy).toBe(false); 22 | }); 23 | 24 | it('environment integers', () => { 25 | let config = getGlobalConfig(); 26 | expect(config.authRenewal).toBe(570); 27 | 28 | process.env.REST_ON_COUCH_AUTH_RENEWAL = '600x'; 29 | expect(() => getGlobalConfig({})).toThrow( 30 | /Value must be a non-negative integer\n.*at authRenewal/, 31 | ); 32 | 33 | process.env.REST_ON_COUCH_AUTH_RENEWAL = '600'; 34 | config = getGlobalConfig({}); 35 | expect(config.authRenewal).toBe(600); 36 | }); 37 | }); 38 | -------------------------------------------------------------------------------- /src/client/components/LoginGoogle.jsx: -------------------------------------------------------------------------------- 1 | import PropTypes from 'prop-types'; 2 | import { connect } from 'react-redux'; 3 | 4 | import { checkLogin } from '../actions/login'; 5 | import { API_ROOT } from '../api'; 6 | import { dbManager } from '../store'; 7 | 8 | import googleSigninImage from '../../../public/assets/img/logo/google_signin.png'; 9 | 10 | const LoginGoogleImpl = ({ doGoogleLogin }) => ( 11 | 12 | Google signin 13 | 14 | ); 15 | 16 | LoginGoogleImpl.propTypes = { 17 | doGoogleLogin: PropTypes.func.isRequired, 18 | }; 19 | 20 | const LoginGoogle = connect(null, (dispatch) => ({ 21 | doGoogleLogin: () => { 22 | const height = 600; 23 | const width = 450; 24 | const left = Math.round(window.outerWidth / 2 - width / 2); 25 | const top = Math.round(window.outerHeight / 2 - height / 2); 26 | const win = window.open( 27 | `${API_ROOT}auth/login/google/popup`, 28 | 'loginPopup', 29 | `location=1,scrollbars=1,height=${height},width=${width},left=${left},top=${top}`, 30 | ); 31 | if (win.focus) win.focus(); 32 | checkWindowStatus(); 33 | 34 | function checkWindowStatus() { 35 | if (win.closed) { 36 | checkLogin(dispatch, 'google'); 37 | dbManager.syncDb(); 38 | } else { 39 | setTimeout(checkWindowStatus, 250); 40 | } 41 | } 42 | }, 43 | }))(LoginGoogleImpl); 44 | 45 | export default LoginGoogle; 46 | -------------------------------------------------------------------------------- /src/client/components/EnterTextField.jsx: -------------------------------------------------------------------------------- 1 | import PropTypes from 'prop-types'; 2 | import { Component } from 'react'; 3 | 4 | class EnterTextField extends Component { 5 | constructor(props) { 6 | super(props); 7 | this.state = { 8 | value: props.value || '', 9 | }; 10 | this.handleChange = this.handleChange.bind(this); 11 | this.handleSubmit = this.handleSubmit.bind(this); 12 | this.handleKeyPress = this.handleKeyPress.bind(this); 13 | } 14 | 15 | handleChange(event) { 16 | this.setState({ 17 | value: event.target.value, 18 | }); 19 | } 20 | 21 | handleSubmit() { 22 | if (this.isEmpty()) return; 23 | this.props.onSubmit(this.state.value); 24 | this.setState({ 25 | value: '', 26 | }); 27 | } 28 | 29 | handleKeyPress(event) { 30 | if (event.key === 'Enter') { 31 | event.preventDefault(); 32 | this.handleSubmit(); 33 | } 34 | } 35 | 36 | isEmpty() { 37 | return this.state.value === ''; 38 | } 39 | 40 | render() { 41 | const { label } = this.props; 42 | return ( 43 |
    44 | 45 | 52 |
    53 | ); 54 | } 55 | } 56 | 57 | EnterTextField.propTypes = { 58 | onSubmit: PropTypes.func.isRequired, 59 | }; 60 | 61 | export default EnterTextField; 62 | -------------------------------------------------------------------------------- /test/unit/rest-api/owners.test.js: -------------------------------------------------------------------------------- 1 | import { beforeEach, describe, it } from 'node:test'; 2 | import { expect } from 'chai'; 3 | 4 | import data from '../../data/data.js'; 5 | import { authenticateAs } from '../../utils/authenticate.js'; 6 | import { getAgent } from '../../utils/agent.js'; 7 | 8 | const request = getAgent(); 9 | 10 | describe('rest api - manage owners', () => { 11 | const id = 'A'; 12 | beforeEach(() => { 13 | return data().then(() => authenticateAs(request, 'b@b.com', '123')); 14 | }); 15 | it('get owners', () => { 16 | return request 17 | .get(`/db/test/entry/${id}/_owner`) 18 | .expect(200) 19 | .then((result) => { 20 | expect(result.body).toHaveLength(3); 21 | expect(result.body[0]).toBe('b@b.com'); 22 | }); 23 | }); 24 | it('add owner', () => { 25 | return request 26 | .put(`/db/test/entry/${id}/_owner/test`) 27 | .expect(200) 28 | .then(() => couch.getEntry(id, 'b@b.com')) 29 | .then((entry) => { 30 | expect(entry.$owners.includes('test')).toBe(true); 31 | }); 32 | }); 33 | it('remove owner', () => { 34 | return couch 35 | .addOwnersToDoc(id, 'b@b.com', 'testRemove', 'entry') 36 | .then(() => { 37 | return request 38 | .del(`/db/test/entry/${id}/_owner/testRemove`) 39 | .expect(200) 40 | .then(() => couch.getEntry(id, 'b@b.com')) 41 | .then((entry) => { 42 | expect(entry.$owners.includes('testRemove')).toBe(false); 43 | }); 44 | }); 45 | }); 46 | }); 47 | -------------------------------------------------------------------------------- /test/unit/attachments.test.js: -------------------------------------------------------------------------------- 1 | import { beforeEach, describe, it } from 'node:test'; 2 | import { expect } from 'chai'; 3 | 4 | import data from '../data/data.js'; 5 | 6 | describe('entries with attachments', () => { 7 | beforeEach(data); 8 | 9 | it('should error if entry has no attachment', () => { 10 | return expect( 11 | couch.getAttachmentByName('anonymousEntry', 'foo.txt', 'b@b.com'), 12 | ).rejects.toThrow(/attachment foo\.txt not found/); 13 | }); 14 | 15 | it('should error if entry attachment does not exist', () => { 16 | return expect( 17 | couch.getAttachmentByName('entryWithAttachment', 'foo.txt', 'b@b.com'), 18 | ).rejects.toThrow(/attachment foo\.txt not found/); 19 | }); 20 | 21 | it('should return attachment data', () => { 22 | return expect( 23 | couch.getAttachmentByName('entryWithAttachment', 'test.txt', 'b@b.com'), 24 | ).resolves.toEqual(Buffer.from('THIS IS A TEST')); 25 | }); 26 | 27 | it('should delete an attachment from a document given by its uuid', () => { 28 | return couch 29 | .getEntry('entryWithAttachment', 'b@b.com') 30 | .then((entry) => 31 | couch.deleteAttachment(entry._id, 'b@b.com', 'test.txt', { 32 | rev: entry._rev, 33 | }), 34 | ) 35 | .then(() => { 36 | return expect( 37 | couch.getAttachmentByName( 38 | 'entryWithAttachment', 39 | 'test.txt', 40 | 'b@b.com', 41 | ), 42 | ).rejects.toThrow(/attachment test\.txt not found/); 43 | }); 44 | }); 45 | }); 46 | -------------------------------------------------------------------------------- /.github/workflows/codeql-analysis.yml: -------------------------------------------------------------------------------- 1 | name: 'Code scanning - action' 2 | 3 | on: 4 | push: 5 | pull_request: 6 | schedule: 7 | - cron: '0 6 * * 5' 8 | 9 | jobs: 10 | CodeQL-Build: 11 | runs-on: ubuntu-latest 12 | 13 | steps: 14 | - name: Checkout repository 15 | uses: actions/checkout@v5 16 | with: 17 | # We must fetch at least the immediate parents so that if this is 18 | # a pull request then we can checkout the head. 19 | fetch-depth: 2 20 | 21 | # If this run was triggered by a pull request event, then checkout 22 | # the head of the pull request instead of the merge commit. 23 | - run: git checkout HEAD^2 24 | if: ${{ github.event_name == 'pull_request' }} 25 | 26 | # Initializes the CodeQL tools for scanning. 27 | - name: Initialize CodeQL 28 | uses: github/codeql-action/init@v2 29 | # Override language selection by uncommenting this and choosing your languages 30 | # with: 31 | # languages: go, javascript, csharp, python, cpp, java 32 | 33 | # ℹ️ Command-line programs to run using the OS shell. 34 | # 📚 https://git.io/JvXDl 35 | 36 | # ✏️ If the Autobuild fails above, remove it and uncomment the following three lines 37 | # and modify them (or add more) to build your code if your project 38 | # uses a compiled language 39 | 40 | #- run: | 41 | # make bootstrap 42 | # make release 43 | 44 | - name: Perform CodeQL Analysis 45 | uses: github/codeql-action/analyze@v2 46 | -------------------------------------------------------------------------------- /test/unit/token2.test.js: -------------------------------------------------------------------------------- 1 | import { beforeEach, describe, it } from 'node:test'; 2 | import { expect } from 'chai'; 3 | 4 | import constants from '../data/constants.js'; 5 | import data from '../data/data.js'; 6 | 7 | describe('token methods data', () => { 8 | beforeEach(data); 9 | 10 | it('user token allow to create document', async () => { 11 | const token = await couch.createUserToken('a@a.com', [ 12 | 'read', 13 | 'write', 14 | 'create', 15 | ]); 16 | const newEntry = await couch.insertEntry(constants.newEntry, 'anonymous', { 17 | token, 18 | }); 19 | expect(newEntry.action).toBe('created'); 20 | expect(newEntry.info.isNew).toBe(true); 21 | }); 22 | 23 | it('user token should not allow to create document with groups if not owner', async () => { 24 | const token = await couch.createUserToken('a@a.com', ['read', 'create']); 25 | await expect( 26 | couch.insertEntry(constants.newEntry, 'anonymous', { 27 | token, 28 | groups: ['group1'], 29 | }), 30 | ).rejects.toThrow(/not allowed to create with groups/); 31 | }); 32 | 33 | it('user token allow to create document with groups if owner', async () => { 34 | const token = await couch.createUserToken('a@a.com', [ 35 | 'read', 36 | 'create', 37 | 'owner', 38 | ]); 39 | const newEntry = await couch.insertEntry(constants.newEntry, 'anonymous', { 40 | token, 41 | groups: ['group1'], 42 | }); 43 | expect(newEntry.action).toBe('created'); 44 | expect(newEntry.info.isNew).toBe(true); 45 | }); 46 | }); 47 | -------------------------------------------------------------------------------- /test/utils/utils.js: -------------------------------------------------------------------------------- 1 | import Couch from '../../src/index.js'; 2 | import { getGlobalConfig } from '../../src/config/config.js'; 3 | import getNano from '../../src/util/nanoShim.js'; 4 | 5 | export async function resetDatabase( 6 | databaseName, 7 | options = { database: databaseName }, 8 | ) { 9 | await resetDatabaseWithoutCouch(databaseName); 10 | const couchInstance = new Couch(options); 11 | await couchInstance.open(); 12 | return couchInstance; 13 | } 14 | 15 | export async function resetDatabaseWithoutCouch(databaseName) { 16 | const globalConfig = getGlobalConfig(); 17 | const nano = await getNano( 18 | globalConfig.url, 19 | 'admin', 20 | globalConfig.adminPassword, 21 | ); 22 | try { 23 | await destroy(nano, databaseName); 24 | } catch { 25 | // ignore if db doesn't exist 26 | } 27 | // Workaround flaky tests: "The database could not be created, the file already exists." 28 | await wait(20); 29 | await create(nano, databaseName); 30 | } 31 | 32 | function destroy(nano, db) { 33 | return nano.destroyDatabase(db); 34 | } 35 | 36 | async function create(nano, db) { 37 | await nano.createDatabase(db); 38 | await nano.request({ 39 | method: 'PUT', 40 | db, 41 | doc: '_security', 42 | body: { 43 | admins: { 44 | names: ['rest-on-couch'], 45 | roles: [], 46 | }, 47 | members: { 48 | names: ['rest-on-couch'], 49 | roles: [], 50 | }, 51 | }, 52 | }); 53 | } 54 | 55 | function wait(ms) { 56 | return new Promise((resolve) => { 57 | setTimeout(resolve, ms); 58 | }); 59 | } 60 | -------------------------------------------------------------------------------- /test/unit/user.test.js: -------------------------------------------------------------------------------- 1 | import { beforeEach, describe, it } from 'node:test'; 2 | import { expect } from 'chai'; 3 | 4 | import data from '../data/data.js'; 5 | 6 | describe('Couch user API', () => { 7 | beforeEach(data); 8 | it('Should get a user', async () => { 9 | const doc = await couch.getUser('a@a.com'); 10 | expect(doc.user).toBe('a@a.com'); 11 | }); 12 | 13 | it('Get user should throw if not exists', () => { 14 | return expect(couch.getUser('b@b.com')).rejects.toThrow(/not found/); 15 | }); 16 | 17 | it('Edit user should throw when anonymous', () => { 18 | return expect(couch.editUser('anonymous', { val: 'test' })).rejects.toThrow( 19 | /must be an email/, 20 | ); 21 | }); 22 | 23 | it('Should edit user', async () => { 24 | { 25 | const res = await couch.editUser('b@b.com', { val: 'b', v: 'b' }); 26 | expect(res.rev).toMatch(/^1/); 27 | } 28 | { 29 | const doc = await couch.getUser('b@b.com'); 30 | expect(doc.user).toBe('b@b.com'); 31 | expect(doc.val).toBe('b'); 32 | } 33 | { 34 | const res = await couch.editUser('b@b.com', { val: 'x' }); 35 | expect(res.rev).toMatch(/^2/); 36 | } 37 | { 38 | const doc = await couch.getUser('b@b.com'); 39 | expect(doc.user).toBe('b@b.com'); 40 | expect(doc.val).toBe('x'); 41 | expect(doc.v).toBe('b'); 42 | } 43 | }); 44 | 45 | it('getUserInfo', async () => { 46 | const user = await couch.getUserInfo('user@test.com'); 47 | expect(user.email).toBe('user@test.com'); 48 | expect(user.value).toBe(42); 49 | }); 50 | }); 51 | -------------------------------------------------------------------------------- /src/client/api.js: -------------------------------------------------------------------------------- 1 | const location = window.location; 2 | 3 | let API_ROOT; 4 | if (import.meta.env.PROD) { 5 | API_ROOT = location.origin + location.pathname; 6 | } else { 7 | API_ROOT = location.origin.replace(/:\d+/, `:${3300}`) + location.pathname; 8 | } 9 | 10 | export { API_ROOT }; 11 | 12 | export function apiFetch(path, options) { 13 | options = { 14 | mode: 'cors', 15 | credentials: 'include', 16 | headers: { 17 | Accept: 'application/json', 18 | 'Content-Type': 'application/json', 19 | }, 20 | ...options, 21 | }; 22 | return fetch(`${API_ROOT}${path}`, options); 23 | } 24 | 25 | export async function apiFetchJSON(path, options) { 26 | path = path.replace(/^\/+/, ''); 27 | const req = await apiFetch(path, options); 28 | return req.json(); 29 | } 30 | 31 | export async function apiFetchJSONOptional(path, options) { 32 | path = path.replace(/^\/+/, ''); 33 | const req = await apiFetch(path, options); 34 | if (req.status === 404) { 35 | return null; 36 | } else if (req.status < 300) { 37 | return req.json(); 38 | } else { 39 | throw new Error(`Unexpected status code ${req.status}`); 40 | } 41 | } 42 | 43 | export function apiFetchForm(path, data) { 44 | const formData = new URLSearchParams(); 45 | for (const key in data) { 46 | formData.set(key, data[key]); 47 | } 48 | return apiFetch(path, { 49 | method: 'POST', 50 | body: formData, 51 | redirect: 'manual', 52 | headers: {}, 53 | }); 54 | } 55 | 56 | export async function apiFetchFormJSON(path, data) { 57 | const req = await apiFetchForm(path, data); 58 | return req.json(); 59 | } 60 | -------------------------------------------------------------------------------- /src/audit/actions.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const { getGlobalConfig } = require('../config/config'); 4 | const { open } = require('../connect'); 5 | const debug = require('../util/debug')('audit:actions'); 6 | 7 | let auditEnabled; 8 | 9 | function isAuditEnabled() { 10 | auditEnabled ??= !!getGlobalConfig().auditActions; 11 | return auditEnabled; 12 | } 13 | 14 | let _globalNano = null; 15 | let _db = null; 16 | 17 | async function ensureNano() { 18 | const config = getGlobalConfig(); 19 | const newGlobalNano = await open(); 20 | if (_globalNano !== newGlobalNano) { 21 | _db = newGlobalNano.useDb(config.auditActionsDb); 22 | } 23 | return _db; 24 | } 25 | 26 | async function auditAction(action, username, ip, meta) { 27 | if (!isAuditEnabled()) return; 28 | debug('logAction', action, username, ip); 29 | validateString('action', action); 30 | validateString('username', username); 31 | validateString('ip', ip); 32 | const doc = { 33 | action, 34 | username, 35 | ip, 36 | date: new Date().toISOString(), 37 | }; 38 | if (meta) { 39 | doc.meta = meta; 40 | } 41 | const db = await ensureNano(); 42 | await db.insertDocument(doc); 43 | } 44 | 45 | async function auditLogin(username, success, provider, ctx) { 46 | if (!auditEnabled) return; 47 | const action = success ? 'login.success' : 'login.failed'; 48 | await auditAction(action, username, ctx.ip, { provider }); 49 | } 50 | 51 | function validateString(name, value) { 52 | if (typeof value !== 'string') { 53 | throw new TypeError(`${name} must be a string`); 54 | } 55 | } 56 | 57 | module.exports = { 58 | auditLogin, 59 | }; 60 | -------------------------------------------------------------------------------- /src/client/components/Groups.jsx: -------------------------------------------------------------------------------- 1 | import PropTypes from 'prop-types'; 2 | import { connect } from 'react-redux'; 3 | 4 | import { 5 | addValueToGroup, 6 | createGroup, 7 | removeGroup, 8 | removeValueFromGroup, 9 | setGroupProperties, 10 | } from '../actions/db'; 11 | 12 | import GroupCreator from './GroupCreator'; 13 | import GroupEditor from './GroupEditor'; 14 | 15 | const GroupsImpl = (props) => { 16 | const groups = props.userGroups.map((group) => ( 17 |
    18 | 25 |
    26 | )); 27 | return ( 28 |
    29 | {props.hasCreateGroupRight ? ( 30 | 31 | ) : null} 32 | {groups} 33 |
    34 | ); 35 | }; 36 | 37 | GroupsImpl.propTypes = { 38 | userGroups: PropTypes.array.isRequired, 39 | addValueToGroup: PropTypes.func.isRequired, 40 | removeValueFromGroup: PropTypes.func.isRequired, 41 | removeGroup: PropTypes.func.isRequired, 42 | }; 43 | 44 | const Groups = connect( 45 | (state) => ({ 46 | userGroups: state.db.userGroups, 47 | hasCreateGroupRight: state.db.userRights.includes('createGroup'), 48 | }), 49 | { 50 | addValueToGroup, 51 | removeValueFromGroup, 52 | createGroup, 53 | removeGroup, 54 | setGroupProperties, 55 | }, 56 | )(GroupsImpl); 57 | 58 | export default Groups; 59 | -------------------------------------------------------------------------------- /tools/batch/addGroupToEntryByKind.js: -------------------------------------------------------------------------------- 1 | #!/bin/env node 2 | /* eslint-disable no-console */ 3 | 4 | 'use strict'; 5 | 6 | /* 7 | This script allows to add group(s) to entries matching a list of kinds 8 | */ 9 | 10 | const { program } = require('commander'); 11 | 12 | program 13 | .option('-c --config ', 'Path to custom config file') 14 | .option('-d, --db ', 'Database name') 15 | .option('-k, --kind ', 'Comma-separated list of kinds') 16 | .option('-s, --suffix ', 'Comma-separated list of suffixes') 17 | .parse(process.argv); 18 | 19 | const options = program.opts(); 20 | 21 | if (typeof options.db !== 'string') program.missingArgument('db'); 22 | if (typeof options.kind !== 'string') program.missingArgument('kind'); 23 | if (typeof options.suffix !== 'string') program.missingArgument('suffix'); 24 | 25 | const kinds = new Set(options.kind.split(',')); 26 | const suffixes = options.suffix.split(','); 27 | 28 | const Couch = require('../..'); 29 | 30 | const couch = Couch.get(options.db); 31 | 32 | (async function openIIFE() { 33 | await couch.open(); 34 | const db = couch._db; 35 | for (const kind of kinds) { 36 | console.log(`treating kind ${kind}`); 37 | const owners = suffixes.map((suffix) => kind + suffix); 38 | const body = { group: owners }; 39 | const docs = await db.queryView('entryByKind', { key: kind }); 40 | console.log(`${docs.length} documents match`); 41 | for (const { id } of docs) { 42 | await db.updateWithHandler('addGroupToEntry', id, body); 43 | } 44 | } 45 | })() 46 | .catch(console.error) 47 | .then(function close() { 48 | couch.close(); 49 | }); 50 | -------------------------------------------------------------------------------- /src/couch/find.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const debug = require('debug')('main:find'); 4 | 5 | const CouchError = require('../util/CouchError'); 6 | const { getUserGroups } = require('../util/groups'); 7 | 8 | const validateMethods = require('./validate'); 9 | 10 | const methods = { 11 | async findEntriesByRight(user, right, options) { 12 | debug('findEntriesByRight (%s, %s)', user, right); 13 | await this.open(); 14 | options = options || {}; 15 | const query = options.query || {}; 16 | 17 | // Check options 18 | if (query.sort && !query.use_index) { 19 | throw new CouchError('query with sort must use index', 'bad argument'); 20 | } 21 | 22 | right = right || 'read'; 23 | 24 | user = validateMethods.userFromTokenAndRights(user, options.token, [right]); 25 | 26 | // First check if user has global right 27 | const hasGlobalRight = await validateMethods.checkGlobalRight( 28 | this, 29 | user, 30 | right, 31 | ); 32 | 33 | query.selector = query.selector || {}; 34 | query.selector['\\$type'] = 'entry'; 35 | if (hasGlobalRight) { 36 | query.selector['\\$owners'] = undefined; 37 | } else { 38 | const userGroups = await getUserGroups( 39 | this, 40 | user, 41 | right, 42 | options.groups, 43 | options.mine, 44 | ); 45 | query.selector['\\$owners'] = { 46 | $in: userGroups, 47 | }; 48 | } 49 | 50 | if (options.stream) { 51 | return this._db.queryMangoStream(query); 52 | } else { 53 | return this._db.queryMango(query); 54 | } 55 | }, 56 | }; 57 | 58 | module.exports = { methods }; 59 | -------------------------------------------------------------------------------- /src/client/components/Sidebar.jsx: -------------------------------------------------------------------------------- 1 | import { Link } from 'react-router-dom'; 2 | 3 | import SidebarLink from './SidebarLink'; 4 | 5 | export default function Sidebar({ 6 | hasDb, 7 | rocOnline, 8 | loggedIn, 9 | loginProvider, 10 | isAdmin, 11 | userRights, 12 | isGroupOwner, 13 | }) { 14 | return ( 15 |
    16 |
    17 |
    18 | 19 | rest-on-couch 20 | 21 |
    22 | {rocOnline && ( 23 |
      24 | {userRights.includes('createGroup') || isGroupOwner ? ( 25 | 26 | ) : null} 27 | {loggedIn && loginProvider === 'local' ? ( 28 | 33 | ) : null} 34 | {isAdmin ? ( 35 | 36 | ) : null} 37 | {userRights && userRights.includes('admin') ? ( 38 | 43 | ) : null} 44 | {hasDb ? ( 45 | 50 | ) : null} 51 |
    52 | )} 53 |
    54 |
    55 | ); 56 | } 57 | -------------------------------------------------------------------------------- /src/server/auth/ldap/index.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | // Doc: https://github.com/vesse/passport-ldapauth#readme 4 | const LdapStrategy = require('passport-ldapauth'); 5 | 6 | const { auditLogin } = require('../../../audit/actions'); 7 | const auth = require('../../middleware/auth'); 8 | const util = require('../../middleware/util'); 9 | 10 | exports.init = function ldapInit(passport, router, config) { 11 | const strategyConfig = { 12 | passReqToCallback: true, 13 | ...config, 14 | server: { 15 | ...config.server, 16 | }, 17 | }; 18 | passport.use( 19 | new LdapStrategy(strategyConfig, (req, user, done) => { 20 | const data = { 21 | provider: 'ldap', 22 | email: user.mail, 23 | info: {}, 24 | }; 25 | if (typeof config.getUserEmail === 'function') { 26 | data.email = config.getUserEmail(user); 27 | } 28 | if (typeof data.email !== 'string') { 29 | return done( 30 | new Error(`LDAP email must be a string. Saw ${data.email} instead.`), 31 | ); 32 | } 33 | if (typeof config.getSessionProfile === 'function') { 34 | return Promise.resolve(config.getSessionProfile(user)).then( 35 | (info) => { 36 | data.profile = info; 37 | auditLogin(data.email, true, 'ldap', req.ctx); 38 | done(null, data); 39 | }, 40 | (err) => done(err), 41 | ); 42 | } else { 43 | auditLogin(data.email, true, 'ldap', req.ctx); 44 | done(null, data); 45 | return true; 46 | } 47 | }), 48 | ); 49 | 50 | router.post( 51 | '/login/ldap', 52 | util.parseBody(), 53 | auth.afterFailure, 54 | passport.authenticate('ldapauth'), 55 | auth.afterSuccess, 56 | ); 57 | }; 58 | -------------------------------------------------------------------------------- /src/client/components/GroupCreator.jsx: -------------------------------------------------------------------------------- 1 | import PropTypes from 'prop-types'; 2 | import { Component } from 'react'; 3 | import { connect } from 'react-redux'; 4 | 5 | class GroupCreatorImpl extends Component { 6 | constructor(props) { 7 | super(props); 8 | this.state = { 9 | value: '', 10 | }; 11 | } 12 | 13 | render() { 14 | const { createGroup, error } = this.props; 15 | return ( 16 |
    17 |
    18 |

    Create new group

    19 |
    20 |
    21 |
    22 | { 26 | this.setState({ 27 | value: event.target.value, 28 | }); 29 | }} 30 | /> 31 |
    32 | { 36 | createGroup(this.state.value); 37 | }} 38 | value="Create group" 39 | /> 40 |
    41 | {error ? ( 42 |
    43 | {error} 44 |
    45 | ) : null} 46 |
    47 |
    48 | ); 49 | } 50 | } 51 | 52 | GroupCreatorImpl.propTypes = { 53 | createGroup: PropTypes.func.isRequired, 54 | }; 55 | 56 | const mapStateToProps = (state) => { 57 | return { 58 | error: state.db.errors.createGroup, 59 | }; 60 | }; 61 | 62 | const GroupCreator = connect(mapStateToProps)(GroupCreatorImpl); 63 | 64 | export default GroupCreator; 65 | -------------------------------------------------------------------------------- /.github/workflows/docker_run.yml: -------------------------------------------------------------------------------- 1 | name: Docker run 2 | 3 | on: 4 | push: 5 | branches: 6 | - main 7 | pull_request: 8 | 9 | jobs: 10 | test_docker: 11 | runs-on: ubuntu-latest 12 | services: 13 | couchdb: 14 | image: couchdb:latest 15 | env: 16 | COUCHDB_USER: admin 17 | COUCHDB_PASSWORD: admin 18 | ports: 19 | - 127.0.0.1:5984:5984 20 | ldap: 21 | image: ghcr.io/zakodium/ldap-with-users:1 22 | ports: 23 | - 127.0.0.1:1389:389 24 | steps: 25 | - uses: actions/checkout@v5 26 | - name: Setup Node.js 27 | uses: actions/setup-node@v5 28 | with: 29 | node-version-file: package.json 30 | - name: Setup test database 31 | run: node scripts/setup_database.mjs 32 | - name: Build docker image 33 | run: docker build ./ -t rest-on-couch 34 | - name: Run docker container 35 | run: | 36 | echo "Running docker container for 10s..." 37 | CONTAINER_ID=$(docker run -d \ 38 | --network host \ 39 | -v ./test/homeDirectories:/rest-on-couch \ 40 | -e REST_ON_COUCH_HOME_DIR=/rest-on-couch/dev \ 41 | -e DEBUG=couch:* \ 42 | rest-on-couch) 43 | 44 | # Wait up to 10s to see if it exits 45 | if timeout 10s bash -c "docker wait $CONTAINER_ID > /dev/null"; then 46 | echo "❌ Container exited within 10 seconds." 47 | docker logs "$CONTAINER_ID" 48 | docker rm "$CONTAINER_ID" >/dev/null 49 | exit 1 50 | else 51 | docker logs "$CONTAINER_ID" 52 | echo "✅ Still running after 10 seconds (stable)." 53 | docker rm -f "$CONTAINER_ID" >/dev/null 54 | fi 55 | -------------------------------------------------------------------------------- /test/homeDirectories/constants.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | let oidcAuthConfig; 4 | const oidcClient = process.env.OIDC_CLIENT_ID; 5 | const oidcClientSecret = process.env.OIDC_CLIENT_SECRET; 6 | 7 | if (oidcClient && oidcClientSecret) { 8 | // This dev app is configured on id.zakodium.com 9 | oidcAuthConfig = { 10 | title: 'Zakodium SSO', 11 | showLogin: true, 12 | issuer: 'https://id.zakodium.com', 13 | authorizationURL: 'https://id.zakodium.com/oauth/v2/authorize', 14 | tokenURL: 'https://id.zakodium.com/oauth/v2/token', 15 | userInfoURL: 'https://id.zakodium.com/oidc/v1/userinfo', 16 | skipUserProfile: false, 17 | storeProfileInSession: true, 18 | getEmail: function getEmail(data) { 19 | // You can customize the email extraction logic here 20 | // If data is missing from the profile, you might want to decode the jwt token directly 21 | // const jwt = require('jsonwebtoken'); 22 | // const decoded = jwt.decode(data.idToken); 23 | 24 | const { profile } = data; 25 | return profile.emails?.[0]?.value; 26 | }, 27 | getProfile: function getProfile(data) { 28 | return data.profile; 29 | }, 30 | clientID: oidcClient, 31 | clientSecret: oidcClientSecret, 32 | }; 33 | } 34 | 35 | const ldapAuthConfig = { 36 | server: { 37 | url: process.env.REST_ON_COUCH_LDAP_URL, 38 | searchBase: 'dc=zakodium,dc=com', 39 | searchFilter: 'uid={{username}}', 40 | bindDN: process.env.REST_ON_COUCH_LDAP_BIND_D_N, 41 | bindCredentials: process.env.REST_ON_COUCH_LDAP_BIND_PASSWORD, 42 | }, 43 | getSessionProfile: function (user) { 44 | return { 45 | uid: user.uid, 46 | displayName: user.displayName, 47 | }; 48 | }, 49 | }; 50 | 51 | module.exports = { 52 | ldapAuthConfig, 53 | oidcAuthConfig, 54 | }; 55 | -------------------------------------------------------------------------------- /test/unit/rest-api/couchdb_user.test.js: -------------------------------------------------------------------------------- 1 | import { before, beforeEach, describe, it } from 'node:test'; 2 | import { expect } from 'chai'; 3 | 4 | import { getGlobalConfig } from '../../../src/config/config.js'; 5 | import getNano from '../../../src/util/nanoShim.js'; 6 | import { authenticateAs } from '../../utils/authenticate.js'; 7 | import { getAgent } from '../../utils/agent.js'; 8 | 9 | const request = getAgent(); 10 | 11 | let nano; 12 | before(async () => { 13 | const config = getGlobalConfig(); 14 | nano = await getNano(config.url, 'admin', config.adminPassword); 15 | const db = nano.useDb('_users'); 16 | await db.destroyDocument('org.couchdb.user:test@user.com'); 17 | }); 18 | 19 | describe('administrators can configure couchdb users', () => { 20 | beforeEach(async () => { 21 | await authenticateAs(request, 'admin@a.com', '123'); 22 | }); 23 | 24 | it('create a new user', async () => { 25 | await request 26 | .post('/auth/couchdb/user') 27 | .send({ 28 | email: 'test@user.com', 29 | password: 'abc', 30 | }) 31 | .expect(201) 32 | .then((res) => { 33 | expect(res.body).toEqual({ ok: true }); 34 | }); 35 | }); 36 | }); 37 | 38 | describe('non-administrators cannot configure couchdb users', () => { 39 | beforeEach(async () => { 40 | await authenticateAs(request, 'a@a.com', '123'); 41 | }); 42 | 43 | it('cannot create a new user', async () => { 44 | await request 45 | .post('/auth/couchdb/user') 46 | .send({ 47 | email: 'test@user.com', 48 | password: 'abc', 49 | }) 50 | .expect(403) 51 | .then((res) => { 52 | expect(res.body).toEqual({ 53 | code: 'forbidden', 54 | error: 'restricted to administrators', 55 | }); 56 | }); 57 | }); 58 | }); 59 | -------------------------------------------------------------------------------- /eslint.config.mjs: -------------------------------------------------------------------------------- 1 | import { defineConfig, globalIgnores } from 'eslint/config'; 2 | import { globals } from 'eslint-config-zakodium'; 3 | import js from 'eslint-config-zakodium/js'; 4 | import react from 'eslint-config-zakodium/react'; 5 | 6 | export default defineConfig( 7 | globalIgnores([ 8 | 'public', 9 | 'test/homeDirectories/**', 10 | 'src/design/*', 11 | 'coverage', 12 | 'dist', 13 | ]), 14 | js, 15 | { 16 | rules: { 17 | camelcase: ['error', { properties: 'never' }], 18 | 'callback-return': 'off', 19 | 'no-await-in-loop': 'off', 20 | 'no-var': 'off', 21 | 'prefer-named-capture-group': 'off', 22 | 'import/no-dynamic-require': 'off', 23 | 'import/no-extraneous-dependencies': [ 24 | 'error', 25 | // So the deep package.json files (used to specify the module format) are not taken into account. 26 | { packageDir: import.meta.dirname }, 27 | ], 28 | 'import/order': 'off', 29 | }, 30 | }, 31 | { 32 | languageOptions: { 33 | sourceType: 'commonjs', 34 | globals: { 35 | ...globals.node, 36 | }, 37 | }, 38 | }, 39 | { 40 | files: ['test/**'], 41 | languageOptions: { 42 | sourceType: 'module', 43 | globals: { 44 | couch: true, 45 | }, 46 | }, 47 | }, 48 | { 49 | files: ['src/client/**/*'], 50 | languageOptions: { 51 | sourceType: 'module', 52 | globals: { 53 | ...globals.browser, 54 | }, 55 | }, 56 | }, 57 | { 58 | files: ['src/client/**/*.jsx'], 59 | extends: [...react], 60 | }, 61 | { 62 | files: ['scripts/**'], 63 | rules: { 64 | 'no-console': 'off', 65 | }, 66 | }, 67 | { 68 | files: ['**/*.mjs'], 69 | languageOptions: { 70 | sourceType: 'module', 71 | }, 72 | }, 73 | ); 74 | -------------------------------------------------------------------------------- /src/client/store.js: -------------------------------------------------------------------------------- 1 | import { applyMiddleware, createStore } from 'redux'; 2 | import { persistCombineReducers, persistStore } from 'redux-persist'; 3 | import storage from 'redux-persist/lib/storage'; 4 | import promiseMiddleware from 'redux-promise-middleware'; 5 | import { thunk as thunkMiddleware } from 'redux-thunk'; 6 | 7 | import { setDbName } from './actions/db'; 8 | import { getRocStatus } from './actions/main'; 9 | import DbManager from './dbManager'; 10 | import dbReducer from './reducers/db'; 11 | import dbNameReducer from './reducers/dbName'; 12 | import loginReducer from './reducers/login'; 13 | import mainReducer from './reducers/main'; 14 | 15 | const composeStoreWithMiddleware = applyMiddleware( 16 | promiseMiddleware, 17 | thunkMiddleware, 18 | )(createStore); 19 | 20 | const rootReducer = persistCombineReducers( 21 | { 22 | key: 'reduxPersist', 23 | storage, 24 | whitelist: ['dbName'], 25 | throttle: 1000, 26 | }, 27 | { 28 | main: mainReducer, 29 | db: dbReducer, 30 | dbName: dbNameReducer, 31 | login: loginReducer, 32 | }, 33 | ); 34 | 35 | const store = composeStoreWithMiddleware( 36 | rootReducer, 37 | window.__REDUX_DEVTOOLS_EXTENSION__ && window.__REDUX_DEVTOOLS_EXTENSION__(), 38 | ); 39 | 40 | persistStore(store, null, onRehydrated); 41 | 42 | store.dispatch(getRocStatus()); 43 | 44 | export default store; 45 | export const dbManager = new DbManager(store); 46 | 47 | function onRehydrated() { 48 | function getParameterByName(name) { 49 | const url = new URL(window.location.href); 50 | return url.searchParams.get(name); 51 | } 52 | 53 | // If url has a database name, we override the persisted database name 54 | const initialDbName = getParameterByName('database'); 55 | if (initialDbName) store.dispatch(setDbName(initialDbName)); 56 | dbManager.syncDb(); 57 | } 58 | -------------------------------------------------------------------------------- /src/util/token.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const randomatic = require('randomatic'); 4 | 5 | const getRandomToken = () => randomatic('Aa0', 32); 6 | 7 | const CouchError = require('./CouchError'); 8 | const ensureStringArray = require('./ensureStringArray'); 9 | 10 | exports.createEntryToken = async function createEntryToken( 11 | db, 12 | user, 13 | uuid, 14 | rights, 15 | ) { 16 | rights = ensureStringArray(rights, 'rights'); 17 | const token = { 18 | $type: 'token', 19 | $kind: 'entry', 20 | $id: getRandomToken(), 21 | $owner: user, 22 | $creationDate: Date.now(), 23 | uuid, 24 | rights, 25 | }; 26 | await db.insertDocument(token); 27 | return token; 28 | }; 29 | 30 | exports.createUserToken = async function createUserToken(db, user, rights) { 31 | rights = ensureStringArray(rights, 'rights'); 32 | const token = { 33 | $type: 'token', 34 | $kind: 'user', 35 | $id: getRandomToken(), 36 | $owner: user, 37 | $creationDate: Date.now(), 38 | rights, 39 | }; 40 | await db.insertDocument(token); 41 | return token; 42 | }; 43 | 44 | exports.getToken = async function getToken(db, tokenId) { 45 | const result = await db.queryView( 46 | 'tokenById', 47 | { key: tokenId, include_docs: true }, 48 | { onlyDoc: true }, 49 | ); 50 | if (result.length === 0) { 51 | return null; 52 | } else if (result.length === 1) { 53 | return result[0]; 54 | } else { 55 | throw new CouchError('multiple tokens with the same ID', 'fatal'); 56 | } 57 | }; 58 | 59 | exports.getTokens = function getTokens(db, user) { 60 | return db.queryView( 61 | 'tokenByOwner', 62 | { key: user, include_docs: true }, 63 | { onlyDoc: true }, 64 | ); 65 | }; 66 | 67 | exports.destroyToken = function destroyToken(db, tokenId, rev) { 68 | return db.destroyDocument(tokenId, rev); 69 | }; 70 | -------------------------------------------------------------------------------- /src/client/reducers/login.js: -------------------------------------------------------------------------------- 1 | import { 2 | CHANGE_COUCHDB_PASSWORD, 3 | CHECK_LOGIN, 4 | CREATE_COUCHDB_USER, 5 | GET_LOGIN_PROVIDERS, 6 | LOGOUT, 7 | } from '../actions/login'; 8 | 9 | const initialState = { 10 | loginProviders: [], 11 | username: null, 12 | provider: null, 13 | admin: null, 14 | errors: {}, 15 | success: {}, 16 | }; 17 | 18 | const loginReducer = (state = initialState, action = {}) => { 19 | switch (action.type) { 20 | case `${CHECK_LOGIN}_FULFILLED`: { 21 | return { ...state, ...onLogin(action.payload) }; 22 | } 23 | case `${LOGOUT}_FULFILLED`: 24 | return { 25 | ...state, 26 | errors: {}, 27 | username: null, 28 | provider: null, 29 | admin: null, 30 | }; 31 | case `${GET_LOGIN_PROVIDERS}_FULFILLED`: 32 | return { ...state, loginProviders: action.payload }; 33 | case `${CHANGE_COUCHDB_PASSWORD}_FULFILLED`: 34 | return { 35 | ...state, 36 | errors: { changePassword: action.payload.error || '' }, 37 | success: { 38 | changePassword: action.payload.error 39 | ? '' 40 | : 'Successfully changed password', 41 | }, 42 | }; 43 | case `${CREATE_COUCHDB_USER}_FULFILLED`: 44 | return { 45 | ...state, 46 | errors: { createUser: action.payload.error || '' }, 47 | success: { 48 | createUser: action.payload.error ? '' : 'Successfully created user', 49 | }, 50 | }; 51 | default: 52 | return state; 53 | } 54 | }; 55 | 56 | export default loginReducer; 57 | 58 | function onLogin(result) { 59 | if (!result.authenticated) { 60 | return { errors: { [result.provider]: true } }; 61 | } else { 62 | return { 63 | errors: {}, 64 | username: result.username, 65 | provider: result.provider, 66 | admin: result.admin, 67 | }; 68 | } 69 | } 70 | -------------------------------------------------------------------------------- /bin/rest-on-couch-log.js: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | 3 | 'use strict'; 4 | 5 | const { program } = require('commander'); 6 | 7 | const constants = require('../src/constants'); 8 | const log = require('../src/couch/log'); 9 | const Couch = require('../src/index'); 10 | const debug = require('../src/util/debug')('bin:log'); 11 | 12 | program 13 | .option('-d, --database ', 'Database name') 14 | .option('-i, --insert ', 'Insert a new log entry') 15 | .option('-l, --level ', 'Log level (default: WARN)') 16 | .option( 17 | '-e, --epoch ', 18 | 'Return results from epoch (default: 1 day ago)', 19 | ) 20 | .option('-w, --watch', 'Watch for new logs') 21 | .option('-c --config ', 'Path to custom config file') 22 | .parse(process.argv); 23 | 24 | const options = program.opts(); 25 | 26 | const couch = new Couch(options.database); 27 | 28 | if (options.insert) { 29 | couch.log(options.insert, options.level).then( 30 | (done) => { 31 | if (done) { 32 | debug('log inserted successfully'); 33 | } else { 34 | debug.warn('log ignored by current level'); 35 | } 36 | }, 37 | (error) => { 38 | debug.error(error); 39 | }, 40 | ); 41 | } else { 42 | couch 43 | .getLogs(parseInt(options.epoch, 10)) 44 | .then((logs) => { 45 | for (var i = 0; i < logs.length; i++) { 46 | write(logs[i]); 47 | } 48 | if (options.watch) { 49 | const feed = couch._db.follow({ 50 | since: 'now', 51 | include_docs: true, 52 | filter: `${constants.DESIGN_DOC_NAME}/logs`, 53 | }); 54 | feed.on('change', (change) => { 55 | write(change.doc); 56 | }); 57 | feed.follow(); 58 | } 59 | }) 60 | .catch((error) => { 61 | debug.error(error); 62 | }); 63 | } 64 | 65 | function write(doc) { 66 | process.stdout.write(`${log.format(doc)}\n`); 67 | } 68 | -------------------------------------------------------------------------------- /test/unit/rights/global.test.js: -------------------------------------------------------------------------------- 1 | import { beforeEach, describe, it } from 'node:test'; 2 | import { expect } from 'chai'; 3 | 4 | import anyuserData from '../../data/anyuser.js'; 5 | import noRights from '../../data/noRights.js'; 6 | 7 | describe('Access based on global rights', () => { 8 | beforeEach(anyuserData); 9 | 10 | it('Should grant read access to any logged user', () => { 11 | return couch.getEntry('A', 'a@a.com').then((doc) => { 12 | expect(doc).toBeInstanceOf(Object); 13 | }); 14 | }); 15 | 16 | it('Should not grant read access to anonymous', () => { 17 | return expect(couch.getEntry('A', 'anonymous')).rejects.toThrow( 18 | /no access/, 19 | ); 20 | }); 21 | }); 22 | 23 | describe('Edit global rights', () => { 24 | beforeEach(noRights); 25 | 26 | it('Should refuse non-admins', () => { 27 | return expect( 28 | couch.addGlobalRight('a@a.com', 'read', 'a@a.com'), 29 | ).rejects.toThrow(/administrators/); 30 | }); 31 | 32 | it('Should only accept valid types', () => { 33 | return expect( 34 | couch.addGlobalRight('admin@a.com', 'invalid', 'a@a.com'), 35 | ).rejects.toThrow(/Invalid global right type/); 36 | }); 37 | 38 | it('Should not grant read before editing global right', () => { 39 | return expect(couch.getEntry('onlyB', 'a@a.com')).rejects.toThrow( 40 | /no access/, 41 | ); 42 | }); 43 | 44 | it('Should add global read right and grant access', () => { 45 | return expect( 46 | couch 47 | .addGlobalRight('admin@a.com', 'read', 'a@a.com') 48 | .then(() => couch.getEntry('onlyB', 'a@a.com')), 49 | ).resolves.toBeDefined(); 50 | }); 51 | 52 | it('Should remove global read right and not grant access anymore', () => { 53 | return expect( 54 | couch 55 | .removeGlobalRight('admin@a.com', 'read', 'a@a.com') 56 | .then(() => couch.getEntry('onlyB', 'a@a.com')), 57 | ).rejects.toThrow(/no access/); 58 | }); 59 | }); 60 | -------------------------------------------------------------------------------- /test/unit/rights/no_rights.test.js: -------------------------------------------------------------------------------- 1 | import { before, describe, it } from 'node:test'; 2 | import { expect } from 'chai'; 3 | 4 | import constants from '../../data/constants.js'; 5 | import data from '../../data/noRights.js'; 6 | 7 | describe('entry reads, database without any default rights', () => { 8 | before(data); 9 | 10 | it('should grant read access to group member with read access', () => { 11 | return expect(couch.getEntry('A', 'a@a.com')).resolves.toBeInstanceOf( 12 | Object, 13 | ); 14 | }); 15 | 16 | it('should not grant read access to inexistant user', () => { 17 | return expect(couch.getEntry('A', 'z@z.com')).rejects.toThrow(/no access/); 18 | }); 19 | 20 | it('owner of entry should have access to it', () => { 21 | return expect(couch.getEntry('A', 'b@b.com')).toBeInstanceOf(Object); 22 | }); 23 | 24 | it('non-read member should not have access to entry', () => { 25 | return expect(couch.getEntry('A', 'c@c.com')).rejects.toThrow(/no access/); 26 | }); 27 | 28 | it('non-read member should not have access to entry (by uuid)', () => { 29 | return expect(couch.getEntry('A', 'c@c.com')).rejects.toThrow(/no access/); 30 | }); 31 | 32 | it('should only get entries for which user has read access', () => { 33 | return couch 34 | .getEntriesByUserAndRights('a@a.com', 'read') 35 | .then((entries) => { 36 | expect(entries).toHaveLength(5); 37 | expect(entries[0].$id).toBe('A'); 38 | }); 39 | }); 40 | 41 | it('should reject anonymous user', () => { 42 | return expect(couch.getEntry('A', 'anonymous')).rejects.toThrow( 43 | /no access/, 44 | ); 45 | }); 46 | }); 47 | 48 | describe('entry editions, database without any default rights', () => { 49 | before(data); 50 | 51 | it('any user is not allowed to create entry', () => { 52 | return expect( 53 | couch.insertEntry(constants.newEntry, 'z@z.com'), 54 | ).rejects.toThrow(/not allowed to create/); 55 | }); 56 | }); 57 | -------------------------------------------------------------------------------- /src/couch/imports.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const CouchError = require('../util/CouchError'); 4 | const debug = require('../util/debug')('main:imports'); 5 | 6 | const validateMethods = require('./validate'); 7 | 8 | const methods = { 9 | async logImport(toLog) { 10 | await this.open(); 11 | toLog.$type = 'import'; 12 | toLog.$creationDate = Date.now(); 13 | await this._db.insertDocument(toLog); 14 | }, 15 | 16 | async getImports(user, query) { 17 | await this.open(); 18 | debug('get imports (%s)', user); 19 | 20 | const hasRight = await validateMethods.checkRightAnyGroup( 21 | this, 22 | user, 23 | 'readImport', 24 | ); 25 | if (!hasRight) { 26 | throw new CouchError( 27 | 'user is missing read right on imports', 28 | 'unauthorized', 29 | ); 30 | } 31 | 32 | const imports = await this._db.queryView( 33 | 'importsByDate', 34 | { 35 | descending: true, 36 | include_docs: true, 37 | limit: query.limit || 10, 38 | skip: query.skip || 0, 39 | }, 40 | { onlyDoc: true }, 41 | ); 42 | 43 | return imports; 44 | }, 45 | 46 | async getImport(user, uuid) { 47 | await this.open(); 48 | debug('get import (%s, %s)', user, uuid); 49 | 50 | const hasRight = await validateMethods.checkRightAnyGroup( 51 | this, 52 | user, 53 | 'readImport', 54 | ); 55 | if (!hasRight) { 56 | throw new CouchError( 57 | 'user is missing read right on imports', 58 | 'unauthorized', 59 | ); 60 | } 61 | 62 | const doc = await this._db.getDocument(uuid); 63 | if (!doc) { 64 | throw new CouchError('document not found', 'not found'); 65 | } 66 | if (doc.$type !== 'import') { 67 | throw new CouchError( 68 | `wrong document type: ${doc.$type}. Expected: import`, 69 | ); 70 | } 71 | 72 | return doc; 73 | }, 74 | }; 75 | 76 | module.exports = { 77 | methods, 78 | }; 79 | -------------------------------------------------------------------------------- /test/unit/global_entry_unicity.test.js: -------------------------------------------------------------------------------- 1 | import { beforeEach, describe, it } from 'node:test'; 2 | import { expect } from 'chai'; 3 | 4 | import entryUnicity from '../data/globalEntryUnicity.js'; 5 | 6 | describe('global entry unicity', () => { 7 | beforeEach(entryUnicity); 8 | it('getEntryById should return the entry when user is the primary owner', async () => { 9 | const entry = await couch.getEntryById('Y', 'b@b.com'); 10 | expect(entry.$id).toBe('Y'); 11 | }); 12 | 13 | it('getEntryById should return the entry when user is owner via a group', async () => { 14 | const entry = await couch.getEntryById('Y', 'a@a.com'); 15 | expect(entry.$id).toBe('Y'); 16 | }); 17 | 18 | it('ensureExistsOrCreateEntry should fail for user a@a.com because the id is already used by another user', () => { 19 | return expect( 20 | couch.ensureExistsOrCreateEntry('Y', 'a@a.com', { throwIfExists: true }), 21 | ).rejects.toThrow(/entry already exists/); 22 | }); 23 | 24 | it('ensureExistsOrCreateEntry should not create a new entry for user b@b.com because the exists', async () => { 25 | const info = await couch.ensureExistsOrCreateEntry('Y', 'b@b.com'); 26 | expect(info.isNew).toBe(false); 27 | }); 28 | 29 | it('ensureExistsOrCreateEntry should not create a new entry for user a@a.com because the id exists', async () => { 30 | const info = await couch.ensureExistsOrCreateEntry('Y', 'a@a.com'); 31 | expect(info.isNew).toBe(false); 32 | }); 33 | 34 | it('insertEntry should fail with user b@b.com because there already is an entry with the same id', () => { 35 | return expect( 36 | couch.insertEntry({ $id: 'Y', $content: {} }, 'b@b.com'), 37 | ).rejects.toThrow(/entry already exists/); 38 | }); 39 | 40 | it('insertEntry should fail with user a@a.com because there already is an entry with the same id', () => { 41 | return expect( 42 | couch.insertEntry({ $id: 'Y', $content: {} }, 'a@a.com'), 43 | ).rejects.toThrow(/entry already exists/); 44 | }); 45 | }); 46 | -------------------------------------------------------------------------------- /.github/workflows/nodejs.yml: -------------------------------------------------------------------------------- 1 | name: Node.js CI 2 | 3 | on: 4 | push: 5 | branches: 6 | - main 7 | pull_request: 8 | 9 | jobs: 10 | lint: 11 | runs-on: ubuntu-latest 12 | steps: 13 | - uses: actions/checkout@v5 14 | - uses: actions/setup-node@v5 15 | with: 16 | node-version-file: package.json 17 | - name: Install dependencies 18 | run: npm ci 19 | - name: Run ESLint 20 | run: npm run eslint 21 | - name: Run Prettier 22 | run: npm run prettier 23 | test: 24 | runs-on: ubuntu-latest 25 | strategy: 26 | fail-fast: false 27 | matrix: 28 | node-version: [22.x, 24.x] 29 | couchdb-version: [1.7, latest] 30 | services: 31 | couchdb: 32 | image: couchdb:${{ matrix.couchdb-version }} 33 | env: 34 | COUCHDB_USER: admin 35 | COUCHDB_PASSWORD: admin 36 | ports: 37 | - 127.0.0.1:5984:5984 38 | ldap: 39 | image: ghcr.io/zakodium/ldap-with-users:1 40 | ports: 41 | - 127.0.0.1:1389:389 42 | steps: 43 | - uses: actions/checkout@v5 44 | - name: Use Node.js ${{ matrix.node-version }} 45 | uses: actions/setup-node@v5 46 | with: 47 | node-version: ${{ matrix.node-version }} 48 | - name: Setup test database 49 | run: node scripts/setup_database.mjs 50 | - name: Install dependencies 51 | run: npm ci 52 | - name: Run tests 53 | if: ${{ matrix.node-version != '24.x' }} 54 | run: npm run test-only 55 | - name: Run tests with coverage 56 | # This command is only supported by Node.js 24 because it uses experimental-config-file 57 | if: ${{ matrix.node-version == '24.x' }} 58 | run: npm run test-coverage 59 | - name: Send coverage report to Codecov 60 | if: ${{ matrix.node-version == '24.x' && matrix.couchdb-version == 'latest' }} 61 | uses: codecov/codecov-action@v5 62 | with: 63 | fail_ci_if_error: true 64 | disable_search: true 65 | files: lcov.info 66 | -------------------------------------------------------------------------------- /scripts/setup_database.mjs: -------------------------------------------------------------------------------- 1 | import { setTimeout as wait } from 'node:timers/promises'; 2 | 3 | const COUCHDB_HOST = process.env.COUCHDB_HOST || 'localhost'; 4 | const COUCHDB_PORT = process.env.COUCHDB_PORT || 5984; 5 | 6 | const COUCHDB_URL = `http://${COUCHDB_HOST}:${COUCHDB_PORT}`; 7 | 8 | const maxTries = 3; 9 | let tries = 0; 10 | 11 | // CouchDB takes some time to start. We have to wait before setting it up. 12 | while (!(await isCouchReady())) { 13 | tries++; 14 | console.log('CouchDB is starting up...'); 15 | if (tries >= maxTries) { 16 | throw new Error('CouchDB did not start in time'); 17 | } 18 | await wait(5_000); 19 | } 20 | 21 | await couchRequest('POST', '/_cluster_setup', { 22 | action: 'finish_cluster', 23 | }); 24 | 25 | await couchRequest('PUT', '/_users/org.couchdb.user:a@a.com', { 26 | password: '123', 27 | type: 'user', 28 | name: 'a@a.com', 29 | roles: [], 30 | }); 31 | await couchRequest('PUT', '/_users/org.couchdb.user:b@b.com', { 32 | password: '123', 33 | type: 'user', 34 | name: 'b@b.com', 35 | roles: [], 36 | }); 37 | await couchRequest('PUT', '/_users/org.couchdb.user:admin@a.com', { 38 | password: '123', 39 | type: 'user', 40 | name: 'admin@a.com', 41 | roles: [], 42 | }); 43 | await couchRequest('PUT', '/_users/org.couchdb.user:rest-on-couch', { 44 | password: 'roc-123', 45 | type: 'user', 46 | name: 'rest-on-couch', 47 | roles: [], 48 | }); 49 | 50 | await couchRequest('PUT', '/test'); 51 | 52 | async function isCouchReady() { 53 | try { 54 | const response = await couchRequest('GET', '/_users'); 55 | return response.status === 200 || response.status === 404; 56 | } catch (e) { 57 | console.log(e); 58 | return false; 59 | } 60 | } 61 | 62 | function couchRequest(method, path, body) { 63 | return fetch(`${COUCHDB_URL}${path}`, { 64 | method, 65 | body: body ? JSON.stringify(body) : undefined, 66 | headers: { 67 | 'Content-Type': 'application/json', 68 | Accept: 'application/json', 69 | Authorization: `Basic ${Buffer.from('admin:admin').toString('base64')}`, 70 | }, 71 | }); 72 | } 73 | -------------------------------------------------------------------------------- /test/unit/rights/groups.test.js: -------------------------------------------------------------------------------- 1 | import { beforeEach, describe, it } from 'node:test'; 2 | import { expect } from 'chai'; 3 | 4 | import { getDefaultGroupsByRight } from '../../../src/couch/validate.js'; 5 | import data from '../../data/data.js'; 6 | import noRights from '../../data/noRights.js'; 7 | 8 | describe('getGroupsByRight', () => { 9 | beforeEach(data); 10 | it('user with create right', () => { 11 | return global.couch.getGroupsByRight('a@a.com', 'create').then((result) => { 12 | expect(result.sort()).toEqual(['groupA', 'groupB']); 13 | }); 14 | }); 15 | it('user without write right', () => { 16 | return expect( 17 | global.couch.getGroupsByRight('a@a.com', 'write'), 18 | ).resolves.toEqual(['groupA']); 19 | }); 20 | it('user without dummy right', () => { 21 | return expect( 22 | global.couch.getGroupsByRight('a@a.com', 'dummy'), 23 | ).resolves.toEqual([]); 24 | }); 25 | }); 26 | 27 | describe('getGroupsByRight with default groups', () => { 28 | beforeEach(noRights); 29 | it('anonymous has default group', () => { 30 | return global.couch.getGroupsByRight('anonymous', 'read').then((result) => { 31 | expect(result.sort()).toEqual(['defaultAnonymousRead']); 32 | }); 33 | }); 34 | 35 | it('getDefaultGroupsByRight anonymous', async () => { 36 | const groups = await getDefaultGroupsByRight( 37 | global.couch._db, 38 | 'anonymous', 39 | 'read', 40 | ); 41 | expect(groups).toHaveLength(1); 42 | expect(groups[0].name).toEqual('defaultAnonymousRead'); 43 | }); 44 | 45 | it('getDefaultGroupsByRight anyuser', async () => { 46 | const groups = await getDefaultGroupsByRight( 47 | global.couch._db, 48 | 'a@a.com', 49 | 'read', 50 | true, 51 | ); 52 | groups.sort(); 53 | expect(groups).toHaveLength(2); 54 | expect(groups).toEqual(['defaultAnonymousRead', 'defaultAnyuserRead']); 55 | }); 56 | 57 | it('anonymous has no group with owner right', async () => { 58 | const groups = await global.couch.getGroupsByRight('anonymous', 'owner'); 59 | expect(groups).toHaveLength(0); 60 | }); 61 | }); 62 | -------------------------------------------------------------------------------- /src/server/auth/couchdb/index.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const got = require('got').default; 4 | const LocalStrategy = require('passport-local').Strategy; 5 | 6 | const { auditLogin } = require('../../../audit/actions'); 7 | const { getGlobalConfig } = require('../../../config/config'); 8 | const isEmail = require('../../../util/isEmail'); 9 | const auth = require('../../middleware/auth'); 10 | const util = require('../../middleware/util'); 11 | 12 | exports.init = function initCouchdb(passport, router) { 13 | const config = getGlobalConfig(); 14 | 15 | router.post( 16 | '/couchdb/user', 17 | util.parseBody({ jsonLimit: '1kb' }), 18 | auth.ensureAdmin, 19 | auth.createUser, 20 | ); 21 | 22 | passport.use( 23 | new LocalStrategy( 24 | { 25 | usernameField: 'username', 26 | passwordField: 'password', 27 | passReqToCallback: true, 28 | }, 29 | (req, username, password, done) => { 30 | (async function handleUserIIFE() { 31 | if (!isEmail(username)) { 32 | return done(null, false, 'username must be an email'); 33 | } 34 | try { 35 | const res = ( 36 | await got.post(`${config.url}/_session`, { 37 | responseType: 'json', 38 | json: { 39 | name: username, 40 | password, 41 | }, 42 | throwHttpErrors: false, 43 | }) 44 | ).body; 45 | 46 | if (res.error) { 47 | auditLogin(username, false, 'couchdb', req.ctx); 48 | return done(null, false, res.reason); 49 | } 50 | auditLogin(username, true, 'couchdb', req.ctx); 51 | return done(null, { 52 | email: res.name, 53 | provider: 'local', 54 | }); 55 | } catch (err) { 56 | return done(err); 57 | } 58 | })(); 59 | }, 60 | ), 61 | ); 62 | 63 | router.post( 64 | '/login/couchdb', 65 | util.parseBody(), 66 | auth.afterFailure, 67 | passport.authenticate('local'), 68 | auth.afterSuccess, 69 | ); 70 | }; 71 | -------------------------------------------------------------------------------- /src/import/index.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const { getImportConfig } = require('../config/config'); 4 | const debug = require('../util/debug')('import'); 5 | 6 | const BaseImport = require('./ImportContext'); 7 | const ImportResult = require('./ImportResult'); 8 | const saveResult = require('./saveResult'); 9 | 10 | exports.importFile = async function importFile( 11 | database, 12 | importName, 13 | filePath, 14 | options = {}, 15 | ) { 16 | debug('import %s (%s, %s)', filePath, database, importName); 17 | 18 | const dryRun = !!options.dryRun; 19 | 20 | const config = getImportConfig(database, importName); 21 | 22 | const baseImport = new BaseImport(filePath, database); 23 | const result = new ImportResult(); 24 | 25 | const { couch, filename, fileDir } = baseImport; 26 | 27 | try { 28 | await config(baseImport, result); 29 | } catch (e) { 30 | await couch 31 | .logImport({ 32 | name: importName, 33 | filename, 34 | fileDir, 35 | status: 'ERROR', 36 | error: { 37 | message: e.message || '', 38 | stack: e.stack || '', 39 | }, 40 | }) 41 | .catch((error) => { 42 | debug.error( 43 | 'error while logging import error for (%s)', 44 | filename, 45 | error, 46 | ); 47 | }); 48 | throw e; 49 | } 50 | 51 | if (result.isSkipped) { 52 | return { skip: 'skip' }; 53 | } 54 | // Check that required properties have been set on the result 55 | result.check(); 56 | if (dryRun) { 57 | return { skip: 'dryRun', result }; 58 | } 59 | const uuid = await saveResult(baseImport, result); 60 | 61 | await couch 62 | .logImport({ 63 | name: importName, 64 | filename, 65 | fileDir, 66 | status: 'SUCCESS', 67 | result: { 68 | uuid, 69 | id: result.id, 70 | kind: result.kind, 71 | owner: result.owner, 72 | }, 73 | }) 74 | .catch((error) => { 75 | debug.error( 76 | 'error while logging import success for (%s)', 77 | filename, 78 | error, 79 | ); 80 | }); 81 | 82 | return { ok: true, result }; 83 | }; 84 | -------------------------------------------------------------------------------- /src/couch/token.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const CouchError = require('../util/CouchError'); 4 | const debug = require('../util/debug')('main:token'); 5 | const token = require('../util/token'); 6 | 7 | const { isValidUsername, ensureRightsArray } = require('./util'); 8 | 9 | const methods = { 10 | async createEntryToken(user, uuid, rights = ['read']) { 11 | debug('createEntryToken (%s, %s)', user, uuid); 12 | if (!isValidUsername(user)) { 13 | throw new CouchError('only a user can create a token', 'unauthorized'); 14 | } 15 | ensureRightsArray(rights); 16 | await this.open(); 17 | // We need write right to create a token. This will throw if not. 18 | await this.getEntryWithRights(uuid, user, 'write'); 19 | return token.createEntryToken(this._db, user, uuid, rights); 20 | }, 21 | 22 | async createUserToken(user, rights = ['read']) { 23 | debug('createUserToken (%s)', user); 24 | if (!isValidUsername(user)) { 25 | throw new CouchError('only a user can create a token', 'unauthorized'); 26 | } 27 | ensureRightsArray(rights); 28 | await this.open(); 29 | return token.createUserToken(this._db, user, rights); 30 | }, 31 | 32 | async deleteToken(user, tokenId) { 33 | debug('deleteToken (%s, %s)', user, tokenId); 34 | await this.open(); 35 | const tokenValue = await token.getToken(this._db, tokenId); 36 | if (!tokenValue) { 37 | throw new CouchError('token not found', 'not found'); 38 | } 39 | if (tokenValue.$owner !== user) { 40 | throw new CouchError('only owner can delete a token', 'unauthorized'); 41 | } 42 | await token.destroyToken(this._db, tokenValue._id, tokenValue._rev); 43 | }, 44 | 45 | async getToken(tokenId) { 46 | debug('getToken (%s)', tokenId); 47 | await this.open(); 48 | const tokenValue = await token.getToken(this._db, tokenId); 49 | if (!tokenValue) { 50 | throw new CouchError('token not found', 'not found'); 51 | } 52 | return tokenValue; 53 | }, 54 | 55 | async getTokens(user) { 56 | debug('getTokens (%s)', user); 57 | await this.open(); 58 | return token.getTokens(this._db, user); 59 | }, 60 | }; 61 | 62 | module.exports = { 63 | methods, 64 | }; 65 | -------------------------------------------------------------------------------- /src/config/config.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const debug = require('../util/debug')('config'); 4 | 5 | const cliConfig = require('./cli'); 6 | const { getDbConfigOrDie } = require('./db'); 7 | const getEnvConfig = require('./env'); 8 | const { getHomeConfig } = require('./home'); 9 | const { configSchema } = require('./schema.mjs'); 10 | const { getConfigGlobal } = require('./global.mjs'); 11 | const { z } = require('zod'); 12 | 13 | const configStore = {}; 14 | let homeConfig; 15 | let dbConfig; 16 | 17 | const noDbKey = Symbol('noDbKey'); 18 | 19 | function getConfig(database, customConfig = undefined) { 20 | homeConfig ??= getHomeConfig(); 21 | dbConfig ??= getDbConfigOrDie(); 22 | const globalConfig = getConfigGlobal(); 23 | debug.trace('getConfig - db: %s', database); 24 | 25 | if (!customConfig) { 26 | if (!configStore[database]) { 27 | configStore[database] = parseConfig({ 28 | ...globalConfig, 29 | ...homeConfig, 30 | ...dbConfig[database], 31 | ...getEnvConfig(), 32 | ...cliConfig, 33 | }); 34 | } 35 | return configStore[database]; 36 | } else { 37 | const final = { 38 | ...globalConfig, 39 | ...homeConfig, 40 | ...dbConfig[database], 41 | ...getEnvConfig(), 42 | ...cliConfig, 43 | ...customConfig, 44 | }; 45 | return parseConfig(final); 46 | } 47 | } 48 | 49 | function parseConfig(config) { 50 | const result = configSchema.safeParse(config); 51 | if (result.success) { 52 | return result.data; 53 | } else { 54 | throw new Error(z.prettifyError(result.error)); 55 | } 56 | } 57 | 58 | function getImportConfig(database, importName) { 59 | const config = getConfig(database); 60 | if (!config.import || !config.import[importName]) { 61 | throw new Error(`no import config for ${database}/${importName}`); 62 | } 63 | if (typeof config.import[importName] !== 'function') { 64 | throw new TypeError('import config must be a function'); 65 | } 66 | return config.import[importName]; 67 | } 68 | 69 | function getGlobalConfig(customConfig) { 70 | return getConfig(noDbKey, customConfig); 71 | } 72 | 73 | module.exports = { 74 | getConfig, 75 | getImportConfig, 76 | getGlobalConfig, 77 | }; 78 | -------------------------------------------------------------------------------- /src/couch/log.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const util = require('util'); 4 | 5 | const debug = require('../util/debug')('main:log'); 6 | 7 | const levels = { 8 | FATAL: 1, 9 | ERROR: 2, 10 | WARN: 3, 11 | INFO: 4, 12 | DEBUG: 5, 13 | TRACE: 6, 14 | }; 15 | const levelNames = ['', 'FATAL', 'ERROR', 'WARN', 'INFO', 'DEBUG', 'TRACE']; 16 | 17 | for (var i in levels) { 18 | exports[i] = levels[i]; 19 | } 20 | 21 | exports.isValidLevel = function isValidLevel(level) { 22 | return !!levels[level]; 23 | }; 24 | 25 | function checkLevel(level) { 26 | if (!exports.isValidLevel(level)) { 27 | throw new Error(`log level ${level} does not exist`); 28 | } 29 | } 30 | 31 | exports.getLevel = function getLevel(level) { 32 | checkLevel(level); 33 | return levels[level]; 34 | }; 35 | 36 | exports.log = async function log(db, currentLevel, message, level) { 37 | if (typeof currentLevel !== 'number') { 38 | throw new TypeError('current log level must be a number'); 39 | } 40 | if (level === undefined) level = 'WARN'; 41 | checkLevel(level); 42 | level = levels[level]; 43 | if (level > currentLevel) { 44 | return false; 45 | } 46 | await db.insertDocument({ 47 | $type: 'log', 48 | epoch: Date.now(), 49 | level, 50 | message, 51 | }); 52 | return true; 53 | }; 54 | 55 | const ONE_DAY = 1000 * 60 * 60 * 24; 56 | exports.getLogs = function getLogs(db, epoch) { 57 | if (epoch === undefined) epoch = Date.now() - ONE_DAY; 58 | return db.queryView( 59 | 'logsByEpoch', 60 | { startkey: epoch, include_docs: true }, 61 | { onlyDoc: true }, 62 | ); 63 | }; 64 | 65 | exports.format = function format(log) { 66 | const name = levelNames[log.level]; 67 | const date = new Date(log.epoch); 68 | return `[${date.toISOString()}] [${name}]${' '.repeat( 69 | 5 - name.length, 70 | )} ${util.format(log.message)}`; 71 | }; 72 | 73 | exports.methods = { 74 | async log(message, level) { 75 | debug('log (%s, %s)', message, level); 76 | await this.open(); 77 | return exports.log(this._db, this._logLevel, message, level); 78 | }, 79 | 80 | async getLogs(epoch) { 81 | debug('getLogs (%s)', epoch); 82 | await this.open(); 83 | return exports.getLogs(this._db, epoch); 84 | }, 85 | }; 86 | -------------------------------------------------------------------------------- /test/unit/server/file_drop.test.js: -------------------------------------------------------------------------------- 1 | import fs from 'node:fs/promises'; 2 | import path from 'node:path'; 3 | 4 | import { afterEach, describe, it } from 'node:test'; 5 | import { expect } from 'chai'; 6 | 7 | import { getFileDropAgent } from '../../utils/agent.js'; 8 | 9 | const request = getFileDropAgent(); 10 | const homedir = path.join(import.meta.dirname, '../../homeDirectories/main'); 11 | 12 | describe('drop file server', () => { 13 | afterEach(() => 14 | fs.rm(path.join(homedir, 'test/kind1'), { 15 | recursive: true, 16 | }), 17 | ); 18 | it('api endpoint using query strings', async () => { 19 | const buffer = Buffer.from('test with query strings'); 20 | await request 21 | .post('/upload?kind=kind1&database=test&filename=test 123') 22 | .send(buffer) 23 | .expect(200); 24 | 25 | const content = await fs.readFile( 26 | path.join(homedir, 'test/kind1/to_process/test 123'), 27 | 'utf-8', 28 | ); 29 | expect(content).toBe('test with query strings'); 30 | }); 31 | 32 | it('api endpoint using path paramaters', async () => { 33 | const buffer = Buffer.from('test with params'); 34 | await request.post('/upload/test/kind1/test123').send(buffer).expect(200); 35 | 36 | const content = await fs.readFile( 37 | path.join(homedir, 'test/kind1/to_process/test123'), 38 | 'utf-8', 39 | ); 40 | expect(content).toBe('test with params'); 41 | }); 42 | 43 | it('sending a file twice should rename it with an incremental part', async () => { 44 | const buffer = Buffer.from('test conflict'); 45 | await request 46 | .post('/upload?kind=kind1&database=test&filename=testConflict.txt') 47 | .send(buffer) 48 | .expect(200); 49 | await request 50 | .post('/upload?kind=kind1&database=test&filename=testConflict.txt') 51 | .send(buffer) 52 | .expect(200); 53 | const content1 = await fs.readFile( 54 | path.join(homedir, 'test/kind1/to_process/testConflict.txt'), 55 | 'utf-8', 56 | ); 57 | const content2 = await fs.readFile( 58 | path.join(homedir, 'test/kind1/to_process/testConflict.txt.1'), 59 | 'utf-8', 60 | ); 61 | expect(content1).toBe('test conflict'); 62 | expect(content2).toBe('test conflict'); 63 | }); 64 | }); 65 | -------------------------------------------------------------------------------- /src/client/components/GroupDataEditor.jsx: -------------------------------------------------------------------------------- 1 | import PropTypes from 'prop-types'; 2 | import { useState } from 'react'; 3 | 4 | import EnterTextField from './EnterTextField'; 5 | import GroupDataElement from './GroupDataElement'; 6 | import ResponsiveTable from './ResponsiveTable'; 7 | 8 | function GroupDataEditor({ 9 | canAdd = true, 10 | editable = 'all', 11 | type, 12 | data, 13 | addValue, 14 | limit = Infinity, 15 | removeValue, 16 | }) { 17 | const [showAll, setShowAll] = useState(false); 18 | 19 | const slicedData = showAll ? data : data.slice(0, limit); 20 | return ( 21 | <> 22 | 23 | 24 | 25 | {type} 26 | 27 | 28 | 29 | {slicedData.map((value, i) => ( 30 | 36 | ))} 37 | {canAdd ? ( 38 | 39 | 40 | 41 | 42 | 43 | 44 | ) : null} 45 | 46 | 47 | {limit < data.length ? ( 48 | 55 | ) : null} 56 | 57 | ); 58 | } 59 | 60 | GroupDataEditor.propTypes = { 61 | addValue: PropTypes.func.isRequired, 62 | removeValue: PropTypes.func.isRequired, 63 | data: PropTypes.array.isRequired, 64 | type: PropTypes.string.isRequired, 65 | }; 66 | 67 | function isEditable(type, idx) { 68 | if (typeof type === 'number') { 69 | return idx < type; 70 | } 71 | switch (type) { 72 | case 'all-except-first': 73 | return idx !== 0; 74 | case 'none': 75 | return false; 76 | case 'all': 77 | return true; 78 | default: 79 | throw new Error('Invalid prop editable'); 80 | } 81 | } 82 | 83 | export default GroupDataEditor; 84 | -------------------------------------------------------------------------------- /test/homeDirectories/main/config.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const { ldapAuthConfig } = require('../constants'); 4 | 5 | module.exports = { 6 | administrators: ['admin@a.com'], 7 | allowedOrigins: ['http://127.0.0.1:3309', 'http://localhost:3309'], 8 | sessionSigned: false, 9 | customDesign: { 10 | views: { 11 | entryIdByRight: { 12 | map: function (doc) { 13 | emitWithOwner(['x', 'y', 'z'], doc.$id); 14 | }, 15 | withOwner: true, 16 | }, 17 | testReduce: { 18 | map: function (doc) { 19 | if (doc.$type === 'entry') { 20 | emit(doc._id, 1); // eslint-disable-line no-undef 21 | } 22 | }, 23 | reduce: function (keys, values) { 24 | return sum(values); 25 | }, 26 | }, 27 | multiEmit: { 28 | map: function (doc) { 29 | if (doc.$type !== 'entry') { 30 | return; 31 | } 32 | emitWithOwner(doc.$id, 1); 33 | emitWithOwner(doc.$id, 2); 34 | }, 35 | withOwner: true, 36 | }, 37 | }, 38 | }, 39 | auth: { 40 | couchdb: { 41 | title: 'CouchDB authentication', 42 | showLogin: true, 43 | }, 44 | ldap: ldapAuthConfig, 45 | }, 46 | async getUserInfo(email, searchLdap) { 47 | if (email.endsWith('@zakodium.com')) { 48 | const uid = email.slice(0, email.indexOf('@')); 49 | const data = await searchLdap({ 50 | filter: `uid=${uid}`, 51 | attributes: ['mail', 'displayName'], 52 | }); 53 | return { 54 | email: data[0].object.mail, 55 | displayName: data[0].object.displayName, 56 | }; 57 | } else { 58 | return { 59 | email, 60 | value: 42, 61 | }; 62 | } 63 | }, 64 | getPublicUserInfo(user) { 65 | return { 66 | displayName: user.displayName, 67 | email: user.mail, 68 | }; 69 | }, 70 | beforeCreateHook(document, groups) { 71 | const owner = document.$owners[0]; 72 | const groupsToAdd = groups.filter( 73 | (group) => 74 | !document.$owners.some((owner) => owner === group.name) && 75 | group.users.some((user) => user === owner), 76 | ); 77 | for (let group of groupsToAdd) { 78 | document.$owners.push(group.name); 79 | } 80 | }, 81 | }; 82 | -------------------------------------------------------------------------------- /src/client/components/LoginGeneric.jsx: -------------------------------------------------------------------------------- 1 | import { Component } from 'react'; 2 | 3 | class LoginGeneric extends Component { 4 | constructor(props) { 5 | super(props); 6 | this.state = { 7 | username: '', 8 | password: '', 9 | }; 10 | this.handleChange = this.handleChange.bind(this); 11 | this.handleSubmit = this.handleSubmit.bind(this); 12 | this.handleKeyPress = this.handleKeyPress.bind(this); 13 | } 14 | 15 | handleChange(event) { 16 | this.setState({ 17 | [event.target.name]: event.target.value, 18 | }); 19 | } 20 | 21 | handleSubmit() { 22 | if (this.isEmpty()) return; 23 | this.props.login(this.state.username, this.state.password); 24 | } 25 | 26 | handleKeyPress(event) { 27 | if (event.key === 'Enter') this.handleSubmit(); 28 | } 29 | 30 | isEmpty() { 31 | return this.state.username === '' || this.state.password === ''; 32 | } 33 | 34 | render() { 35 | return ( 36 |
    37 |
    38 |
    39 |
    40 | 41 | 49 |
    50 |
    51 |
    52 |
    53 | 54 | 62 |
    63 |
    64 |
    65 | {this.props.error ? ( 66 |

    Wrong username or password!

    67 | ) : ( 68 | '' 69 | )} 70 | 78 |
    79 | 80 | ); 81 | } 82 | } 83 | 84 | export default LoginGeneric; 85 | -------------------------------------------------------------------------------- /test/unit/by_owner_entry_unicity.test.js: -------------------------------------------------------------------------------- 1 | import { beforeEach, describe, it } from 'node:test'; 2 | 3 | import { expect } from 'chai'; 4 | 5 | import entryUnicity from '../data/byOwnerEntryUnicity.js'; 6 | 7 | describe('byOwner entry unicity', () => { 8 | beforeEach(entryUnicity); 9 | it('getEntryById should return the entry when user is the primary owner', async () => { 10 | const entry = await couch.getEntryById('Y', 'b@b.com'); 11 | expect(entry.$id).toBe('Y'); 12 | }); 13 | 14 | it('getEntryById should fail when user is not the primary owner', async () => { 15 | return expect(couch.getEntryById('Y', 'a@a.com')).rejects.toThrow( 16 | /document not found/, 17 | ); 18 | }); 19 | 20 | it('ensureExistsOrCreateEntry should fail for user b@b.com because the id is already used by that user', () => { 21 | return expect( 22 | couch.ensureExistsOrCreateEntry('Y', 'b@b.com', { throwIfExists: true }), 23 | ).rejects.toThrow(/entry already exists/); 24 | }); 25 | 26 | it('ensureExistsOrCreateEntry should not create a new entry for user b@b.com because primary owner is the same', async () => { 27 | const info = await couch.ensureExistsOrCreateEntry('Y', 'b@b.com'); 28 | expect(info.isNew).toBe(false); 29 | }); 30 | 31 | it('insertEntry should fail because b@b.com already has an entry with the same id', () => { 32 | return expect( 33 | couch.insertEntry({ $id: 'Y', $content: {} }, 'b@b.com'), 34 | ).rejects.toThrow(/entry already exists/); 35 | }); 36 | 37 | it('insertEntry should also fail if user is authenticated with a token', async () => { 38 | const token = await couch.createUserToken('b@b.com', [ 39 | 'read', 40 | 'write', 41 | 'create', 42 | ]); 43 | return expect( 44 | couch.insertEntry({ $id: 'Y', $content: {} }, 'anonymous', { 45 | token, 46 | }), 47 | ).rejects.toThrow(/entry already exists/); 48 | }); 49 | 50 | it('ensureExistsOrCreateEntry should create a new entry for user a@a.com because primary owner is different', async () => { 51 | const info = await couch.ensureExistsOrCreateEntry('Y', 'a@a.com'); 52 | expect(info.isNew).toBe(true); 53 | }); 54 | 55 | it('insertEntry should succeed when the id exists for another user', async () => { 56 | const newEntry = await couch.insertEntry( 57 | { $id: 'X', $content: {} }, 58 | 'b@b.com', 59 | ); 60 | expect(newEntry.action).toBe('created'); 61 | expect(newEntry.info.isNew).toBe(true); 62 | }); 63 | }); 64 | -------------------------------------------------------------------------------- /src/client/dbManager.js: -------------------------------------------------------------------------------- 1 | import { 2 | setDbName, 3 | setDefaultGroups, 4 | setGlobalRights, 5 | setMemberships, 6 | setUserGroups, 7 | setUserRights, 8 | } from './actions/db'; 9 | import { apiFetchJSONOptional } from './api'; 10 | 11 | export default class DbManager { 12 | constructor(store) { 13 | this.store = store; 14 | } 15 | 16 | get currentDb() { 17 | return this.store.getState().dbName; 18 | } 19 | 20 | switchDb(newDb) { 21 | if (typeof newDb !== 'string') { 22 | throw new TypeError('db must be a string'); 23 | } 24 | if (this.currentDb !== newDb) { 25 | this.store.dispatch(setDbName(newDb)); 26 | this.syncDb(); 27 | } 28 | } 29 | 30 | async syncDb() { 31 | if (this.currentDb) { 32 | const rights = await this.syncRights(); 33 | this.syncGroups(); 34 | if (rights?.includes('admin')) { 35 | this.syncDefaultGroups(); 36 | this.syncGlobalRights(); 37 | } else { 38 | this.resetDefaultGroups(); 39 | this.resetGlobalRights(); 40 | } 41 | this.syncMemberships(); 42 | } 43 | } 44 | 45 | async syncRights() { 46 | const rights = await apiFetchJSONOptional(`db/${this.currentDb}/rights`); 47 | if (rights) { 48 | this.store.dispatch(setUserRights(rights)); 49 | } 50 | return rights; 51 | } 52 | 53 | async syncGroups() { 54 | const groups = await apiFetchJSONOptional(`db/${this.currentDb}/groups`); 55 | if (groups) { 56 | this.store.dispatch(setUserGroups(groups)); 57 | } 58 | } 59 | 60 | async syncMemberships() { 61 | const memberships = await apiFetchJSONOptional( 62 | `db/${this.currentDb}/user/_me/groups`, 63 | ); 64 | if (memberships) { 65 | this.store.dispatch(setMemberships(memberships)); 66 | } 67 | } 68 | 69 | async syncDefaultGroups() { 70 | const defaultGroups = await apiFetchJSONOptional( 71 | `db/${this.currentDb}/rights/defaultGroups`, 72 | ); 73 | if (defaultGroups) { 74 | this.store.dispatch(setDefaultGroups(defaultGroups)); 75 | } 76 | } 77 | 78 | async syncGlobalRights() { 79 | const globalRights = await apiFetchJSONOptional( 80 | `db/${this.currentDb}/rights/doc`, 81 | ); 82 | if (globalRights) { 83 | this.store.dispatch(setGlobalRights(globalRights)); 84 | } 85 | } 86 | 87 | resetDefaultGroups() { 88 | this.store.dispatch(setDefaultGroups([])); 89 | } 90 | 91 | resetGlobalRights() { 92 | this.store.dispatch(setGlobalRights([])); 93 | } 94 | } 95 | -------------------------------------------------------------------------------- /src/couch/util.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const constants = require('../constants'); 4 | const CouchError = require('../util/CouchError'); 5 | const ensureStringArray = require('../util/ensureStringArray'); 6 | const isEmail = require('../util/isEmail'); 7 | 8 | function isSpecialUser(user) { 9 | return user === 'anonymous' || user === 'anyuser'; 10 | } 11 | 12 | const validName = /^[0-9a-zA-Z._-]+$/; // do not forget to update the same regex in design/validateDocUpdate 13 | 14 | function isValidGroupName(groupName) { 15 | return ( 16 | validName.test(groupName) && 17 | !isSpecialUser(groupName) && 18 | !isEmail(groupName) 19 | ); 20 | } 21 | 22 | function isValidUsername(username) { 23 | return isEmail(username); 24 | } 25 | 26 | function isValidOwner(owner) { 27 | return isValidUsername(owner) || isValidGroupName(owner); 28 | } 29 | 30 | function isValidGlobalRightUser(user) { 31 | return isSpecialUser(user) || isValidUsername(user); 32 | } 33 | 34 | function isValidGlobalRightType(type) { 35 | return constants.globalRightTypes.includes(type); 36 | } 37 | 38 | function isAllowedFirstLevelKey(key) { 39 | return constants.allowedFirstLevelKeys.includes(key); 40 | } 41 | 42 | function isManagedDocumentType(type) { 43 | return type === 'entry' || type === 'group'; 44 | } 45 | 46 | function ensureOwnersArray(owners) { 47 | owners = ensureStringArray(owners); 48 | for (const owner of owners) { 49 | if (!isValidOwner(owner)) { 50 | throw new CouchError(`invalid owner: ${owner}`, 'invalid'); 51 | } 52 | } 53 | return owners; 54 | } 55 | 56 | function ensureUsersArray(users) { 57 | users = ensureStringArray(users); 58 | for (const user of users) { 59 | if (!isValidUsername(user)) { 60 | throw new CouchError(`invalid user: ${user}`, 'invalid'); 61 | } 62 | } 63 | return users; 64 | } 65 | 66 | function ensureRightsArray(rights) { 67 | rights = ensureStringArray(rights); 68 | for (const right of rights) { 69 | if (!isValidGlobalRightType(right)) { 70 | throw new CouchError(`invalid right: ${right}`, 'invalid'); 71 | } 72 | } 73 | return rights; 74 | } 75 | 76 | function isLdapGroup(group) { 77 | return !!(group.DN && group.filter); 78 | } 79 | 80 | module.exports = { 81 | isSpecialUser, 82 | isValidGroupName, 83 | isValidUsername, 84 | isValidOwner, 85 | isValidGlobalRightUser, 86 | isValidGlobalRightType, 87 | isAllowedFirstLevelKey, 88 | isManagedDocumentType, 89 | isLdapGroup, 90 | ensureOwnersArray, 91 | ensureUsersArray, 92 | ensureRightsArray, 93 | }; 94 | -------------------------------------------------------------------------------- /src/util/LDAP.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const ldapjs = require('ldapjs'); 4 | 5 | const debug = require('./debug')('ldap:client'); 6 | 7 | const defaultSearchOptions = { 8 | scope: 'sub', 9 | timeLimit: 1, 10 | }; 11 | 12 | const defaultLdapOptions = { 13 | connectTimeout: 2000, 14 | timeout: 2000, 15 | }; 16 | 17 | function search(ldapOptions, searchOptions) { 18 | searchOptions = { ...defaultSearchOptions, ...searchOptions }; 19 | ldapOptions = { ...defaultLdapOptions, ...ldapOptions }; 20 | 21 | return new Promise((resolve, reject) => { 22 | // ldap options should include bind options 23 | // if client could know when it is ready 24 | // promises would be much easier to handle :-( 25 | const client = ldapjs.createClient(ldapOptions); 26 | client.on('error', (e) => { 27 | reject(e); 28 | }); 29 | 30 | client.__resolve__ = function onResolve(value) { 31 | client.destroy(); 32 | resolve(value); 33 | }; 34 | 35 | client.__reject__ = function onReject(err) { 36 | client.destroy(); 37 | reject(err); 38 | }; 39 | bind(client, ldapOptions.bindDN, ldapOptions.bindPassword) 40 | .then(() => { 41 | try { 42 | client.search(searchOptions.DN, searchOptions, (err, res) => { 43 | if (err) { 44 | client.__reject__(err); 45 | return; 46 | } 47 | const entries = []; 48 | res.on('searchEntry', (entry) => { 49 | entries.push(entry); 50 | }); 51 | res.on('error', (err) => { 52 | client.__reject__(err); 53 | }); 54 | res.on('end', () => { 55 | client.__resolve__(entries); 56 | }); 57 | }); 58 | } catch (e) { 59 | // LIBRARY!!! WHY DON'T YOU PASS ALL YOUR ERRORS IN THE CALLBACK!!! 60 | client.__reject__(e); 61 | } 62 | }) 63 | .catch(() => { 64 | /* Error should be handled by __reject__ */ 65 | }); 66 | }); 67 | } 68 | 69 | function bind(client, DN, password) { 70 | if (!DN || !password) { 71 | debug('ldap search: bypass authentication'); 72 | return Promise.resolve(); 73 | } 74 | return new Promise((resolve, reject) => { 75 | try { 76 | client.bind(DN, password, (err) => { 77 | if (err) { 78 | client.__reject__(err); 79 | reject(err); 80 | return; 81 | } 82 | resolve(); 83 | }); 84 | } catch (e) { 85 | client.__reject__(e); 86 | reject(e); 87 | } 88 | }); 89 | } 90 | 91 | module.exports = { 92 | search, 93 | }; 94 | -------------------------------------------------------------------------------- /src/couch/nano.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const CouchError = require('../util/CouchError'); 4 | const debug = require('../util/debug')('main:nano'); 5 | 6 | async function getGroup(db, name) { 7 | debug.trace('get group'); 8 | const groups = await db.queryView('groupByName', { 9 | key: name, 10 | reduce: false, 11 | include_docs: true, 12 | }); 13 | if (!groups || groups.length === 0) { 14 | debug.trace('group does not exist'); 15 | return null; 16 | } 17 | if (groups.length > 1) { 18 | debug.warn('Getting more than one result for a group name'); 19 | } 20 | debug.trace('group exists'); 21 | return groups[0].doc; 22 | } 23 | 24 | function save(db, entry, user) { 25 | switch (entry.$type) { 26 | case 'entry': 27 | return saveEntry(db, entry, user); 28 | case 'group': 29 | return saveGroup(db, entry, user); 30 | default: 31 | throw new CouchError(`invalid type: ${entry.$type}`); 32 | } 33 | } 34 | 35 | function saveEntry(db, entry, user) { 36 | if (entry.$id === undefined) { 37 | entry.$id = null; 38 | } 39 | if (entry.$kind === undefined) { 40 | entry.$kind = null; 41 | } 42 | return saveWithFields(db, entry, user); 43 | } 44 | 45 | function saveGroup(db, group, user) { 46 | return saveWithFields(db, group, user); 47 | } 48 | 49 | async function saveWithFields(db, object, user) { 50 | const now = Date.now(); 51 | let isNew = false; 52 | object.$lastModification = user; 53 | object.$modificationDate = now; 54 | if (object.$creationDate === undefined) { 55 | object.$creationDate = now; 56 | isNew = true; 57 | } 58 | 59 | const result = await db.insertDocument(object); 60 | result.$modificationDate = object.$modificationDate; 61 | result.$creationDate = object.$creationDate; 62 | result.isNew = isNew; 63 | return result; 64 | } 65 | 66 | function getUuidFromId(db, id, user, type) { 67 | switch (type) { 68 | case 'group': 69 | return getUuidFromIdGroup(db, id); 70 | default: 71 | throw new CouchError(`invalid type: ${type}`); 72 | } 73 | } 74 | 75 | async function getUuidFromIdGroup(db, id) { 76 | const owners = await db.queryView('ownerByTypeAndId', { 77 | key: ['group', id], 78 | }); 79 | if (owners.length === 0) { 80 | throw new CouchError('document not found', 'not found'); 81 | } 82 | if (owners.length !== 1) { 83 | throw new CouchError( 84 | `unexpected number of results: ${owners.length}. There should be only one`, 85 | ); 86 | } 87 | return owners[0].id; 88 | } 89 | 90 | module.exports = { 91 | getGroup, 92 | saveEntry, 93 | saveGroup, 94 | save, 95 | getUuidFromId, 96 | }; 97 | -------------------------------------------------------------------------------- /src/client/actions/login.js: -------------------------------------------------------------------------------- 1 | import { apiFetchJSON, apiFetchForm, apiFetchFormJSON } from '../api'; 2 | import { dbManager } from '../store'; 3 | 4 | export const CHECK_LOGIN = 'CHECK_LOGIN'; 5 | export function checkLogin(dispatch, provider) { 6 | dispatch({ 7 | type: CHECK_LOGIN, 8 | payload: checkTheLogin(provider), 9 | }); 10 | } 11 | 12 | export function checkTheLogin(provider) { 13 | return apiFetchJSON('auth/session').then((session) => { 14 | if (!session.provider) session.provider = provider; 15 | return session; 16 | }); 17 | } 18 | 19 | export const LOGOUT = 'LOGOUT'; 20 | export function logout() { 21 | const logoutRequest = doLogout(); 22 | logoutRequest.then(() => dbManager.syncDb()); 23 | return { 24 | type: LOGOUT, 25 | payload: logoutRequest, 26 | }; 27 | } 28 | 29 | async function doLogout() { 30 | await apiFetchJSON('auth/logout'); 31 | } 32 | 33 | export function loginLDAP(dispatch) { 34 | return (username, password) => { 35 | const loginRequest = doLDAPLogin(username, password); 36 | loginRequest.then(() => dbManager.syncDb()); 37 | dispatch({ 38 | type: CHECK_LOGIN, 39 | payload: loginRequest, 40 | }); 41 | }; 42 | } 43 | 44 | export function loginCouchDB(dispatch) { 45 | return (username, password) => { 46 | const loginRequest = doCouchDBLogin(username, password); 47 | loginRequest.then(() => dbManager.syncDb()); 48 | dispatch({ 49 | type: CHECK_LOGIN, 50 | payload: loginRequest, 51 | }); 52 | }; 53 | } 54 | 55 | async function doCouchDBLogin(username, password) { 56 | await apiFetchForm('auth/login/couchdb', { username, password }); 57 | return checkTheLogin('couchdb'); 58 | } 59 | 60 | async function doLDAPLogin(username, password) { 61 | await apiFetchForm('auth/login/ldap', { username, password }); 62 | return checkTheLogin('ldap'); 63 | } 64 | 65 | export const GET_LOGIN_PROVIDERS = 'GET_LOGIN_PROVIDERS'; 66 | export function getLoginProviders(dispatch) { 67 | dispatch({ 68 | type: GET_LOGIN_PROVIDERS, 69 | payload: apiFetchJSON('auth/providers'), 70 | }); 71 | } 72 | 73 | export const CHANGE_COUCHDB_PASSWORD = 'CHANGE_COUCHDB_PASSWORD'; 74 | export function changeCouchDBPassword(oldPassword, newPassword) { 75 | return { 76 | type: CHANGE_COUCHDB_PASSWORD, 77 | payload: apiFetchFormJSON('auth/password', { oldPassword, newPassword }), 78 | }; 79 | } 80 | 81 | export const CREATE_COUCHDB_USER = 'CREATE_COUCHDB_USER'; 82 | export function createCouchDBUser(email, password) { 83 | return { 84 | type: CREATE_COUCHDB_USER, 85 | payload: apiFetchFormJSON('auth/couchdb/user', { email, password }), 86 | }; 87 | } 88 | -------------------------------------------------------------------------------- /src/server/auth/facebook/index.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | // Facebook profile example 4 | // { 5 | // id: '739343132829716', 6 | // username: undefined, 7 | // displayName: 'Daniel Kostro', 8 | // name: 9 | // { 10 | // familyName: 'Kostro', 11 | // givenName: 'Daniel', 12 | // middleName: undefined 13 | // }, 14 | // gender: 'male', 15 | // profileUrl: 'https://www.facebook.com/app_scoped_user_id/739343132829716/', 16 | // emails: [ { value: 'kostro.d@gmail.com' } ], 17 | // provider: 'facebook', 18 | // _raw: '{"id":"739343132829716","email":"kostro.d\\u0040gmail.com","first_name":"Daniel","gender":"male","last_name":"Kostro","link":"https:\\/\\/www.facebook.com\\/app_scoped_user_id\\/739343132829716\\/","locale":"en_US","name":"Daniel Kostro","timezone":1,"updated_time":"2014-03-16T09:40:42+0000","verified":true}', 19 | // _json: 20 | // { 21 | // id: '739343132829716', 22 | // email: 'kostro.d@gmail.com', 23 | // first_name: 'Daniel', 24 | // gender: 'male', 25 | // last_name: 'Kostro', 26 | // link: 'https://www.facebook.com/app_scoped_user_id/739343132829716/', 27 | // locale: 'en_US', 28 | // name: 'Daniel Kostro', 29 | // timezone: 1, 30 | // updated_time: '2014-03-16T09:40:42+0000', 31 | // verified: true 32 | // } 33 | // } 34 | const FacebookStrategy = require('passport-facebook'); 35 | 36 | const { auditLogin } = require('../../../audit/actions'); 37 | 38 | exports.init = function initFacebook(passport, router, config) { 39 | passport.use( 40 | new FacebookStrategy( 41 | { 42 | clientID: config.appId, 43 | clientSecret: config.appSecret, 44 | callbackURL: config.publicAddress + config.callbackURL, 45 | enableProof: false, 46 | passReqToCallback: true, 47 | }, 48 | (req, accessToken, refreshToken, profile, done) => { 49 | const email = profile._json.email; 50 | auditLogin(email, true, 'facebook', req.ctx); 51 | done(null, { 52 | provider: 'facebook', 53 | email, 54 | }); 55 | }, 56 | ), 57 | ); 58 | 59 | router.get( 60 | config.loginURL, 61 | async (ctx, next) => { 62 | ctx.session.redirect = `${config.successRedirect}?${ctx.request.querystring}`; 63 | await next(); 64 | }, 65 | passport.authenticate('facebook', { scope: ['email'] }), 66 | ); 67 | 68 | router.get( 69 | config.callbackURL, 70 | passport.authenticate('facebook', { 71 | failureRedirect: config.failureRedirect, 72 | }), 73 | (ctx) => { 74 | // Successful authentication, redirect home. 75 | if (ctx.session.redirect) { 76 | ctx.response.redirect(ctx.session.redirect); 77 | } else { 78 | ctx.response.redirect(config.successRedirect); 79 | } 80 | }, 81 | ); 82 | }; 83 | -------------------------------------------------------------------------------- /test/unit/basic.test.js: -------------------------------------------------------------------------------- 1 | import assert from 'node:assert'; 2 | 3 | import { beforeEach, describe, it } from 'node:test'; 4 | import { expect } from 'chai'; 5 | 6 | import Couch from '../../src/index.js'; 7 | import constants from '../../src/constants.js'; 8 | import entryUnicity from '../data/byOwnerEntryUnicity.js'; 9 | import { resetDatabaseWithoutCouch } from '../utils/utils.js'; 10 | 11 | import { getCouchMajorVersion } from '../utils/couch.js'; 12 | 13 | process.on('unhandledRejection', function handleUnhandledRejection(err) { 14 | throw err; 15 | }); 16 | 17 | describe('basic initialization tests', () => { 18 | it('should init', async () => { 19 | await resetDatabaseWithoutCouch('test2'); 20 | const couch = Couch.get('test2'); 21 | return couch.open(); 22 | }); 23 | 24 | it('should throw if no database given', () => { 25 | return expect(Promise.resolve().then(() => new Couch())).rejects.toThrow( 26 | 'database option is mandatory', 27 | ); 28 | }); 29 | 30 | it('should throw on invalid db name', () => { 31 | expect(() => new Couch({ database: '_test' })).toThrow( 32 | /invalid database name/, 33 | ); 34 | 35 | expect(() => { 36 | Couch.get(1); 37 | }).toThrow(/database name must be a string/); 38 | }); 39 | }); 40 | 41 | describe('basic initialization with custom design docs', () => { 42 | beforeEach(entryUnicity); 43 | 44 | it('should have initialized the main app design document', async () => { 45 | const app = await couch._db.getDocument( 46 | `_design/${constants.DESIGN_DOC_NAME}`, 47 | ); 48 | assert.notEqual(app, null); 49 | assert.ok(app.filters.abc); 50 | }); 51 | 52 | it('should have initialized the default custom design doc', async () => { 53 | const app = await couch._db.getDocument( 54 | `_design/${constants.CUSTOM_DESIGN_DOC_NAME}`, 55 | ); 56 | assert.notEqual(app, null); 57 | assert.ok(app.views.test); 58 | }); 59 | 60 | it('should have initialized a named custom views design doc', async () => { 61 | const custom = await couch._db.getDocument('_design/custom'); 62 | assert.notEqual(custom, null); 63 | assert.ok(custom.views.testCustom); 64 | }); 65 | 66 | it('should have initialized a named custom index design doc', async () => { 67 | const custom = await couch._db.getDocument('_design/modDateIndex'); 68 | const couchdbVersion = await getCouchMajorVersion(); 69 | if (couchdbVersion === 1) { 70 | // In CouchDB 1, there is no support for mango indexes so this design doc is not created. 71 | assert.equal(custom, null); 72 | } else { 73 | assert.notEqual(custom, null); 74 | assert.ok(custom.views.modDate); 75 | } 76 | }); 77 | 78 | it('should query a custom design document', () => { 79 | return couch.queryEntriesByUser('a@a.com', 'testCustom').then((data) => { 80 | expect(data).toHaveLength(1); 81 | }); 82 | }); 83 | }); 84 | -------------------------------------------------------------------------------- /src/server/auth/oidc/index.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const debug = require('../../../util/debug.js')('auth:oidc'); 4 | const OIDCStrategy = require('passport-openidconnect'); 5 | 6 | const { auditLogin } = require('../../../audit/actions'); 7 | const isEmail = require('../../../util/isEmail'); 8 | 9 | exports.init = function initOidc(passport, router, authConfig, globalConfig) { 10 | passport.use( 11 | new OIDCStrategy( 12 | { 13 | issuer: authConfig.issuer, 14 | authorizationURL: authConfig.authorizationURL, 15 | tokenURL: authConfig.tokenURL, 16 | userInfoURL: authConfig.userInfoURL, 17 | clientID: authConfig.clientID, 18 | clientSecret: authConfig.clientSecret, 19 | claims: authConfig.claims, 20 | skipUserProfile: authConfig.skipUserProfile, 21 | callbackURL: 22 | authConfig.callbackURL || 23 | `${globalConfig.publicAddress}/auth/login/oidc/callback`, 24 | scope: ['openid', 'profile', 'email'], 25 | passReqToCallback: true, 26 | }, 27 | function verify( 28 | req, 29 | issuer, 30 | // Warning: this won't always contain all the profile information, 31 | // and you might want to parse the id token yourself 32 | profile, 33 | context, 34 | idToken, 35 | accessToken, 36 | refreshToken, 37 | done, 38 | ) { 39 | let email; 40 | let sessionProfile; 41 | 42 | const { getEmail = () => profile.email, getProfile = () => profile } = 43 | authConfig; 44 | try { 45 | email = getEmail({ profile, idToken, accessToken }); 46 | } catch (err) { 47 | debug.error('error while parsing user email', err); 48 | return done(null, false, 'error while parsing user email'); 49 | } 50 | 51 | if (typeof email !== 'string' || !isEmail(email)) { 52 | return done(null, false, 'username must be an email'); 53 | } 54 | 55 | try { 56 | sessionProfile = getProfile({ profile, idToken, accessToken }); 57 | } catch (err) { 58 | debug.error('error while parsing user profile', err); 59 | return done(null, false, 'error while parsing user profile'); 60 | } 61 | 62 | auditLogin(email, true, 'oidc', req.ctx); 63 | done(null, { 64 | provider: 'oidc', 65 | email, 66 | profile: authConfig.storeProfileInSession 67 | ? sessionProfile 68 | : undefined, 69 | }); 70 | }, 71 | ), 72 | ); 73 | 74 | router.get('/login/oidc', passport.authenticate('openidconnect')); 75 | 76 | router.get( 77 | '/login/oidc/callback', 78 | passport.authenticate('openidconnect', { 79 | failureRedirect: globalConfig.authRedirectUrl, 80 | failureMessage: true, 81 | }), 82 | (ctx) => { 83 | ctx.response.redirect(globalConfig.authRedirectUrl); 84 | }, 85 | ); 86 | }; 87 | -------------------------------------------------------------------------------- /src/server/middleware/util.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const bodyParser = require('koa-bodyparser'); 4 | const compose = require('koa-compose'); 5 | const rawBody = require('raw-body'); 6 | 7 | const { getGlobalConfig } = require('../../config/config'); 8 | const debug = require('../../util/debug')('middleware:util'); 9 | 10 | const { decorateError, responseHasBody } = require('./decorateError'); 11 | 12 | exports.parseBody = function parseBody(options) { 13 | return bodyParser(options); 14 | }; 15 | 16 | exports.parseRawBody = function parseRawBody(options) { 17 | return async (ctx, next) => { 18 | ctx.request.body = await rawBody(ctx.req, options); 19 | await next(); 20 | }; 21 | }; 22 | 23 | exports.getUuidFromGroupName = async (ctx, next) => { 24 | ctx.params.uuid = await ctx.state.couch.getDocUuidFromId( 25 | ctx.params.name, 26 | ctx.state.userEmail, 27 | 'group', 28 | ); 29 | await next(); 30 | }; 31 | 32 | exports.getGroupFromGroupName = async (ctx, next) => { 33 | ctx.params.group = await ctx.state.couch.getGroup( 34 | ctx.params.name, 35 | ctx.state.userEmail, 36 | ); 37 | await next(); 38 | }; 39 | 40 | exports.composeWithError = function composeWithError(middleware) { 41 | return compose([errorMiddleware, middleware]); 42 | }; 43 | 44 | function onGetError(ctx, e, secure) { 45 | const reason = e.reason; 46 | const message = e.message; 47 | 48 | switch (reason) { 49 | case 'unauthorized': 50 | if (!secure) { 51 | decorateError(ctx, 401, message); 52 | break; 53 | } 54 | // fallthrough 55 | case 'not found': 56 | decorateError(ctx, 404, message); 57 | break; 58 | case 'conflict': 59 | decorateError(ctx, 409, message); 60 | break; 61 | case 'invalid': 62 | decorateError(ctx, 400, message); 63 | break; 64 | case 'forbidden': 65 | decorateError(ctx, 403, message); 66 | break; 67 | default: 68 | if (!handleCouchError(ctx, e, secure)) { 69 | decorateError(ctx, 500, message); 70 | debug.error(e, e.stack); 71 | } 72 | break; 73 | } 74 | if (getGlobalConfig().debugrest && responseHasBody(ctx)) { 75 | ctx.body.stack = e.stack; 76 | } 77 | } 78 | 79 | exports.onGetError = onGetError; 80 | 81 | async function errorMiddleware(ctx, next) { 82 | try { 83 | await next(); 84 | } catch (e) { 85 | onGetError(ctx, e); 86 | } 87 | } 88 | 89 | function handleCouchError(ctx, e, secure) { 90 | if (e.name !== 'HTTPError') { 91 | return false; 92 | } 93 | var statusCode = e.statusCode; 94 | if (statusCode) { 95 | if (statusCode === 404 && secure) { 96 | statusCode = 401; 97 | } 98 | 99 | if (statusCode === 500) { 100 | debug.error(e, e.stack); 101 | } 102 | 103 | decorateError(ctx, statusCode, e.body.reason); 104 | return true; 105 | } 106 | return false; 107 | } 108 | -------------------------------------------------------------------------------- /src/client/components/Login.jsx: -------------------------------------------------------------------------------- 1 | import PropTypes from 'prop-types'; 2 | import { useState } from 'react'; 3 | import { connect } from 'react-redux'; 4 | 5 | import { loginCouchDB, loginLDAP } from '../actions/login'; 6 | import { API_ROOT } from '../api'; 7 | 8 | import LoginGeneric from './LoginGeneric'; 9 | import LoginGoogle from './LoginGoogle'; 10 | 11 | const LoginImpl = (props) => { 12 | const [redirectURL] = useState(() => { 13 | const url = new URL(window.location.href); 14 | url.hash = ''; 15 | return url.toString(); 16 | }); 17 | const googleProvider = props.loginProviders.find((p) => p.name === 'google'); 18 | const couchdbProvider = props.loginProviders.find( 19 | (p) => p.name === 'couchdb', 20 | ); 21 | const ldapProvider = props.loginProviders.find((p) => p.name === 'ldap'); 22 | const oidcProvider = props.loginProviders.find((p) => p.name === 'oidc'); 23 | return ( 24 |
    25 | {props.loginProviders.length === 0 ? 'No login provider available' : ''} 26 | {googleProvider && ( 27 |
    28 |
    29 |

    {googleProvider.title}

    30 |
    31 |
    32 | 33 |
    34 |
    35 | )} 36 | {ldapProvider && ( 37 |
    38 |
    39 |

    {ldapProvider.title}

    40 |
    41 |
    42 | 43 |
    44 |
    45 | )} 46 | {couchdbProvider && ( 47 |
    48 |
    49 |

    {couchdbProvider.title}

    50 |
    51 |
    52 | 56 |
    57 |
    58 | )} 59 | {oidcProvider && ( 60 |
    61 |
    62 |

    {oidcProvider.title}

    63 |
    64 | 71 |
    72 | )} 73 |
    74 | ); 75 | }; 76 | 77 | LoginImpl.propTypes = { 78 | errors: PropTypes.object.isRequired, 79 | loginLDAP: PropTypes.func, 80 | loginProviders: PropTypes.array.isRequired, 81 | }; 82 | 83 | const Login = connect( 84 | (state) => ({ 85 | errors: state.login.errors, 86 | loginProviders: state.login.loginProviders.filter((p) => p.visible), 87 | }), 88 | (dispatch) => ({ 89 | loginLDAP: loginLDAP(dispatch), 90 | loginCouchDB: loginCouchDB(dispatch), 91 | }), 92 | )(LoginImpl); 93 | 94 | export default Login; 95 | -------------------------------------------------------------------------------- /src/server/auth/google/index.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | // provided by passport-google-oauth20 3 | 4 | // { 5 | // "user": { 6 | // "provider": "google", 7 | // "id": "100982963740157602406", 8 | // "displayName": "Daniel Kostro", 9 | // "name": { 10 | // "familyName": "Kostro", 11 | // "givenName": "Daniel" 12 | // }, 13 | // "emails": [ 14 | // { 15 | // "value": "kostro.d@gmail.com" 16 | // } 17 | // ], 18 | // "_raw": "{\n \"id\": \"100982963740157602406\",\n \"email\": \"kostro.d@gmail.com\",\n \"verified_email\": true,\n \"name\": \"Daniel Kostro\",\n \"given_name\": \"Daniel\",\n \"family_name\": \"Kostro\",\n \"link\": \"https://plus.google.com/+DanielKostro\",\n \"picture\": \"https://lh3.googleusercontent.com/-IvcZEni7cxM/AAAAAAAAAAI/AAAAAAAACso/4Zy9vw_ucks/photo.jpg\",\n \"gender\": \"male\"\n}\n", 19 | // "_json": { 20 | // "id": "100982963740157602406", 21 | // "email": "kostro.d@gmail.com", 22 | // "verified_email": true, 23 | // "name": "Daniel Kostro", 24 | // "given_name": "Daniel", 25 | // "family_name": "Kostro", 26 | // "link": "https://plus.google.com/+DanielKostro", 27 | // "picture": "https://lh3.googleusercontent.com/-IvcZEni7cxM/AAAAAAAAAAI/AAAAAAAACso/4Zy9vw_ucks/photo.jpg", 28 | // "gender": "male" 29 | // } 30 | // } 31 | // } 32 | 33 | const GoogleStrategy = require('passport-google-oauth20').Strategy; 34 | 35 | const { auditLogin } = require('../../../audit/actions'); 36 | 37 | exports.init = function init(passport, router, authConfig, globalConfig) { 38 | // todo we should be able to put a relative callbackURL (add proxy: true) but there is a bug in passport-oauth2 39 | // with the generation of redirect_url 40 | passport.use( 41 | new GoogleStrategy( 42 | { 43 | clientID: authConfig.clientID, 44 | clientSecret: authConfig.clientSecret, 45 | callbackURL: `${globalConfig.publicAddress}/auth/login/google/callback`, 46 | passReqToCallback: true, 47 | }, 48 | (req, accessToken, refreshToken, profile, done) => { 49 | const email = profile.emails[0]; 50 | if (!email) { 51 | return done(null, false, { message: 'No profile email' }); 52 | } else { 53 | auditLogin(email.value, true, 'google', req.ctx); 54 | done(null, { 55 | provider: 'google', 56 | email: email.value, 57 | }); 58 | return true; 59 | } 60 | }, 61 | ), 62 | ); 63 | 64 | router.get('/login/google/popup', (ctx) => { 65 | ctx.session.popup = true; 66 | ctx.redirect('/auth/login/google'); 67 | }); 68 | 69 | router.get( 70 | '/login/google', 71 | passport.authenticate('google', { 72 | scope: ['https://www.googleapis.com/auth/userinfo.email'], 73 | }), 74 | ); 75 | 76 | router.get( 77 | '/login/google/callback', 78 | passport.authenticate('google', { 79 | successRedirect: globalConfig.authRedirectUrl, 80 | failureRedirect: globalConfig.authRedirectUrl, 81 | }), 82 | ); 83 | }; 84 | -------------------------------------------------------------------------------- /test/unit/config/load_db_config_errors.test.js: -------------------------------------------------------------------------------- 1 | import path from 'node:path'; 2 | import assert from 'node:assert'; 3 | import fs from 'node:fs'; 4 | import { it, mock } from 'node:test'; 5 | import { expect } from 'chai'; 6 | 7 | import { getDbConfig, getDbConfigOrDie } from '../../../src/config/db.js'; 8 | 9 | process.stderr.write = () => { 10 | // ignore 11 | }; 12 | // Avoid the call to dbConfig to crash the process 13 | process.exit = () => { 14 | // ignore 15 | }; 16 | 17 | it('process should die when there is a problem loading the database configuration', () => { 18 | const exit = mock.method(process, 'exit'); 19 | getDbConfigOrDie( 20 | path.join(import.meta.dirname, '../../homeDirectories/failDuplicateView'), 21 | ); 22 | expect(exit.mock.calls[0]?.arguments[0]).toBe(1); 23 | }); 24 | 25 | it('configuration has duplicate view name', () => { 26 | expect(function load() { 27 | return getDbConfig( 28 | path.join(import.meta.dirname, '../../homeDirectories/failDuplicateView'), 29 | ); 30 | }).toThrow(/a view is defined more than once: viewTest/); 31 | }); 32 | 33 | it('loading configuration that has duplicate index name', () => { 34 | expect(function load() { 35 | return getDbConfig( 36 | path.join( 37 | import.meta.dirname, 38 | '../../homeDirectories/failDuplicateIndex', 39 | ), 40 | ); 41 | }).toThrow(/an index is defined more than once: indexTest/); 42 | }); 43 | 44 | it('loading configuration that has duplicate index name', () => { 45 | expect(function load() { 46 | return getDbConfig( 47 | path.join( 48 | import.meta.dirname, 49 | '../../homeDirectories/failShareDesignDoc', 50 | ), 51 | ); 52 | }).toThrow( 53 | /query indexes and javascript views cannot share design documents: foo/, 54 | ); 55 | }); 56 | 57 | it('loading configuration that has duplicate names', () => { 58 | expect(function load() { 59 | return getDbConfig( 60 | path.join(import.meta.dirname, '../../homeDirectories/failShareName'), 61 | ); 62 | }).toThrow(/query indexes and javascript views cannot share names: test/); 63 | }); 64 | 65 | it('loading configuration with unallowed override of the filters prop', () => { 66 | expect(function load() { 67 | return getDbConfig( 68 | path.join( 69 | import.meta.dirname, 70 | '../../homeDirectories/failUnallowedOverride', 71 | ), 72 | ); 73 | }).toThrow(/^customDesign\.updates cannot be overriden$/); 74 | }); 75 | 76 | it('loading import.js file implemented with ESM syntax', async () => { 77 | const dir = path.join( 78 | import.meta.dirname, 79 | '../../homeDirectories/failEsmInJsFile', 80 | ); 81 | assert(fs.existsSync(dir)); 82 | expect(() => getDbConfig(dir)).toThrow(/Unexpected token 'export'/); 83 | }); 84 | 85 | it('failEsmWrongExport - loading import.mjs with a default export instead of named export', async () => { 86 | const dir = path.join( 87 | import.meta.dirname, 88 | '../../homeDirectories/failEsmWrongExport', 89 | ); 90 | assert(fs.existsSync(dir)); 91 | expect(() => getDbConfig(dir)).toThrow( 92 | /import.mjs must export an `importFile` function/, 93 | ); 94 | }); 95 | -------------------------------------------------------------------------------- /src/design/app.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const fs = require('fs'); 4 | const path = require('path'); 5 | 6 | const getConfig = require('../config/config').getConfig; 7 | const constants = require('../constants'); 8 | 9 | const filters = require('./filters'); 10 | const updates = require('./updates'); 11 | const validateDocUpdate = require('./validateDocUpdate'); 12 | const views = require('./views'); 13 | 14 | /* istanbul ignore next */ 15 | const mapTpl = function (doc) { 16 | if (doc.$type !== 'entry') return; 17 | var emitWithOwner = function (key, data) { 18 | for (var i = 0; i < doc.$owners.length; i++) { 19 | if (key == null) { 20 | emit([doc.$owners[i]], data); 21 | } else { 22 | emit([doc.$owners[i]].concat(key), data); 23 | } 24 | } 25 | }; 26 | var customMap = CUSTOM_MAP; 27 | customMap(doc); 28 | }.toString(); 29 | 30 | // Extends design doc with default views 31 | // Adds the special lib view to the design doc 32 | module.exports = function getDesignDoc(custom, dbName) { 33 | custom = custom || {}; 34 | const config = getConfig(dbName); 35 | processViews(custom, config); 36 | if (custom.designDoc === constants.DESIGN_DOC_NAME) { 37 | return { 38 | _id: constants.DESIGN_DOC_ID, 39 | language: 'javascript', 40 | filters: Object.assign({}, custom.filters, filters), 41 | updates: Object.assign({}, custom.updates, updates), 42 | views: Object.assign({}, custom.views, views), 43 | validate_doc_update: validateDocUpdate, 44 | lists: Object.assign({}, custom.lists), 45 | }; 46 | } else { 47 | return custom; 48 | } 49 | }; 50 | 51 | function processViews(custom, config) { 52 | if (custom.views) { 53 | for (const viewName in custom.views) { 54 | const view = custom.views[viewName]; 55 | if (viewName !== 'lib') { 56 | if (view.withOwner && typeof view.map === 'function') { 57 | view.map = mapTpl.replace('CUSTOM_MAP', view.map.toString()); 58 | view.reduce = '_count'; // force the reduce for future optimizations. 59 | } 60 | } 61 | } 62 | } 63 | // Lib is added to all design documents 64 | if ( 65 | config.customDesign && 66 | config.customDesign.views && 67 | config.customDesign.views.lib 68 | ) { 69 | if (!custom.views) custom.views = {}; 70 | custom.views.lib = {}; 71 | const view = config.customDesign.views.lib; 72 | for (const libName in view) { 73 | let lib = view[libName]; 74 | if (!Array.isArray(lib)) { 75 | lib = [lib]; 76 | } 77 | let libCode = lib[0]; 78 | if (typeof libCode === 'string') { 79 | if (libCode.endsWith('.js')) { 80 | libCode = fs.readFileSync( 81 | path.resolve(config.homeDir, config.database, libCode), 82 | 'utf8', 83 | ); 84 | } 85 | if (lib.length === 1) { 86 | custom.views.lib[libName] = libCode; 87 | } else { 88 | for (let i = 1; i < lib.length; i++) { 89 | if (custom._id === `_design/${lib[i]}`) { 90 | custom.views.lib[libName] = libCode; 91 | } 92 | } 93 | } 94 | } 95 | } 96 | } 97 | } 98 | -------------------------------------------------------------------------------- /src/import/saveResult.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const ASCIIFolder = require('fold-to-ascii'); 4 | 5 | const constants = require('../constants'); 6 | 7 | module.exports = async function saveResult(importBase, result) { 8 | const couch = importBase.couch; 9 | if (result.isSkipped) return; 10 | 11 | // Create the new document if it does not exist 12 | let document = await couch.ensureExistsOrCreateEntry( 13 | result.id, 14 | result.owner, 15 | { 16 | kind: result.kind, 17 | owners: result.groups, 18 | }, 19 | ); 20 | 21 | // In case the document already existed, we need update the list of owners 22 | if (result.groups.length) { 23 | document = await couch.addOwnersToDoc( 24 | document.id, 25 | result.owner, 26 | result.groups, 27 | 'entry', 28 | ); 29 | } 30 | 31 | const mainFilename = 32 | result.filename === null ? importBase.filename : result.filename; 33 | 34 | switch (result.getUpdateType()) { 35 | case constants.IMPORT_UPDATE_FULL: 36 | await couch.addFileToJpath( 37 | result.id, 38 | result.owner, 39 | result.jpath, 40 | result.metadata, 41 | { 42 | field: result.field, 43 | name: `${result.jpath.join('/')}/${ASCIIFolder.foldReplacing( 44 | mainFilename, 45 | '_', 46 | )}`, 47 | data: await importBase.getContents(), 48 | reference: result.reference, 49 | content_type: result.content_type, 50 | }, 51 | result.content, 52 | ); 53 | break; 54 | case constants.IMPORT_UPDATE_WITHOUT_ATTACHMENT: 55 | await couch.addFileToJpath( 56 | result.id, 57 | result.owner, 58 | result.jpath, 59 | result.metadata, 60 | { 61 | reference: result.reference, 62 | }, 63 | result.content, 64 | true, 65 | ); 66 | break; 67 | case constants.IMPORT_UPDATE_$CONTENT_ONLY: 68 | await couch.insertEntry( 69 | { 70 | $id: result.id, 71 | $kind: result.kind, 72 | $content: result.content, 73 | _id: document.id, 74 | _rev: document.rev, 75 | }, 76 | result.owner, 77 | { merge: true }, 78 | ); 79 | break; 80 | default: 81 | throw new Error('Unreachable'); 82 | } 83 | 84 | // Upload additional attachments with metadata 85 | for (const attachment of result.attachments) { 86 | const contents = Buffer.from( 87 | attachment.contents.buffer, 88 | attachment.contents.byteOffset, 89 | attachment.contents.byteLength, 90 | ); 91 | await couch.addFileToJpath( 92 | result.id, 93 | result.owner, 94 | attachment.jpath, 95 | attachment.metadata, 96 | { 97 | field: attachment.field, 98 | reference: attachment.reference, 99 | name: `${attachment.jpath.join('/')}/${ASCIIFolder.foldReplacing( 100 | attachment.filename, 101 | '_', 102 | )}`, 103 | data: contents, 104 | content_type: attachment.content_type, 105 | }, 106 | ); 107 | } 108 | 109 | return document.id; 110 | }; 111 | -------------------------------------------------------------------------------- /src/client/components/EditableTextField.jsx: -------------------------------------------------------------------------------- 1 | import PropTypes from 'prop-types'; 2 | import { Component, createRef } from 'react'; 3 | 4 | class EditableTextField extends Component { 5 | constructor(props) { 6 | super(props); 7 | this.state = { 8 | editedValue: props.value || '', 9 | isEdited: false, 10 | focus: false, 11 | }; 12 | this.textInput = createRef(); 13 | this.handleChange = this.handleChange.bind(this); 14 | this.handleSubmit = this.handleSubmit.bind(this); 15 | this.handleKeyPress = this.handleKeyPress.bind(this); 16 | this.handleKeyDown = this.handleKeyDown.bind(this); 17 | this.cancelEdit = this.cancelEdit.bind(this); 18 | this.makeEditable = this.makeEditable.bind(this); 19 | } 20 | 21 | componentDidUpdate() { 22 | if (this.state.focus) { 23 | this.textInput.current.focus(); 24 | this.textInput.current.select(); 25 | } 26 | } 27 | 28 | handleChange(event) { 29 | this.setState({ 30 | editedValue: event.target.value, 31 | focus: false, 32 | }); 33 | } 34 | 35 | handleSubmit() { 36 | if (this.isEmpty()) return; 37 | this.props.onSubmit(this.state.editedValue); 38 | this.setState({ 39 | isEdited: false, 40 | focus: false, 41 | }); 42 | } 43 | 44 | handleKeyDown(event) { 45 | // For some reason escape key is not handled by key press 46 | if (event.key === 'Escape') { 47 | this.cancelEdit(); 48 | } 49 | } 50 | 51 | handleKeyPress(event) { 52 | if (event.key === 'Enter') { 53 | event.preventDefault(); 54 | this.handleSubmit(); 55 | } 56 | } 57 | 58 | cancelEdit() { 59 | this.setState({ 60 | isEdited: false, 61 | editedValue: this.props.value, 62 | focus: false, 63 | }); 64 | } 65 | 66 | makeEditable() { 67 | this.setState({ 68 | isEdited: true, 69 | focus: true, 70 | }); 71 | } 72 | 73 | isEmpty() { 74 | return this.state.editedValue === ''; 75 | } 76 | 77 | render() { 78 | const { label, value } = this.props; 79 | return ( 80 |
    81 | 82 | {this.state.isEdited ? ( 83 | 93 | ) : ( 94 |
    95 | {value ? ( 96 | value 97 | ) : ( 98 | 99 | (no value) 100 | 101 | )} 102 |    103 | 104 | 105 | 106 |
    107 | )} 108 | 109 | ); 110 | } 111 | } 112 | 113 | EditableTextField.propTypes = { 114 | onSubmit: PropTypes.func.isRequired, 115 | value: PropTypes.string, 116 | }; 117 | 118 | export default EditableTextField; 119 | -------------------------------------------------------------------------------- /src/client/components/CreateUser.jsx: -------------------------------------------------------------------------------- 1 | import { Component } from 'react'; 2 | import { connect } from 'react-redux'; 3 | 4 | import { createCouchDBUser } from '../actions/login'; 5 | 6 | class CreateUserImpl extends Component { 7 | constructor(props) { 8 | super(props); 9 | this.state = { 10 | email: '', 11 | password: '', 12 | }; 13 | this.handleChange = this.handleChange.bind(this); 14 | this.handleSubmit = this.handleSubmit.bind(this); 15 | this.handleKeyPress = this.handleKeyPress.bind(this); 16 | } 17 | 18 | handleChange(event) { 19 | this.setState({ 20 | [event.target.name]: event.target.value, 21 | }); 22 | } 23 | 24 | handleSubmit() { 25 | if (this.isEmpty()) return; 26 | this.props.createCouchDBUser(this.state.email, this.state.password); 27 | } 28 | 29 | handleKeyPress(event) { 30 | if (event.key === 'Enter') this.handleSubmit(); 31 | } 32 | 33 | isEmpty() { 34 | return this.state.email === '' || this.state.password === ''; 35 | } 36 | 37 | render() { 38 | return ( 39 |
    40 |

    Create a new user

    41 |
    42 |
    43 |
    44 |
    45 | 46 | 54 |
    55 |
    56 |
    57 |
    58 | 59 | 67 |
    68 |
    69 |
    70 | {this.props.error ? ( 71 |

    {this.props.error}

    72 | ) : ( 73 | '' 74 | )} 75 | {this.props.success ? ( 76 |

    {this.props.success}

    77 | ) : ( 78 | '' 79 | )} 80 | 88 |
    89 | 90 |
    91 | ); 92 | } 93 | } 94 | 95 | function mapStateToProps(state) { 96 | return { 97 | username: state.login.username, 98 | error: state.login.errors.createUser, 99 | success: state.login.success.createUser, 100 | }; 101 | } 102 | 103 | const CreateUser = connect(mapStateToProps, { createCouchDBUser })( 104 | CreateUserImpl, 105 | ); 106 | 107 | export default CreateUser; 108 | -------------------------------------------------------------------------------- /src/couch/doc.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const CouchError = require('../util/CouchError'); 4 | const debug = require('../util/debug')('main:doc'); 5 | 6 | const nanoMethods = require('./nano'); 7 | const util = require('./util'); 8 | const validate = require('./validate'); 9 | const { union, difference } = require('../util/array_sets.js'); 10 | 11 | const methods = { 12 | async getDocUuidFromId(id, user, type) { 13 | await this.open(); 14 | return nanoMethods.getUuidFromId(this._db, id, user, type); 15 | }, 16 | 17 | async getDocByRights(uuid, user, rights, type, options) { 18 | debug.trace('getDocByRights'); 19 | await this.open(); 20 | if (!util.isManagedDocumentType(type)) { 21 | throw new CouchError(`invalid type argument: ${type}`); 22 | } 23 | 24 | const doc = await this._db.getDocument(uuid); 25 | if (!doc) { 26 | throw new CouchError('document not found', 'not found'); 27 | } 28 | if (doc.$type !== type) { 29 | throw new CouchError( 30 | `wrong document type: ${doc.$type}. Expected: ${type}`, 31 | ); 32 | } 33 | 34 | const token = options ? options.token : null; 35 | if ( 36 | await validate.validateTokenOrRights( 37 | this, 38 | uuid, 39 | doc.$owners, 40 | rights, 41 | user, 42 | token, 43 | type, 44 | ) 45 | ) { 46 | return this._db.getDocument(uuid, options); 47 | } 48 | throw new CouchError('user has no access', 'unauthorized'); 49 | }, 50 | 51 | async addOwnersToDoc(uuid, user, owners, type, options) { 52 | debug('addOwnersToDoc (%s, %s)', uuid, user); 53 | await this.open(); 54 | owners = util.ensureOwnersArray(owners); 55 | const doc = await this.getDocByRights(uuid, user, 'owner', type, options); 56 | doc.$owners = union(doc.$owners, owners); 57 | return nanoMethods.save(this._db, doc, user); 58 | }, 59 | 60 | async removeOwnersFromDoc(uuid, user, owners, type, options) { 61 | debug.trace('removeOwnersFromDoc (%s, %s)', uuid, user); 62 | await this.open(); 63 | owners = util.ensureOwnersArray(owners); 64 | const doc = await this.getDocByRights(uuid, user, 'owner', type, options); 65 | const mainOwner = doc.$owners[0]; 66 | if (owners.includes(mainOwner)) { 67 | throw new CouchError('cannot remove primary owner', 'forbidden'); 68 | } 69 | const newArray = difference(doc.$owners.slice(1), owners); 70 | newArray.unshift(doc.$owners[0]); 71 | doc.$owners = newArray; 72 | return nanoMethods.save(this._db, doc, user); 73 | }, 74 | 75 | async getDocsAsOwner(user, type, options) { 76 | debug.trace('getDocsAsOwner'); 77 | await this.open(); 78 | options = { ...options }; 79 | if (this.isSuperAdmin(user)) { 80 | return this._db.queryView( 81 | 'documentByType', 82 | { 83 | key: type, 84 | include_docs: true, 85 | }, 86 | options, 87 | ); 88 | } else { 89 | return this._db.queryView( 90 | 'documentByOwners', 91 | { 92 | key: [type, user], 93 | include_docs: true, 94 | }, 95 | options, 96 | ); 97 | } 98 | }, 99 | }; 100 | 101 | module.exports = { 102 | methods, 103 | }; 104 | -------------------------------------------------------------------------------- /src/client/components/ChangePassword.jsx: -------------------------------------------------------------------------------- 1 | import { Component } from 'react'; 2 | import { connect } from 'react-redux'; 3 | 4 | import { changeCouchDBPassword } from '../actions/login'; 5 | 6 | class ChangePasswordImpl extends Component { 7 | constructor(props) { 8 | super(props); 9 | this.state = { 10 | oldPassword: '', 11 | newPassword: '', 12 | }; 13 | this.handleChange = this.handleChange.bind(this); 14 | this.handleSubmit = this.handleSubmit.bind(this); 15 | this.handleKeyPress = this.handleKeyPress.bind(this); 16 | } 17 | 18 | handleChange(event) { 19 | this.setState({ 20 | [event.target.name]: event.target.value, 21 | }); 22 | } 23 | 24 | handleSubmit() { 25 | if (this.isEmpty()) return; 26 | this.props.changeCouchDBPassword( 27 | this.state.oldPassword, 28 | this.state.newPassword, 29 | ); 30 | this.setState({ 31 | oldPassword: '', 32 | newPassword: '', 33 | }); 34 | } 35 | 36 | handleKeyPress(event) { 37 | if (event.key === 'Enter') this.handleSubmit(); 38 | } 39 | 40 | isEmpty() { 41 | return this.state.oldPassword === '' || this.state.newPassword === ''; 42 | } 43 | 44 | render() { 45 | return ( 46 |
    47 |

    Change password

    48 |
    49 |
    50 |
    51 |
    52 | 53 | 62 |
    63 |
    64 |
    65 |
    66 | 67 | 76 |
    77 |
    78 |
    79 | {this.props.error ? ( 80 |

    {this.props.error}

    81 | ) : ( 82 | '' 83 | )} 84 | {this.props.success ? ( 85 |

    {this.props.success}

    86 | ) : ( 87 | '' 88 | )} 89 | 97 |
    98 | 99 |
    100 | ); 101 | } 102 | } 103 | 104 | function mapStateToProps(state) { 105 | return { 106 | username: state.login.username, 107 | error: state.login.errors.changePassword, 108 | success: state.login.success.changePassword, 109 | }; 110 | } 111 | 112 | const ChangePassword = connect(mapStateToProps, { changeCouchDBPassword })( 113 | ChangePasswordImpl, 114 | ); 115 | 116 | export default ChangePassword; 117 | -------------------------------------------------------------------------------- /test/data/noRights.js: -------------------------------------------------------------------------------- 1 | import { resetDatabase } from '../utils/utils.js'; 2 | 3 | import insertDocument from './insertDocument.js'; 4 | 5 | function populate(db) { 6 | const prom = []; 7 | // Default groups 8 | prom.push( 9 | (async () => { 10 | const doc = await db.getDocument('defaultGroups'); 11 | await db.insertDocument({ 12 | _id: 'defaultGroups', 13 | _rev: doc._rev, 14 | $type: 'db', 15 | anonymous: ['defaultAnonymousRead', 'inexistantGroup'], 16 | anyuser: ['defaultAnyuserRead'], 17 | }); 18 | })(), 19 | ); 20 | 21 | // Groups 22 | prom.push( 23 | insertDocument(db, { 24 | $type: 'group', 25 | $owners: ['a@a.com'], 26 | name: 'groupA', 27 | users: ['a@a.com'], 28 | customUsers: ['a@a.com'], 29 | rights: ['create', 'write', 'delete', 'read'], 30 | }), 31 | ); 32 | 33 | prom.push( 34 | insertDocument(db, { 35 | $type: 'group', 36 | $owners: ['a@a.com'], 37 | name: 'groupB', 38 | users: ['b@b.com', 'c@c.com'], 39 | customUsers: ['b@b.com', 'c@c.com'], 40 | rights: ['create'], 41 | }), 42 | ); 43 | 44 | prom.push( 45 | insertDocument(db, { 46 | $type: 'group', 47 | $owners: ['a@a.com'], 48 | name: 'defaultAnonymousRead', 49 | users: [], 50 | customUsers: [], 51 | rights: ['read'], 52 | }), 53 | ); 54 | 55 | prom.push( 56 | insertDocument(db, { 57 | $type: 'group', 58 | $owners: ['a@a.com'], 59 | name: 'defaultAnyuserRead', 60 | users: [], 61 | customUsers: [], 62 | rights: ['read'], 63 | }), 64 | ); 65 | 66 | // Entries 67 | prom.push( 68 | insertDocument(db, { 69 | $type: 'entry', 70 | $owners: ['b@b.com', 'groupA', 'groupB'], 71 | $id: 'A', 72 | $content: { 73 | x: 1, 74 | }, 75 | }), 76 | ); 77 | 78 | prom.push( 79 | insertDocument(db, { 80 | $type: 'entry', 81 | $owners: ['b@b.com'], 82 | $id: 'onlyB', 83 | $content: { 84 | x: 2, 85 | }, 86 | }), 87 | ); 88 | 89 | prom.push( 90 | insertDocument(db, { 91 | $type: 'entry', 92 | $owners: ['a@a.com'], 93 | $id: 'onlyA', 94 | $content: { 95 | x: 3, 96 | }, 97 | }), 98 | ); 99 | 100 | prom.push( 101 | insertDocument(db, { 102 | $type: 'entry', 103 | $owners: ['x@x.com', 'defaultAnonymousRead'], 104 | $id: 'entryWithDefaultAnonymousRead', 105 | $content: { 106 | x: 4, 107 | }, 108 | }), 109 | ); 110 | 111 | prom.push( 112 | insertDocument(db, { 113 | $type: 'entry', 114 | $owners: ['x@x.com', 'defaultAnyuserRead'], 115 | $id: 'entryWithDefaultAnyuserRead', 116 | $content: { 117 | x: 5, 118 | }, 119 | }), 120 | ); 121 | 122 | prom.push( 123 | insertDocument(db, { 124 | $type: 'entry', 125 | $owners: ['x@x.com', 'defaultAnonymousRead', 'defaultAnyuserRead'], 126 | $id: 'entryWithDefaultMultiRead', 127 | $content: { 128 | x: 6, 129 | }, 130 | }), 131 | ); 132 | 133 | // Tokens 134 | prom.push( 135 | insertDocument(db, { 136 | $type: 'token', 137 | $kind: 'entry', 138 | $owner: 'x@x.com', 139 | $id: 'mytoken', 140 | $creationDate: 0, 141 | uuid: 'A', 142 | rights: ['read'], 143 | }), 144 | ); 145 | 146 | return Promise.all(prom); 147 | } 148 | 149 | export default async function createDatabase() { 150 | global.couch = await resetDatabase('test'); 151 | await populate(global.couch._db); 152 | } 153 | -------------------------------------------------------------------------------- /views/login.hbs: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | Login 7 | 35 | 36 | 37 | 38 | {{#if sessionMessage }} 39 |
    40 | {{sessionMessage}} 41 |
    42 | {{/if}} 43 | {{#if google }} 44 |
    45 |

    Google login

    46 |

    47 | 48 |

    49 |
    50 | {{/if }} 51 | 52 | {{#if ldap }} 53 |
    54 |

    {{ pluginConfig.ldap.title }}

    55 |

    56 |

    57 | 58 | 59 | 60 | 61 | 62 |
    63 |

    64 |
    65 | {{/if }} 66 | 67 | {{#if couchdb }} 68 |
    69 |

    {{ pluginConfig.couchdb.title }}

    70 |

    71 |

    72 | 73 | 74 | 75 | 76 | 77 |
    78 |

    79 |
    80 | {{/if }} 81 | 82 | {{#if oidc }} 83 |
    84 |

    {{ pluginConfig.oidc.title }}

    85 |

    86 | Click here to login with {{ pluginConfig.oidc.title }} 87 |

    88 |
    89 | {{/if }} 90 | 91 | 118 | 119 | 120 | --------------------------------------------------------------------------------