├── .github ├── FUNDING.yml ├── dependabot.yml └── workflows │ └── node.js.yml ├── doc └── Tuttle-OpenAPI.png ├── .gitignore ├── src ├── resources │ ├── images │ │ ├── HPTuttle-1866.jpg │ │ ├── HPTuttle-1866.png │ │ ├── gitlab.svg │ │ ├── github.svg │ │ ├── Git_icon.svg │ │ └── x.svg │ └── css │ │ ├── fore.css │ │ ├── toastify.css │ │ ├── styles.css │ │ └── vars.css ├── repo.xml.tmpl ├── expath-pkg.xml.tmpl ├── cleanup.xq ├── api.html ├── finish.xq ├── modules │ ├── vcs.xqm │ ├── config.xqm │ ├── gitlab.xqm │ ├── app.xqm │ ├── github.xqm │ └── api.xq ├── controller.xq ├── data │ └── tuttle-example-config.xml ├── content │ ├── collection.xqm │ └── callbacks.xqm └── index.html ├── test ├── main.js ├── fixtures │ ├── alt-repoxml-tuttle.xml │ ├── alt-big-repo-tuttle.xml │ ├── test.xqm │ └── alt-tuttle.xml ├── util.js ├── gitlab.js ├── github.js └── tuttle.js ├── .existdb.json ├── package.json ├── gulpfile.js └── README.md /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | github: eeditiones 2 | custom: https://www.paypal.com/paypalme/eeditiones 3 | -------------------------------------------------------------------------------- /doc/Tuttle-OpenAPI.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/eeditiones/tuttle/HEAD/doc/Tuttle-OpenAPI.png -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .idea 2 | build 3 | node_modules 4 | .vscode 5 | .DS_Store 6 | *.xar 7 | src/data/test.xml 8 | -------------------------------------------------------------------------------- /src/resources/images/HPTuttle-1866.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/eeditiones/tuttle/HEAD/src/resources/images/HPTuttle-1866.jpg -------------------------------------------------------------------------------- /src/resources/images/HPTuttle-1866.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/eeditiones/tuttle/HEAD/src/resources/images/HPTuttle-1866.png -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | updates: 3 | # Maintain dependencies for GitHub Actions 4 | - package-ecosystem: "github-actions" 5 | directory: "/" 6 | schedule: 7 | interval: weekly 8 | - package-ecosystem: npm 9 | directory: "/" 10 | schedule: 11 | interval: weekly 12 | -------------------------------------------------------------------------------- /src/resources/images/gitlab.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | -------------------------------------------------------------------------------- /test/main.js: -------------------------------------------------------------------------------- 1 | import { before, after } from 'node:test'; 2 | // the tests depend on eachother. run the in the correct order. 3 | import github from './github.js'; 4 | import gitlab from './gitlab.js'; 5 | import tuttle from './tuttle.js'; 6 | import { ensureTuttleIsInstalled, remove } from './util.js'; 7 | 8 | before(ensureTuttleIsInstalled); 9 | 10 | await github(); 11 | await gitlab(); 12 | await tuttle(); 13 | 14 | after(() => remove()); 15 | -------------------------------------------------------------------------------- /src/repo.xml.tmpl: -------------------------------------------------------------------------------- 1 | 2 | 3 | a Git Web API integration for eXist-db 4 | TEI Publisher Project Team 5 | https://github.com/eeditiones/tuttle 6 | stable 7 | GPL-3.0 8 | true 9 | application 10 | tuttle 11 | finish.xq 12 | cleanup.xq 13 | 14 | -------------------------------------------------------------------------------- /src/expath-pkg.xml.tmpl: -------------------------------------------------------------------------------- 1 | 2 | 4 | @title@ 5 | 6 | 7 | 8 | 9 | http://e-editiones.org/tuttle/callbacks 10 | callbacks.xqm 11 | 12 | 13 | http://existsolutions.com/modules/collection 14 | collection.xqm 15 | 16 | 17 | -------------------------------------------------------------------------------- /src/cleanup.xq: -------------------------------------------------------------------------------- 1 | xquery version "3.1"; 2 | 3 | declare namespace sm="http://exist-db.org/xquery/securitymanager"; 4 | 5 | (: TODO: $target is not set in cleanup phase :) 6 | declare variable $configuration-collection := "/db/apps/tuttle/data/"; 7 | declare variable $backup-collection := "/db/tuttle-backup/"; 8 | declare variable $configuration-filename := "tuttle.xml"; 9 | 10 | (: backup tuttle configuration :) 11 | if (not(xmldb:collection-available($backup-collection))) 12 | then ((: move/copy to collection :) 13 | util:log("info", "Creating configuration backup collection"), 14 | xmldb:create-collection("/db", "tuttle-backup"), 15 | sm:chmod(xs:anyURI($backup-collection), "rwxr-x---") 16 | ) 17 | else () 18 | , 19 | util:log("info", "Backing up configuration"), 20 | xmldb:move($configuration-collection, $backup-collection, $configuration-filename) 21 | -------------------------------------------------------------------------------- /test/fixtures/alt-repoxml-tuttle.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | github 5 | https://api.github.com/ 6 | tuttle-sample-data 7 | eeditiones 8 | XXX 9 | v2 10 | admin 11 | 12 | 13 | 14 | 15 | existdb.json 16 | build.xml 17 | README.md 18 | .gitignore 19 | expath-pkg.xml.tmpl 20 | repo.xml.tmpl 21 | build.properties.xml 22 | 23 | 24 | 25 | 26 | 27 | 28 | -------------------------------------------------------------------------------- /src/api.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | Tuttle - Git-integration for eXist-db 5 | 6 | 7 | 8 | 9 |
10 |
11 | 23 | 24 | 25 | -------------------------------------------------------------------------------- /.existdb.json: -------------------------------------------------------------------------------- 1 | { 2 | "servers": { 3 | "localhost": { 4 | "server": "https://127.0.0.1:8443/exist", 5 | "user": "admin", 6 | "password": "", 7 | "root": "/db/apps/tuttle" 8 | } 9 | }, 10 | "sync": { 11 | "server": "localhost", 12 | "ignore": [ 13 | ".existdb.json", 14 | ".git/**", 15 | "node_modules/**", 16 | "bower_components/**", 17 | "package*.json", 18 | ".vscode/**" 19 | ] 20 | }, 21 | "package": { 22 | "author": "TEI-publisher project team", 23 | "target": "tuttle", 24 | "description": "Synchronize data with git services", 25 | "namespace": "http://e-editiones.org/tuttle", 26 | "website": "https://github.com/eeditiones/tuttle", 27 | "status": "dev", 28 | "title": "tuttle - pull data from git APIs", 29 | "license": "GPLv3" 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /src/resources/images/github.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | -------------------------------------------------------------------------------- /test/fixtures/alt-big-repo-tuttle.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | true 6 | github 7 | https://api.github.com/ 8 | tuttle-sample-data 9 | eeditiones 10 | XXX 11 | long-history 12 | admin 13 | 14 | 15 | 16 | 17 | existdb.json 18 | build.xml 19 | README.md 20 | .gitignore 21 | expath-pkg.xml.tmpl 22 | repo.xml.tmpl 23 | build.properties.xml 24 | 25 | 31 | 32 | -------------------------------------------------------------------------------- /test/fixtures/test.xqm: -------------------------------------------------------------------------------- 1 | xquery version "3.1"; 2 | (: 3 | : custom tuttle callback function for testing 4 | :) 5 | module namespace test="//test"; 6 | 7 | (: 8 | the first argument is the collection configuration as a map 9 | 10 | example changes 11 | 12 | map { 13 | "del": [ 14 | map { "path": "fileD", "success": true() } 15 | ], 16 | "new": [ 17 | map { "path": "fileN1", "success": true() } 18 | map { "path": "fileN2", "success": true() } 19 | map { "path": "fileN3", "success": false(), "error": map{ "code": "err:XPTY0004", "description": "AAAAAAH!", "value": () } } 20 | ], 21 | "ignored": [ 22 | map { "path": "fileD" } 23 | ] 24 | } 25 | 26 | each array member in del, new and ignored is a 27 | 28 | record action-result( 29 | "path": xs:string, 30 | "success": xs:boolean, 31 | "error"?: xs:error() 32 | ) 33 | :) 34 | declare function test:test ($collection-config as map(*), $changes as map(*)) as item()* { 35 | map{ 36 | "callback": "test:test", 37 | "arguments": map{ 38 | "config": $collection-config, 39 | "changes": $changes 40 | } 41 | } 42 | }; -------------------------------------------------------------------------------- /src/finish.xq: -------------------------------------------------------------------------------- 1 | xquery version "3.1"; 2 | 3 | declare namespace sm="http://exist-db.org/xquery/securitymanager"; 4 | 5 | (: the target collection into which the app is deployed :) 6 | declare variable $target external; 7 | 8 | declare variable $configuration-collection := $target || "/data/"; 9 | declare variable $backup-collection := "/db/tuttle-backup/"; 10 | declare variable $configuration-filename := "tuttle.xml"; 11 | 12 | (: look for backed up tuttle configuration :) 13 | if (doc-available($backup-collection || $configuration-filename)) 14 | then ((: move/copy to collection :) 15 | util:log("info", "Restoring tuttle configuration from backup."), 16 | xmldb:move($backup-collection, $configuration-collection, $configuration-filename), 17 | xmldb:remove($backup-collection) 18 | ) 19 | else ((: copy example configuration when no backup was found :) 20 | util:log("info", "No previous tuttle configuration found."), 21 | xmldb:copy-resource( 22 | $configuration-collection, "tuttle-example-config.xml", 23 | $configuration-collection, $configuration-filename 24 | ) 25 | ) 26 | , 27 | (: tighten security for configuration file :) 28 | sm:chmod(xs:anyURI($configuration-collection || $configuration-filename), "rw-r-----") 29 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "tuttle", 3 | "version": "2.1.0", 4 | "description": "tuttle - a Git-integration for eXist-db", 5 | "type": "module", 6 | "scripts": { 7 | "start": "gulp", 8 | "test": "npm run build && node --test --test-reporter spec ./test/main.js", 9 | "test:watch": "node --test --watch ./test/main.js", 10 | "build": "gulp xar", 11 | "deploy": "gulp install", 12 | "copy": "gulp copyStatic" 13 | }, 14 | "repository": { 15 | "type": "git", 16 | "url": "https://github.com/eeditiones/tuttle" 17 | }, 18 | "license": "GPL-3.0", 19 | "devDependencies": { 20 | "@existdb/gulp-exist": "^5.0.0", 21 | "@existdb/gulp-replace-tmpl": "^1.0.4", 22 | "axios": "^1.10.0", 23 | "delete": "^1.1.0", 24 | "gulp": "^5.0.1", 25 | "gulp-rename": "^2.1.0", 26 | "gulp-zip": "^6.1.0", 27 | "slimdom": "^4.3.5" 28 | }, 29 | "private": true, 30 | "app": { 31 | "author": "TEI-publisher project team", 32 | "abbrev": "tuttle", 33 | "description": "Synchronize data with git services", 34 | "namespace": "http://e-editiones.org/tuttle", 35 | "website": "https://github.com/eeditiones/tuttle", 36 | "status": "dev", 37 | "title": "tuttle - pull data from git APIs", 38 | "license": "GPLv3" 39 | } 40 | } 41 | -------------------------------------------------------------------------------- /src/resources/images/Git_icon.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 6 | 7 | 14 | 15 | 16 | -------------------------------------------------------------------------------- /test/fixtures/alt-tuttle.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | github 5 | https://api.github.com/ 6 | tuttle-sample-data 7 | eeditiones 8 | XXX 9 | nonexistent 10 | admin 11 | 12 | 13 | 14 | 15 | gitlab 16 | https://gitlab.com/api/v4/ 17 | tuttle-sample-data 18 | XXX 19 | master 20 | admin 21 | 22 | 23 | 24 | 25 | 26 | 27 | existdb.json 28 | build.xml 29 | README.md 30 | .gitignore 31 | expath-pkg.xml.tmpl 32 | repo.xml.tmpl 33 | build.properties.xml 34 | 35 | 36 | 37 | 38 | 39 | 40 | -------------------------------------------------------------------------------- /.github/workflows/node.js.yml: -------------------------------------------------------------------------------- 1 | # This workflow will do a clean install of node dependencies, build the source code and run tests across different versions of node 2 | # For more information see: https://help.github.com/actions/language-and-framework-guides/using-nodejs-with-github-actions 3 | 4 | name: Test 5 | 6 | on: 7 | push: 8 | schedule: 9 | # weekly runs mondays at 08:40 10 | - cron: "40 8 * * 1" 11 | 12 | 13 | jobs: 14 | build: 15 | runs-on: ubuntu-latest 16 | continue-on-error: ${{ matrix.experimental }} 17 | strategy: 18 | fail-fast: false 19 | matrix: 20 | exist-version: [release, 5.5.1] 21 | experimental: [false] 22 | include: 23 | - exist-version: latest 24 | experimental: true 25 | services: 26 | # Label used to access the service container 27 | exist: 28 | env: 29 | tuttle_token_tuttle_sample_data: ${{ secrets.TUTTLE_TEST_TOKEN }} 30 | tuttle_token_tuttle_sample_gitlab: ${{ secrets.GITLAB_READ_TOKEN }} 31 | image: existdb/existdb:${{ matrix.exist-version }} 32 | ports: 33 | - 8443:8443 34 | volumes: 35 | # point autodeploy to a folder without any XARs 36 | - ${{ github.workspace }}/doc:/exist/autodeploy 37 | options: >- 38 | --health-interval 4s 39 | steps: 40 | - uses: actions/checkout@v4 41 | - uses: actions/setup-node@v4 42 | with: 43 | node-version: 22 44 | - run: npm ci 45 | - name: run tests 46 | run: npm test 47 | 48 | -------------------------------------------------------------------------------- /src/resources/css/fore.css: -------------------------------------------------------------------------------- 1 | @import 'toastify.css'; 2 | [unresolved]{ 3 | display: none; 4 | } 5 | [disabled] { 6 | pointer-events: none; 7 | cursor: default; 8 | } 9 | fx-trigger a[disabled] { 10 | color:lightgrey; 11 | } 12 | fx-trigger img[disabled]{ 13 | filter:blur(2px); 14 | } 15 | 16 | .error{ 17 | background: var(--paper-red-500); 18 | } 19 | fx-alert{ 20 | color:darkred; 21 | font-size: 0.9rem; 22 | } 23 | 24 | fx-model, fx-model *, fx-model ::slotted(fx-instance), fx-instance{ 25 | display:none; 26 | } 27 | 28 | fx-control, fx-trigger{ 29 | white-space: nowrap; 30 | position: relative; 31 | } 32 | fx-hint{ 33 | display: none; 34 | } 35 | fx-repeatitem{ 36 | position:relative; 37 | /* 38 | opacity:1; 39 | -webkit-transition: opacity 3s; 40 | -moz-transition: opacity 3s; 41 | transition: opacity 3s; 42 | */ 43 | } 44 | .hidden { 45 | visibility: hidden; 46 | opacity: 0; 47 | transition: visibility 0s 2s, opacity 2s linear; 48 | } 49 | /* 50 | .visible { 51 | visibility: visible; 52 | opacity: 1; 53 | transition: opacity 2s linear; 54 | } 55 | */ 56 | 57 | .required:after { 58 | content: '*'; 59 | color: red; 60 | padding-left: 4px; 61 | right:3px; 62 | top:3px; 63 | z-index: 1; 64 | } 65 | 66 | [required]:after { 67 | content: "*"; 68 | display: inline; 69 | color: red; 70 | } 71 | .logtree details{ 72 | padding:0.1rem 1rem; 73 | margin:0; 74 | } 75 | .logtree details summary{ 76 | 77 | } 78 | .non-relevant{ 79 | opacity: 0; 80 | height: 0; 81 | transition: opacity 1s; 82 | } 83 | .vertical label{ 84 | display: block; 85 | } 86 | 87 | -------------------------------------------------------------------------------- /src/modules/vcs.xqm: -------------------------------------------------------------------------------- 1 | 2 | module namespace vcs="http://e-editiones.org/tuttle/vcs"; 3 | 4 | (: all API adapter modules must be imported here :) 5 | import module namespace github="http://e-editiones.org/tuttle/github" at "github.xqm"; 6 | import module namespace gitlab="http://e-editiones.org/tuttle/gitlab" at "gitlab.xqm"; 7 | 8 | (: each enabled API must be listed here :) 9 | declare variable $vcs:mappings as map(*) := map { 10 | "github": map { 11 | "get-url": github:get-url#1, 12 | "get-archive": github:get-archive#2, 13 | "get-last-commit": github:get-last-commit#1, 14 | "get-specific-commit": github:get-specific-commit#2, 15 | "get-commits": github:get-commits#2, 16 | "get-all-commits": github:get-commits#1, 17 | "incremental-dry": github:incremental-dry#1, 18 | "incremental": github:incremental#1, 19 | "check-signature": github:check-signature#2 20 | }, 21 | "gitlab": map { 22 | "get-url": gitlab:get-url#1, 23 | "get-archive": gitlab:get-archive#2, 24 | "get-last-commit": gitlab:get-last-commit#1, 25 | "get-specific-commit": gitlab:get-specific-commit#2, 26 | "get-commits": gitlab:get-commits#2, 27 | "get-all-commits": gitlab:get-commits#1, 28 | "incremental-dry": gitlab:incremental-dry#1, 29 | "incremental": gitlab:incremental#1, 30 | "check-signature": gitlab:check-signature#2 31 | } 32 | }; 33 | 34 | declare variable $vcs:supported-services as xs:string+ := map:keys($vcs:mappings); 35 | 36 | declare function vcs:is-known-service ($vcs as xs:string?) as xs:boolean { 37 | exists($vcs) and $vcs = $vcs:supported-services 38 | }; 39 | 40 | declare function vcs:get-actions ($vcs as xs:string?) as map(*)? { 41 | if (vcs:is-known-service($vcs)) 42 | then $vcs:mappings?($vcs) 43 | else error((), "Unknown VCS: '" || $vcs || "'") 44 | }; 45 | -------------------------------------------------------------------------------- /src/resources/css/toastify.css: -------------------------------------------------------------------------------- 1 | /*! 2 | * Toastify js 1.11.0 3 | * https://github.com/apvarun/toastify-js 4 | * @license MIT licensed 5 | * 6 | * Copyright (C) 2018 Varun A P 7 | */ 8 | 9 | .toastify { 10 | padding: 12px 20px; 11 | color: #ffffff; 12 | display: inline-block; 13 | box-shadow: 0 3px 6px -1px rgba(0, 0, 0, 0.12), 0 10px 36px -4px rgba(77, 96, 232, 0.3); 14 | background: -webkit-linear-gradient(315deg, #73a5ff, #5477f5); 15 | background: linear-gradient(135deg, #73a5ff, #5477f5); 16 | position: fixed; 17 | opacity: 0; 18 | transition: all 0.4s cubic-bezier(0.215, 0.61, 0.355, 1); 19 | border-radius: 2px; 20 | cursor: pointer; 21 | text-decoration: none; 22 | max-width: calc(50% - 20px); 23 | z-index: 2147483647; 24 | } 25 | 26 | .toastify.on { 27 | opacity: 1; 28 | } 29 | 30 | .toast-close { 31 | opacity: 0.8; 32 | padding: 0 5px; 33 | } 34 | 35 | .toastify-right { 36 | right: 15px; 37 | } 38 | .toastify-right .toast-close { 39 | margin-right:-10px; 40 | } 41 | 42 | .toastify-left { 43 | left: 15px; 44 | } 45 | .toastify-left .toast-close{ 46 | margin-left:-10px; 47 | } 48 | 49 | .toastify-top { 50 | top: -150px; 51 | } 52 | 53 | .toastify-bottom { 54 | bottom: -150px; 55 | } 56 | 57 | .toastify-rounded { 58 | border-radius: 25px; 59 | } 60 | 61 | .toastify-avatar { 62 | width: 1.5em; 63 | height: 1.5em; 64 | margin: -7px 5px; 65 | border-radius: 2px; 66 | } 67 | 68 | .toastify-center { 69 | margin-left: auto; 70 | margin-right: auto; 71 | left: 0; 72 | right: 0; 73 | max-width: fit-content; 74 | max-width: -moz-fit-content; 75 | } 76 | 77 | @media only screen and (max-width: 360px) { 78 | .toastify-right, .toastify-left { 79 | margin-left: auto; 80 | margin-right: auto; 81 | left: 0; 82 | right: 0; 83 | max-width: fit-content; 84 | } 85 | } 86 | -------------------------------------------------------------------------------- /src/controller.xq: -------------------------------------------------------------------------------- 1 | xquery version "3.0"; 2 | import module namespace login="http://exist-db.org/xquery/login" at "resource:org/exist/xquery/modules/persistentlogin/login.xql"; 3 | 4 | declare variable $exist:path external; 5 | declare variable $exist:resource external; 6 | declare variable $exist:controller external; 7 | declare variable $exist:prefix external; 8 | declare variable $exist:root external; 9 | 10 | declare variable $is-get := lower-case(request:get-method()) eq 'get'; 11 | 12 | if ($is-get and $exist:path eq "") then 13 | 14 | 15 | 16 | 17 | else if ($is-get and $exist:path eq "/") then 18 | 19 | 20 | 21 | 22 | 23 | 24 | else if ($exist:resource eq 'login') then 25 | let $loggedIn := login:set-user("org.exist.login", (), false()) 26 | let $user := request:get-attribute("org.exist.login.user") 27 | return ( 28 | util:declare-option("exist:serialize", "method=json"), 29 | try { 30 | 31 | {$user} 32 | { 33 | if ($user) then ( 34 | for $item in sm:get-user-groups($user) return {$item}, 35 | {sm:is-dba($user)} 36 | ) else 37 | () 38 | } 39 | 40 | } catch * { 41 | response:set-status-code(401), 42 | {$err:description} 43 | } 44 | ) 45 | 46 | (: static HTML page for API documentation should be served directly to make sure it is always accessible :) 47 | else if ($is-get and $exist:path = ("/api.html", "/api.json")) then 48 | 49 | 50 | (: serve static resources :) 51 | else if ($is-get and matches($exist:path, "^/(css|js|images)/[^/]+\.(css|js(\.map)?|svg|jpg|png)$")) then 52 | 53 | 54 | 55 | 56 | 57 | 58 | 59 | (: all other requests are passed on the Open API router :) 60 | else 61 | 62 | 63 | 64 | 65 | 66 | 67 | 68 | 69 | 70 | -------------------------------------------------------------------------------- /src/data/tuttle-example-config.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 7 | true 8 | 9 | 10 | github 11 | https://api.github.com/ 12 | 13 | 14 | eeditiones 15 | tuttle-sample-data 16 | 17 | 18 | next 19 | 20 | 22 | XXX 23 | 24 | 25 | admin 26 | 27 | 28 | 33 | 34 | 35 | 38 | 41 | 42 | 43 | 44 | 45 | gitlab 46 | https://gitlab.com/api/v4/ 47 | 48 | 49 | line-o 50 | tuttle-sample-data 51 | 52 | 53 | 50872175 54 | 55 | 56 | main 57 | 58 | 60 | XXX 61 | 62 | 63 | admin 64 | 65 | 66 | 67 | 68 | 69 | 70 | existdb.json 71 | build.xml 72 | README.md 73 | .gitignore 74 | expath-pkg.xml.tmpl 75 | repo.xml.tmpl 76 | build.properties.xml 77 | 78 | 79 | 80 | 81 | 85 | 86 | 87 | -------------------------------------------------------------------------------- /gulpfile.js: -------------------------------------------------------------------------------- 1 | /** 2 | * an example gulpfile to make ant-less existdb package builds a reality 3 | */ 4 | import { src, dest, watch, series, parallel, lastRun } from 'gulp' 5 | import { createClient } from '@existdb/gulp-exist' 6 | import replace from '@existdb/gulp-replace-tmpl' 7 | import zip from 'gulp-zip' 8 | import rename from 'gulp-rename' 9 | import del from 'delete' 10 | 11 | import pkg from './package.json' with { type: 'json' } 12 | const { app, version, license } = pkg 13 | const replacements = [app, { version, license }] 14 | 15 | const packageUri = app.namespace 16 | 17 | // read metadata from .existdb.json 18 | import existJSON from './.existdb.json' with { type: 'json' } 19 | const serverInfo = existJSON.servers.localhost 20 | const url = new URL(serverInfo.server) 21 | const connectionOptions = { 22 | host: url.hostname, 23 | port: url.port, 24 | secure: url.protocol === 'https:', 25 | basic_auth: { 26 | user: serverInfo.user, 27 | pass: serverInfo.password 28 | } 29 | } 30 | const existClient = createClient(connectionOptions); 31 | 32 | /** 33 | * Use the `delete` module directly, instead of using gulp-rimraf 34 | */ 35 | function clean (cb) { 36 | del(['build', 'dist'], cb); 37 | } 38 | 39 | /** 40 | * replace placeholders 41 | * in src/repo.xml.tmpl and 42 | * output to build/repo.xml 43 | */ 44 | function templates () { 45 | return src('src/*.tmpl') 46 | .pipe(replace(replacements, {unprefixed:true})) 47 | .pipe(rename(path => { path.extname = "" })) 48 | .pipe(dest('build/')) 49 | } 50 | 51 | function watchTemplates () { 52 | watch('src/*.tmpl', series(templates)) 53 | } 54 | 55 | 56 | const staticFiles = 'src/**/*.{xml,html,xq,xqm,xsl,xconf,json,svg,js,css,map}' 57 | const images = 'src/**/*.{png,jpg}' 58 | 59 | /** 60 | * copy html templates, XSL stylesheet, XMLs and XQueries to 'build' 61 | */ 62 | function copyStatic () { 63 | return src(staticFiles).pipe(dest('build')) 64 | } 65 | 66 | function watchStatic () { 67 | watch(staticFiles, series(copyStatic)); 68 | } 69 | 70 | /** 71 | * copy images to 'build' 72 | */ 73 | function copyImages () { 74 | return src(images, {encoding: false}).pipe(dest('build')) 75 | } 76 | 77 | function watchImages () { 78 | watch(images, series(copyImages)); 79 | } 80 | 81 | /** 82 | * Upload all files in the build folder to existdb. 83 | * This function will only upload what was changed 84 | * since the last run (see gulp documentation for lastRun). 85 | */ 86 | function deployApp () { 87 | return src('build/**/*', { 88 | base: 'build/', 89 | since: lastRun(deploy) 90 | }) 91 | .pipe(existClient.dest({target})) 92 | } 93 | 94 | function watchBuild () { 95 | watch('build/**/*', series(deploy)) 96 | } 97 | 98 | // construct the current xar name from available data 99 | const xarFilename = `${app.abbrev}-${version}.xar` 100 | 101 | /** 102 | * create XAR package in repo root 103 | */ 104 | function createXar () { 105 | return src('build/**/*', {base: 'build/', encoding: false}) 106 | .pipe(zip(xarFilename)) 107 | .pipe(dest('dist')) 108 | } 109 | 110 | /** 111 | * upload and install the latest built XAR 112 | */ 113 | function installXar () { 114 | return src(xarFilename, {cwd:'dist/', encoding: false}) 115 | .pipe(existClient.install({ packageUri })) 116 | } 117 | 118 | 119 | // composed tasks 120 | const build = series( 121 | clean, 122 | templates, 123 | copyStatic, 124 | copyImages 125 | ) 126 | const deploy = series(build, deployApp) 127 | const watchAll = parallel( 128 | watchStatic, 129 | watchImages, 130 | watchTemplates, 131 | watchBuild 132 | ) 133 | 134 | const xar = series(build, createXar) 135 | const install = series(build, xar, installXar) 136 | 137 | export { 138 | clean, 139 | templates, 140 | watchTemplates, 141 | copyStatic, 142 | watchStatic, 143 | copyImages, 144 | watchImages, 145 | build, 146 | deploy, 147 | xar, 148 | install, 149 | watchAll as watch, 150 | } 151 | 152 | // main task for day to day development 153 | export default series(build, deploy, watchAll) -------------------------------------------------------------------------------- /src/content/collection.xqm: -------------------------------------------------------------------------------- 1 | xquery version '3.1'; 2 | 3 | module namespace collection="http://existsolutions.com/modules/collection"; 4 | 5 | import module namespace sm="http://exist-db.org/xquery/securitymanager"; 6 | import module namespace xmldb="http://exist-db.org/xquery/xmldb"; 7 | 8 | (:~ 9 | : create arbitrarily deep-nested sub-collection 10 | : @param $new-collection absolute path that starts with "/db" 11 | : the string can have a slash at the end 12 | : @returns map(*) with xs:boolean success, xs:string path and xs:string error, 13 | : if something went wrong error contains the description and path is 14 | : the collection where the error occurred 15 | ~:) 16 | declare 17 | function collection:create ($path as xs:string) as map(*) { 18 | if (not(starts-with($path, '/db'))) 19 | then ( 20 | map { 21 | 'success': false(), 22 | 'path': $path, 23 | 'error': 'New collection must start with /db' 24 | } 25 | ) 26 | else ( 27 | fold-left( 28 | tail(tokenize($path, '/')), 29 | map { 'success': true(), 'path': '' }, 30 | collection:fold-collections#2 31 | ) 32 | ) 33 | }; 34 | 35 | declare 36 | %private 37 | function collection:fold-collections ($result as map(*), $next as xs:string*) as map(*) { 38 | let $path := concat($result?path, '/', $next) 39 | 40 | return 41 | if (not($result?success)) 42 | then ($result) 43 | else if (xmldb:collection-available($path)) 44 | then (map { 'success': true(), 'path': $path }) 45 | else ( 46 | try { 47 | map { 48 | 'success': exists(xmldb:create-collection($result?path, $next)), 49 | 'path': $path 50 | } 51 | } 52 | catch * { 53 | map { 54 | 'success': false(), 55 | 'path': $path, 56 | 'error': $err:description 57 | } 58 | } 59 | ) 60 | }; 61 | 62 | declare function collection:remove($path as xs:string, $force as xs:boolean) { 63 | if (not(xmldb:collection-available($path))) 64 | then true() 65 | else if ($force) 66 | then xmldb:remove($path) 67 | else if (not(empty(( 68 | xmldb:get-child-resources($path), 69 | xmldb:get-child-collections($path) 70 | )))) 71 | then error(xs:QName("collection:not-empty"), "Collection '" || $path || "' is not empty and $force is false().") 72 | else xmldb:remove($path) 73 | }; 74 | 75 | (:~ 76 | : Scan a collection tree recursively starting at $root. Call the supplied function once for each 77 | : resource encountered. The first parameter to $func is the collection URI, the second the resource 78 | : path (including the collection part). 79 | :) 80 | declare function collection:scan($root as xs:string, $func as function(xs:string, xs:string?) as item()*) { 81 | collection:scan-collection($root, $func) 82 | }; 83 | 84 | (:~ Scan a collection tree recursively starting at $root. Call $func once for each collection found :) 85 | declare %private function collection:scan-collection($collection as xs:string, $func as function(xs:string, xs:string?) as item()*) { 86 | $func($collection, ()), 87 | collection:scan-resources($collection, $func), 88 | for $child-collection in xmldb:get-child-collections($collection) 89 | let $path := concat($collection, "/", $child-collection) 90 | return 91 | if (sm:has-access(xs:anyURI($path), "rx")) then ( 92 | collection:scan-collection($path, $func) 93 | ) else () 94 | }; 95 | 96 | (:~ 97 | : List all resources contained in a collection and call the supplied function once for each 98 | : resource with the complete path to the resource as parameter. 99 | :) 100 | declare %private function collection:scan-resources($collection as xs:string, $func as function(xs:string, xs:string?) as item()*) { 101 | for $child-resource in xmldb:get-child-resources($collection) 102 | let $path := concat($collection, "/", $child-resource) 103 | return 104 | if (sm:has-access(xs:anyURI($path), "r")) then ( 105 | $func($collection, $path) 106 | ) else () 107 | }; 108 | -------------------------------------------------------------------------------- /test/util.js: -------------------------------------------------------------------------------- 1 | import { Agent } from 'node:https'; 2 | import { readFile, readdir } from 'node:fs/promises'; 3 | import { join, basename } from 'node:path'; 4 | import { after, before } from 'node:test'; 5 | 6 | import axios from 'axios'; 7 | import { connect } from '@existdb/node-exist'; 8 | 9 | import existJson from '../.existdb.json' with { type: 'json' }; 10 | const { user, password, server } = existJson.servers.localhost; 11 | 12 | import pkg from '../package.json' with { type: 'json' }; 13 | const { namespace } = pkg.app; 14 | 15 | // for use in custom controller tests 16 | const adminCredentials = { username: user, password }; 17 | 18 | // read connction options from ENV 19 | if (process.env.EXISTDB_USER && 'EXISTDB_PASS' in process.env) { 20 | adminCredentials.username = process.env.EXISTDB_USER; 21 | adminCredentials.password = process.env.EXISTDB_PASS; 22 | } 23 | 24 | const testServer = 'EXISTDB_SERVER' in process.env ? process.env.EXISTDB_SERVER : server; 25 | 26 | const { origin, hostname, port, protocol } = new URL(testServer); 27 | 28 | const axiosInstanceOptions = { 29 | baseURL: `${origin}/exist/apps/tuttle`, 30 | headers: { Origin: origin }, 31 | withCredentials: true, 32 | }; 33 | 34 | const rejectUnauthorized = !(hostname === 'localhost' || hostname === '127.0.0.1'); 35 | const secure = protocol === 'https:'; 36 | 37 | if (secure) { 38 | axiosInstanceOptions.httpsAgent = new Agent({ rejectUnauthorized }); 39 | } 40 | 41 | const axiosInstance = axios.create(axiosInstanceOptions); 42 | 43 | const db = connect({ 44 | host: hostname, 45 | port, 46 | secure, 47 | rejectUnauthorized, 48 | basic_auth: { 49 | user: adminCredentials.username, 50 | pass: adminCredentials.password, 51 | }, 52 | }); 53 | 54 | async function putResource(buffer, path) { 55 | const fh = await db.documents.upload(buffer); 56 | return await db.documents.parseLocal(fh, path, {}); 57 | } 58 | 59 | function getResourceInfo(resource) { 60 | return db.resources.describe(resource); 61 | } 62 | 63 | /** 64 | * @param {string} resource 65 | * @returns {Promise} 66 | */ 67 | function getResource(resource) { 68 | return db.documents.read(resource, {}); 69 | } 70 | 71 | async function install() { 72 | const matches = (await readdir('dist')).filter((entry) => entry.endsWith('.xar')); 73 | 74 | if (matches.length > 1) { 75 | throw new Error(`Multiple tuttle versions: ${matches}`); 76 | } 77 | if (matches.length === 0) { 78 | throw new Error(`No tuttle.xar found. Run 'npm build' before running tests`); 79 | } 80 | 81 | const xarFile = join('dist', matches[0]); 82 | const xarContents = await readFile(xarFile); 83 | const xarName = basename(xarFile); 84 | console.log('Uploading tuttle'); 85 | await db.app.upload(xarContents, xarName); 86 | console.log('Uploaded tuttle, installing'); 87 | 88 | await db.app.install(xarName); 89 | } 90 | 91 | async function remove() { 92 | console.log('removing'); 93 | await db.app.remove(namespace); 94 | 95 | const result = await Promise.allSettled([ 96 | db.collections.remove('/db/tuttle-backup'), 97 | db.collections.remove('/db/pkgtmp'), 98 | 99 | db.collections.remove('/db/apps/tuttle-sample-data'), 100 | db.collections.remove('/db/apps/tuttle-sample-gitlab'), 101 | ]); 102 | 103 | if (result.some((r) => r.status === 'rejected')) { 104 | console.warn( 105 | 'clean up failed', 106 | result.filter((r) => r.status === 'rejected').map((r) => r.reason), 107 | ); 108 | } 109 | } 110 | 111 | /** 112 | * @type {Promise void>} 113 | */ 114 | let tuttleInstallationPromise = null; 115 | let tuttleIsInstalled = false; 116 | 117 | async function ensureTuttleIsInstalled() { 118 | if (!tuttleInstallationPromise) { 119 | console.log('installing tuttle', tuttleInstallationPromise, tuttleIsInstalled); 120 | 121 | tuttleInstallationPromise = remove().then(() => install()); 122 | await tuttleInstallationPromise; 123 | tuttleIsInstalled = true; 124 | return; 125 | } 126 | console.log('Not installing tuttle twice'); 127 | 128 | console.log('Waiting until tuttle install is done'); 129 | await tuttleInstallationPromise; 130 | console.log('done installing tuttle'); 131 | } 132 | 133 | export { 134 | axiosInstance as axios, 135 | adminCredentials as auth, 136 | getResourceInfo, 137 | putResource, 138 | getResource, 139 | ensureTuttleIsInstalled, 140 | remove, 141 | install, 142 | }; 143 | -------------------------------------------------------------------------------- /src/resources/images/x.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/content/callbacks.xqm: -------------------------------------------------------------------------------- 1 | xquery version "3.1"; 2 | (:~ 3 | Default callbacks 4 | 5 | callbacks must have the signature 6 | function(map(*), map(*)) as item()* 7 | :) 8 | module namespace cb="http://e-editiones.org/tuttle/callbacks"; 9 | 10 | import module namespace collection="http://existsolutions.com/modules/collection"; 11 | 12 | declare namespace expath="http://expath.org/ns/pkg"; 13 | 14 | declare variable $cb:package-descriptor := "expath-pkg.xml"; 15 | declare variable $cb:package-meta-files := ("expath-pkg.xml", "repo.xml", "exist.xml"); 16 | 17 | declare variable $cb:temp-collection := "/db/system/repo"; 18 | 19 | (:~ 20 | : example callback 21 | : It will just return the arguments the function is called with 22 | : for documentation and testing purposes 23 | : 24 | : the first argument is the collection configuration as a map 25 | : the second argument is a report of the changes that were applied 26 | : example changes 27 | 28 | map { 29 | "del": [ 30 | map { "path": "fileD", "success": true() } 31 | ], 32 | "new": [ 33 | map { "path": "fileN1", "success": true() } 34 | map { "path": "fileN2", "success": true() } 35 | map { "path": "fileN3", "success": false(), "error": map{ "code": "err:XPTY0004", "description": "AAAAAAH!", "value": () } } 36 | ], 37 | "ignored": [ 38 | map { "path": "fileD" } 39 | ] 40 | } 41 | 42 | : each array member in del, new and ignored is a 43 | 44 | record action-result( 45 | "path": xs:string, 46 | "success": xs:boolean, 47 | "error"?: xs:error() 48 | ) 49 | :) 50 | declare function cb:test ($collection-config as map(*), $changes as map(*)) { 51 | map{ 52 | "callback": "cb:test", 53 | "arguments": map{ 54 | "config": $collection-config, 55 | "changes": $changes 56 | } 57 | } 58 | }; 59 | 60 | (:~ 61 | : Scan an array like ?new ?del or ?ignored you can find out if it contains a specific path(s) 62 | : if given more than one path then you need to think of them as being combined with or 63 | : Example: 64 | : to check if the repo.xml was added or changed do 65 | : cb:changes-array-contains-path($changes?new, "repo.xml") 66 | :) 67 | declare function cb:changes-array-contains-path($array as array(*), $path as xs:string+) as xs:boolean { 68 | $path = $array?*?path 69 | }; 70 | 71 | (:~ 72 | : Scan the changeset for an updated expath-pkg.xml 73 | : update the version that exist-db reports for this package by 74 | : "installing" a stub 75 | :) 76 | declare function cb:check-version ($collection-config as map(*), $changes as map(*)) as xs:string { 77 | if (not(cb:changes-array-contains-path($changes?new, $cb:package-descriptor))) then ( 78 | "Descriptor unchanged" 79 | ) else if (not(doc-available($collection-config?path || "/" || $cb:package-descriptor))) then ( 80 | error(xs:QName("cb:descriptor-missing"), "Package descriptor does not exist even though it was updated") 81 | ) else ( 82 | let $expath-package-meta := doc($collection-config?path || "/" || $cb:package-descriptor)/expath:package 83 | let $new-version := $expath-package-meta/@version/string() 84 | let $old-version := cb:installed-version($expath-package-meta/@name) 85 | 86 | return 87 | if ($new-version eq $old-version) then ( 88 | "Version unchanged" 89 | ) else if ($old-version) then ( 90 | ( 91 | repo:remove($expath-package-meta/@name), 92 | cb:update-package-version($collection-config?path, $expath-package-meta), 93 | "Updated from " || $old-version || " to " || $new-version 94 | )[3] 95 | ) else ( 96 | cb:update-package-version($collection-config?path, $expath-package-meta), 97 | "Version set to " || $new-version 98 | ) 99 | ) 100 | }; 101 | 102 | 103 | declare function cb:update-package-version ($target-collection as xs:string, $expath-package-meta as element(expath:package)) { 104 | let $package-name := $expath-package-meta/@name/string() 105 | let $stub-name := concat($expath-package-meta/@abbrev, "-", $expath-package-meta/@version, "__stub.xar") 106 | let $xar := cb:create-stub-package($target-collection, $stub-name) 107 | 108 | return cb:install-stub-package($stub-name) 109 | }; 110 | 111 | declare function cb:installed-version($package-name as xs:string) as xs:string? { 112 | if (not($package-name = repo:list())) then ( 113 | ) else ( 114 | parse-xml( 115 | util:binary-to-string( 116 | repo:get-resource($package-name, $cb:package-descriptor))) 117 | /expath:package/@version/string() 118 | ) 119 | }; 120 | 121 | declare %private function cb:create-stub-package($collection as xs:string, $filename as xs:string) { 122 | (: ensure temp collection exists :) 123 | let $_ := collection:create($cb:temp-collection) 124 | let $contents := compression:zip(cb:resources-to-zip($collection), true(), $collection) 125 | return 126 | xmldb:store($cb:temp-collection, $filename, $contents, "application/zip") 127 | }; 128 | 129 | declare %private function cb:resources-to-zip($collection as xs:string) { 130 | for $resource in ( 131 | xmldb:get-child-resources($collection)[. = $cb:package-meta-files or starts-with(., "icon")] 132 | ) 133 | return 134 | xs:anyURI($collection || "/" || $resource) 135 | }; 136 | 137 | declare %private function cb:install-stub-package($xar-name as xs:string) { 138 | repo:install-from-db($cb:temp-collection || "/" || $xar-name), 139 | xmldb:remove($cb:temp-collection, $xar-name) 140 | }; 141 | -------------------------------------------------------------------------------- /test/gitlab.js: -------------------------------------------------------------------------------- 1 | import { axios, auth, getResourceInfo, ensureTuttleIsInstalled } from './util.js'; 2 | import { describe, it, before } from 'node:test'; 3 | import assert from 'node:assert'; 4 | 5 | export default () => 6 | describe('Gitlab', async function () { 7 | before(async () => { 8 | await ensureTuttleIsInstalled(); 9 | }); 10 | const testHASH = '79789e5c4842afaaa63c733c3ed6babe37f70121'; 11 | const collection = 'tuttle-sample-gitlab'; 12 | 13 | it('Remove lockfile', async function () { 14 | const resultPromise = axios.post(`git/${collection}/lockfile`, {}, { auth }); 15 | await assert.doesNotReject(resultPromise, 'The request should succeed'); 16 | const res = await resultPromise; 17 | assert.strictEqual(res.status, 200); 18 | }); 19 | 20 | it('Get changelog', async function () { 21 | const resultPromise = axios.get(`git/${collection}/commits`, { auth }); 22 | await assert.doesNotReject(resultPromise, 'The request should succeed'); 23 | const res = await resultPromise; 24 | assert.strictEqual(res.status, 200); 25 | // console.log(res.data) 26 | assert(res.data.commits.length > 2); 27 | }); 28 | 29 | it('Pull ' + testHASH + ' into staging collection', async function () { 30 | const resultPromise = axios.get(`git/${collection}?hash=${testHASH}`, { auth }); 31 | await assert.doesNotReject(resultPromise, 'The request should succeed'); 32 | const res = await resultPromise; 33 | 34 | assert.strictEqual(res.status, 200); 35 | assert.deepStrictEqual(res.data, { 36 | message: 'success', 37 | collection: `/db/apps/${collection}-stage`, 38 | hash: testHASH, 39 | }); 40 | }); 41 | 42 | it('Deploy staging to target collection', async function () { 43 | const resultPromise = axios.post(`git/${collection}`, {}, { auth }); 44 | await assert.doesNotReject(resultPromise, 'The request should succeed'); 45 | const res = await resultPromise; 46 | assert.strictEqual(res.status, 200); 47 | assert.strictEqual(res.data.message, 'success'); 48 | }); 49 | 50 | it('Check Hashes', async function () { 51 | const resultPromise = axios.get(`git/${collection}/hash`, { auth }); 52 | await assert.doesNotReject(resultPromise, 'the request should succeed'); 53 | const res = await resultPromise; 54 | 55 | assert.strictEqual(res.status, 200); 56 | assert.strictEqual(res.data['local-hash'], testHASH); 57 | }); 58 | 59 | describe('Incremental update', async function () { 60 | let newFiles; 61 | let delFiles; 62 | 63 | describe('can do a dry run', async function () { 64 | let dryRunResponse 65 | before(async function () { 66 | const resultPromise = axios.post( 67 | `git/${collection}/incremental?dry=true`, 68 | {}, 69 | { auth }, 70 | ); 71 | await assert.doesNotReject(resultPromise, 'The request should succeed'); 72 | dryRunResponse = await resultPromise; 73 | }) 74 | 75 | it('Succeeds', function () { 76 | assert.strictEqual(dryRunResponse.status, 200); 77 | assert.strictEqual(dryRunResponse.data.message, 'dry-run'); 78 | }); 79 | 80 | it('Returns a list of new resources', async function () { 81 | newFiles = await Promise.all( 82 | dryRunResponse.data.changes.new.map(async (resource) => { 83 | const resourceInfo = await getResourceInfo( 84 | `/db/apps/${collection}/${resource.path}`, 85 | ); 86 | return [resource, resourceInfo.modified]; 87 | }), 88 | ); 89 | // console.log('files to fetch', newFiles) 90 | 91 | assert.strictEqual(newFiles.length, 3); 92 | assert.strictEqual(newFiles[0][0].path, 'data/F-aww.xml'); 93 | assert(newFiles[0][1] instanceof Date); 94 | 95 | assert.deepStrictEqual(newFiles[1], [{ path: 'data/F-tit2.xml' }, undefined]); 96 | 97 | assert.strictEqual(newFiles[2][0].path, 'data/F-ham.xml'); 98 | assert(newFiles[2][1] instanceof Date); 99 | }); 100 | 101 | it('Returns a list of resources to be deleted', async function () { 102 | delFiles = dryRunResponse.data.changes.del; 103 | 104 | assert(delFiles.length > 0); 105 | assert.deepStrictEqual(delFiles, [ 106 | { path: 'data/F-wiv.xml' }, 107 | { path: 'data/F-tit.xml' }, 108 | ]); 109 | }); 110 | }); 111 | 112 | describe('can do a run', async function () { 113 | let incrementalUpdateResponse; 114 | before(async function () { 115 | incrementalUpdateResponse = await axios.post( 116 | `git/${collection}/incremental`, 117 | {}, 118 | { auth }, 119 | ); 120 | // console.log('incrementalUpdateResponse', incrementalUpdateResponse.data) 121 | }); 122 | 123 | it('succeeds', function () { 124 | assert.strictEqual(incrementalUpdateResponse.status, 200); 125 | assert.strictEqual(incrementalUpdateResponse.data.message, 'success'); 126 | }); 127 | 128 | it('updates all changed resources', async function () { 129 | await Promise.all( 130 | newFiles.map(async (resource) => { 131 | const { modified } = await getResourceInfo( 132 | `/db/apps/${collection}/${resource[0].path}`, 133 | ); 134 | assert.notStrictEqual(modified, undefined); 135 | assert.notStrictEqual(modified, resource[1]); 136 | }), 137 | ); 138 | }); 139 | 140 | it('deletes all deleted resources', async function () { 141 | await Promise.all( 142 | delFiles.map(async (resource) => { 143 | const resourceInfo = await getResourceInfo( 144 | `/db/apps/${collection}/${resource.path}`, 145 | ); 146 | assert.deepStrictEqual(resourceInfo, {}); 147 | }), 148 | ); 149 | }); 150 | }); 151 | }); 152 | }); 153 | -------------------------------------------------------------------------------- /src/modules/config.xqm: -------------------------------------------------------------------------------- 1 | xquery version "3.1"; 2 | 3 | module namespace config="http://e-editiones.org/tuttle/config"; 4 | 5 | declare namespace repo="http://exist-db.org/xquery/repo"; 6 | 7 | (:~ 8 | : Configurtion file 9 | :) 10 | declare variable $config:tuttle-config as element(tuttle) := doc("/db/apps/tuttle/data/tuttle.xml")/tuttle; 11 | 12 | declare variable $config:default-ns := "http://e-editiones.org/tuttle/callbacks"; 13 | 14 | (:~ 15 | : Git configuration 16 | :) 17 | declare function config:collections($collection-name as xs:string) as map(*)? { 18 | let $collection-config := $config:tuttle-config/repos/collection[@name = $collection-name] 19 | let $path := config:prefix() || $collection-name 20 | 21 | return 22 | if (empty($collection-config)) 23 | then ( 24 | (: error((), "Collection config for '" || $collection || "' not found!") :) 25 | ) 26 | else map { 27 | "repo" : $collection-config/repo/string(), 28 | "owner" : $collection-config/owner/string(), 29 | "project-id" : $collection-config/project-id/string(), 30 | "ref": $collection-config/ref/string(), 31 | "collection": $collection-name, 32 | 33 | "type": $collection-config/type/string(), 34 | "baseurl": $collection-config/baseurl/string(), 35 | "hookuser": $collection-config/hookuser/string(), 36 | 37 | "path": $path, 38 | "deployed": config:deployed-sha($path), 39 | 40 | (: be careful never to expose these :) 41 | "hookpasswd": $collection-config/hookpasswd/string(), 42 | "token": config:token($collection-config) 43 | } 44 | }; 45 | 46 | (:~ 47 | : Which commit is deployed? 48 | :) 49 | declare function config:deployed-sha($path as xs:string) as xs:string? { 50 | (: @TODO: shares a lot of code with app.xqm app:read-commit-info :) 51 | if (doc-available($path || "/repo.xml")) then ( 52 | doc($path || "/repo.xml")//repo:meta/@commit-id 53 | ) else if (doc-available($path || "/gitsha.xml")) then ( 54 | doc($path || "/gitsha.xml")/hash/value 55 | ) else () 56 | }; 57 | 58 | declare function config:get-callback($config as map(*)) as function(*)? { 59 | let $collection-config := $config:tuttle-config/repos/collection[@name = $config?collection] 60 | return 61 | if (empty($collection-config/callback)) then ( 62 | ) else if (count($collection-config/callback) ne 1) then ( 63 | error( 64 | xs:QName("config:multiple-callbacks"), 65 | "More than one callback is not allowed: " || $collection-config/@name, 66 | $collection-config/callback 67 | ) 68 | ) else ( 69 | let $ns := 70 | if ($collection-config/callback/@ns) 71 | then $collection-config/callback/@ns/string() 72 | else $config:default-ns 73 | 74 | let $qname := 75 | try { 76 | QName($ns, $collection-config/callback/@name/string()) 77 | } catch * { 78 | error( 79 | xs:QName("config:callback-qname"), 80 | "Callback QName problem: " || $collection-config/@name, 81 | $collection-config/callback 82 | ) 83 | } 84 | 85 | let $import-options := 86 | map { 87 | "location-hints" : 88 | if (exists($collection-config/callback/@location)) then ( 89 | $collection-config/callback/@location/string() 90 | ) else ( 91 | "/db/apps/tuttle/content/callbacks.xqm" 92 | ) 93 | } 94 | 95 | (: get function reference :) 96 | let $module := 97 | try { 98 | fn:load-xquery-module($ns, $import-options) 99 | } catch * { 100 | error( 101 | xs:QName("config:callback-module-load"), 102 | "Problem loading the callback for collection " || $collection-config/@name, 103 | $collection-config/callback 104 | ) 105 | } 106 | 107 | return 108 | if ( 109 | (: callback must have arity 2 110 | : (map(*),map(*)) -> item()? :) 111 | map:contains($module?functions?($qname), 2) 112 | ) then ( 113 | util:log("info", ('Found callback function ', $qname, '#', 2)), 114 | $module?functions?($qname)?2 115 | ) else ( 116 | error( 117 | xs:QName("config:callback-not-found"), 118 | "Callback function could not be found in module: " || $collection-config/@name, 119 | $collection-config/callback 120 | ) 121 | ) 122 | ) 123 | }; 124 | 125 | declare %private function config:token($collection-config as element(collection)) as xs:string? { 126 | let $env-var := "tuttle_token_" || replace($collection-config/@name/string(), "-", "_") 127 | let $token-env := environment-variable($env-var) 128 | 129 | return 130 | if (exists($token-env) and $token-env ne "") then ( 131 | $token-env 132 | ) else ( 133 | $collection-config/token 134 | ) 135 | }; 136 | 137 | (:~ 138 | : List collection names 139 | :) 140 | declare function config:collection-config-available($collection as xs:string) as xs:boolean { 141 | exists($config:tuttle-config/repos/collection[@name = $collection]) 142 | }; 143 | 144 | (:~ 145 | : List collection names 146 | :) 147 | declare function config:list-collections() as xs:string* { 148 | $config:tuttle-config/repos/collection/@name/string() 149 | }; 150 | 151 | 152 | (:~ 153 | : get default collection 154 | :) 155 | declare function config:default-collection() as xs:string? { 156 | $config:tuttle-config/repos/collection[default="true"]/@name/string() 157 | }; 158 | 159 | (:~ 160 | : ignore - these files are not checkout from git and are ignored 161 | :) 162 | declare function config:ignore() as xs:string* { 163 | $config:tuttle-config/ignore/file/string() 164 | }; 165 | 166 | (:~ 167 | : Suffix of the checked out git statging collection 168 | :) 169 | declare function config:suffix() as xs:string { 170 | $config:tuttle-config/config/@suffix/string() 171 | }; 172 | 173 | (:~ 174 | : The running task is stored in the lockfile. It ensures that two tasks do not run at the same time. 175 | :) 176 | declare function config:lock() as xs:string { 177 | $config:tuttle-config/config/@lock/string() 178 | }; 179 | 180 | (:~ 181 | : Prefix for collections 182 | :) 183 | declare function config:prefix() as xs:string { 184 | $config:tuttle-config/config/@prefix/string() 185 | }; 186 | 187 | (:~ 188 | : The destination where the key for the webhook is stored. 189 | :) 190 | declare function config:apikeys() as xs:string* { 191 | $config:tuttle-config/config/@apikeys/string() 192 | }; 193 | 194 | (:~ 195 | : DB User and Permissions as fallback if "permissions" not set in repo.xml 196 | :) 197 | declare function config:sm() as map(*) { 198 | let $sm := $config:tuttle-config/config/sm 199 | 200 | return map { 201 | "user" : $sm/@user/string(), 202 | "group" : $sm/@group/string(), 203 | "mode" : $sm/@mode/string() 204 | } 205 | }; 206 | -------------------------------------------------------------------------------- /test/github.js: -------------------------------------------------------------------------------- 1 | import { axios, auth, getResourceInfo, ensureTuttleIsInstalled } from './util.js'; 2 | import { before, describe, it } from 'node:test'; 3 | import assert from 'node:assert'; 4 | 5 | export default () => 6 | describe('Github', async function () { 7 | before(async () => { 8 | await ensureTuttleIsInstalled(); 9 | }); 10 | const testHASH = '79789e5c4842afaaa63c733c3ed6babe37f70121'; 11 | const collection = 'tuttle-sample-data'; 12 | 13 | it('Remove lockfile', async function () { 14 | const resultPromise = axios.get('git/lockfile', { auth }); 15 | await assert.doesNotReject(resultPromise, 'The request should succeed'); 16 | const res = await resultPromise; 17 | assert.strictEqual(res.status, 200, res.statusText); 18 | }); 19 | 20 | it('Get changelog', async function () { 21 | const resultPromise = axios.get('git/commits', { auth }); 22 | await assert.doesNotReject(resultPromise, 'The request should succeed'); 23 | const res = await resultPromise; 24 | 25 | assert.strictEqual(res.status, 200); 26 | assert(res.data.commits.length > 2, 'there should have been at least two commits'); 27 | }); 28 | 29 | it('Pull ' + testHASH + ' into staging collection', async function () { 30 | const resultPromise = axios.get(`git/?hash=${testHASH}`, { auth }); 31 | await assert.doesNotReject(resultPromise, 'The request should succeed'); 32 | const res = await resultPromise; 33 | 34 | assert.strictEqual(res.status, 200); 35 | assert.deepStrictEqual(res.data, { 36 | message: 'success', 37 | collection: `/db/apps/${collection}-stage`, 38 | hash: testHASH, 39 | }); 40 | }); 41 | 42 | it('Deploy staging to target collection', async function () { 43 | const resultPromise = axios.post('git/', {}, { auth }); 44 | await assert.doesNotReject(resultPromise, 'The request should succeed'); 45 | const res = await resultPromise; 46 | assert.strictEqual(res.status, 200); 47 | assert.strictEqual(res.data.message, 'success'); 48 | }); 49 | 50 | it('Check Hashes', async function () { 51 | const resultPromise = axios.get('git/hash', { auth }); 52 | await assert.doesNotReject(resultPromise, 'The request should succeed'); 53 | const res = await resultPromise; 54 | 55 | assert.strictEqual(res.status, 200); 56 | assert.strictEqual(res.data['local-hash'], testHASH); 57 | }); 58 | 59 | describe('Incremental update', async function () { 60 | describe('can do a dry run', async function () { 61 | let resultPromise, dryRunResponse 62 | 63 | before(async function () { 64 | resultPromise = axios.post('git/incremental?dry=true', {}, { auth }); 65 | await assert.doesNotReject(resultPromise, 'The request should succeed'); 66 | dryRunResponse = await resultPromise; 67 | }) 68 | 69 | it('Succeeds', function () { 70 | assert.strictEqual(dryRunResponse.status, 200); 71 | assert.strictEqual(dryRunResponse.data.message, 'dry-run'); 72 | }); 73 | 74 | it('Returns a list of new resources', async function () { 75 | const newFiles = await Promise.all( 76 | dryRunResponse.data.changes.new.map(async (resource) => { 77 | const resourceInfo = await getResourceInfo( 78 | `/db/apps/${collection}/${resource.path}`, 79 | ); 80 | return [resource, resourceInfo.modified]; 81 | }), 82 | ); 83 | // console.log('files to fetch', newFiles) 84 | 85 | assert.strictEqual(newFiles.length, 3); 86 | assert.strictEqual(newFiles[0][0].path, 'data/F-aww.xml'); 87 | assert(newFiles[0][1] instanceof Date); 88 | 89 | assert.deepStrictEqual( 90 | newFiles[1], 91 | [{ path: 'data/F-tit2.xml' }, undefined], 92 | 'File was added, so no modified timestamp is expected', 93 | ); 94 | 95 | assert.strictEqual(newFiles[2][0].path, 'data/F-ham.xml'); 96 | assert(newFiles[2][1] instanceof Date); 97 | }); 98 | 99 | it('Returns a list of resources to be deleted', async function () { 100 | const delFiles = dryRunResponse.data.changes.del; 101 | 102 | assert(delFiles.length > 0); 103 | assert.deepStrictEqual(delFiles, [ 104 | { path: 'data/F-wiv.xml' }, 105 | { path: 'data/F-tit.xml' }, 106 | ]); 107 | }); 108 | }); 109 | 110 | describe('can do a run', async function () { 111 | let resultPromise, incrementalUpdateResponse 112 | 113 | before(async function () { 114 | const resultPromise = axios.post('git/incremental', {}, { auth }); 115 | await assert.doesNotReject(resultPromise, 'The request should succeed'); 116 | incrementalUpdateResponse = await resultPromise; 117 | }) 118 | 119 | it('succeeds', function () { 120 | assert.strictEqual(incrementalUpdateResponse.status, 200); 121 | assert.strictEqual(incrementalUpdateResponse.data.message, 'success'); 122 | }); 123 | 124 | it('updates all changed resources', async function () { 125 | const newFiles = await Promise.all( 126 | incrementalUpdateResponse.data.changes.new.map(async (resource) => { 127 | const resourceInfo = await getResourceInfo( 128 | `/db/apps/${collection}/${resource.path}`, 129 | ); 130 | return [resource, resourceInfo.modified]; 131 | }), 132 | ); 133 | 134 | await Promise.all( 135 | newFiles.map(async (resource) => { 136 | const { modified } = await getResourceInfo( 137 | `/db/apps/${collection}/${resource[0].path}`, 138 | ); 139 | assert.ok(modified); 140 | assert.notStrictEqual(modified, resource[1]); 141 | }), 142 | ); 143 | }); 144 | 145 | it('deletes all deleted resources', async function () { 146 | const delFiles = incrementalUpdateResponse.data.changes.del; 147 | 148 | await Promise.all( 149 | delFiles.map(async (resource) => { 150 | const resourceInfo = await getResourceInfo( 151 | `/db/apps/${collection}/${resource.path}`, 152 | ); 153 | assert.deepStrictEqual(resourceInfo, {}); 154 | }), 155 | ); 156 | }); 157 | }); 158 | }); 159 | }); 160 | -------------------------------------------------------------------------------- /src/resources/css/styles.css: -------------------------------------------------------------------------------- 1 | html { 2 | min-height: 100vh; 3 | } 4 | 5 | body { 6 | font-family:Verdana, sans-serif; 7 | background: linear-gradient(180deg, rgba(19, 83, 102, 1) 6%, rgb(92, 112, 164) 100%); 8 | color: white; 9 | } 10 | 11 | a { 12 | color:#3b3b3b; 13 | } 14 | 15 | .api { 16 | font-size: 1rem; 17 | vertical-align: super; 18 | background: rgba(255,255,255,0.5); 19 | padding: 0.3rem; 20 | border-radius: 50%; 21 | color:#555; 22 | } 23 | 24 | .wrapper, fx-fore, fx-switch{ 25 | height: auto; 26 | } 27 | 28 | 29 | button{ 30 | padding: 0.5rem; 31 | } 32 | button span{ 33 | display:inline-block; 34 | } 35 | .docs{ 36 | position: absolute; 37 | right: 0; 38 | top: 0; 39 | background: rgba(255,255,255,0.5); 40 | width: 130px; 41 | height: 36px; 42 | display: flex; 43 | align-items: center; 44 | color: black !important; 45 | justify-content: center; 46 | border-bottom-left-radius: 12px; 47 | border-top-right-radius: 5px; 48 | } 49 | .error{ 50 | position:relative; 51 | } 52 | .message{ 53 | display:none; 54 | } 55 | .error .message{ 56 | display: flex; 57 | position: absolute; 58 | width: calc(100% - 1rem); 59 | background: rgba(255,0,0,0.8); 60 | height: 100%; 61 | z-index: 1; 62 | justify-content: center; 63 | align-items: center; 64 | color: white; 65 | font-size: 1.4rem; 66 | padding-left:1rem; 67 | } 68 | 69 | fx-case{ 70 | position: relative; 71 | } 72 | #repos{ 73 | /*margin-top:10rem;*/ 74 | } 75 | 76 | fx-group{ 77 | margin-bottom:2rem; 78 | height: 100%; 79 | background: rgba(255,255,255,0.3); 80 | } 81 | 82 | #repos > fx-group{ 83 | max-width:800px; 84 | margin:0 auto; 85 | padding:1rem; 86 | position:relative; 87 | height:auto; 88 | border-radius:0.5rem; 89 | 90 | backdrop-filter: blur(5px); 91 | background-color: rgba(255,255,255, 0.1); 92 | box-shadow: rgba(0, 0, 0, 0.2) 2px 8px 8px; 93 | border: 2px rgba(255,255,255,0.4) solid; 94 | border-bottom: 1px rgba(40,40,40,0.35) solid; 95 | border-right: 1px rgba(40,40,40,0.35) solid; 96 | 97 | } 98 | .overlay{ 99 | /*display:none;*/ 100 | display:block; 101 | position: absolute; 102 | width: 100vw; 103 | height: 100vh; 104 | top: 0; 105 | left: 0; 106 | background:rgba(220,220,240,0.9); 107 | z-index:50; 108 | } 109 | 110 | .overlay.loaded{ 111 | display:none; 112 | } 113 | .overlay.progress-wrapper{ 114 | display:block; 115 | } 116 | .wrap-overlay{ 117 | width:380px; 118 | position:absolute; 119 | top:35%; 120 | left:50%; 121 | transform:translate(-50%,-50%); 122 | height:200px; 123 | display:grid; 124 | grid-template-columns:repeat(3,1fr); 125 | align-items:center; 126 | justify-items:center; 127 | background:rgba(255,255,255,0.5); 128 | border-radius:0.5rem; 129 | padding:0 2rem; 130 | } 131 | .icon{ 132 | width:100px; 133 | } 134 | .icon.exist{ 135 | } 136 | 137 | .icon.git{ 138 | } 139 | 140 | 141 | .progress-wrapper{ 142 | position:absolute; 143 | width:100%; 144 | height:100%; 145 | top:0; 146 | left:0; 147 | background:rgba(200,200,200,0.8); 148 | z-index:1; 149 | } 150 | fx-repeatitem{ 151 | padding:0.5rem; 152 | } 153 | fx-repeatitem > div{ 154 | padding:1rem; 155 | border-radius:0.3rem; 156 | display:grid; 157 | grid-template-columns:60px auto 120px 120px; 158 | align-items:center; 159 | grid-gap:0.3rem; 160 | opacity:0.8; 161 | /*box-shadow: rgb(0 0 0 / 20%) 2px 8px 8px;*/ 162 | border: 2px rgba(255,255,255,0.4) solid; 163 | border-bottom: 1px rgba(40,40,40,0.35) solid; 164 | border-right: 1px rgba(40,40,40,0.35) solid; 165 | 166 | } 167 | fx-switch{ 168 | width: 100%; 169 | height: 100%; 170 | display:none; 171 | } 172 | fx-switch.loaded{ 173 | display:block; 174 | animation:fade 0.6s; 175 | } 176 | 177 | 178 | .gitUrl{ 179 | display:grid; 180 | grid-template-columns:120px auto; 181 | 182 | } 183 | .gitUrl input{ 184 | font-size:1.4rem; 185 | border-radius:3px; 186 | } 187 | 188 | 189 | fx-control, fx-trigger{ 190 | display: inline-block; 191 | margin:0.3rem 0; 192 | } 193 | 194 | fx-control{ 195 | display:grid; 196 | grid-template-columns:30% auto; 197 | } 198 | fx-trigger button{ 199 | width:100%; 200 | } 201 | h1,h2,h3{ 202 | font-weight:300; 203 | } 204 | h1 a{ 205 | text-decoration: none; 206 | } 207 | h1{ 208 | text-align:center; 209 | margin-top:4rem; 210 | color:white; 211 | font-size:3.5rem; 212 | } 213 | h2{ 214 | font-size:3rem; 215 | margin-top:0; 216 | } 217 | h3{ 218 | font-size:2rem; 219 | margin-bottom:0; 220 | } 221 | 222 | h2,h3{ 223 | color:white; 224 | } 225 | h2{ 226 | margin-bottom:2rem; 227 | } 228 | 229 | input{ 230 | height: 1.5rem; 231 | } 232 | label{ 233 | width:100px; 234 | display: inline-block; 235 | } 236 | .login{ 237 | width:320px; 238 | position: absolute; 239 | top:50%; 240 | left:50%; 241 | transform: translateX(-50%); 242 | padding: 2rem; 243 | border:thin solid white; 244 | color:white; 245 | } 246 | .login fx-trigger{ 247 | display:block; 248 | } 249 | .repoName{ 250 | font-size:1.2rem; 251 | } 252 | .small{ 253 | font-size:1rem; 254 | font-weight:300; 255 | margin-left:0.3rem; 256 | white-space:nowrap; 257 | display:inline-block; 258 | } 259 | .new{ 260 | background:var(--paper-amber-500); 261 | } 262 | 263 | .tuttle{ 264 | display:inline-block; 265 | border-radius: 32px; 266 | width:64px; 267 | filter: drop-shadow(2px 4px 6px white) opacity(0.75) hue-rotate(150deg); 268 | transform:translateY(1rem) translateX(-0.5rem); 269 | } 270 | .type{ 271 | width:42px; 272 | height:42px; 273 | background-position:center center; 274 | 275 | } 276 | .type.github{ 277 | background:url('../images/github.svg'); 278 | background-size:cover; 279 | } 280 | .type.gitlab{ 281 | background:url('../images/gitlab.svg'); 282 | background-size:cover; 283 | } 284 | .uptodate{ 285 | background:var(--paper-green-500); 286 | } 287 | .behind{ 288 | background:var(--paper-red-500); 289 | } 290 | 291 | .loader, 292 | .loader:after { 293 | border-radius: 50%; 294 | width: 2em; 295 | height: 2em; 296 | } 297 | 298 | 299 | 300 | 301 | .loader-1 { 302 | width : 24px; 303 | height: 24px; 304 | border: 3px solid white; 305 | border-bottom-color: var(--paper-amber-500); 306 | border-radius: 50%; 307 | display: inline-block; 308 | animation: rotation 1s linear infinite; 309 | background:lightgrey; 310 | } 311 | 312 | .loader-26 { 313 | position:absolute; 314 | width : 124px; 315 | height: 124px; 316 | display: inline-block; 317 | border-radius:50%; 318 | opacity:0.5; 319 | top:25%; 320 | left:50%; 321 | transform:translateX(-50%); 322 | background:white; 323 | z-index:2; 324 | } 325 | .loader-26::after, .loader-26::before{ 326 | content: ''; 327 | width : 124px; 328 | height: 124px; 329 | border-radius: 50%; 330 | background: lightgrey; 331 | position: absolute; 332 | left:0; 333 | top: 0; 334 | animation: animloader14 2s linear infinite; 335 | } 336 | .loader-26::after{ 337 | animation-delay: 1s; 338 | 339 | } 340 | 341 | 342 | @keyframes rotation { 343 | 0% { transform: rotate(0deg) } 344 | 100% { transform: rotate(360deg) } 345 | } 346 | 347 | 348 | @keyframes animloader14 { 349 | 0% { transform: scale(0); opacity: 1;} 350 | 100% { transform: scale(1); opacity: 0;} 351 | } 352 | 353 | .github-corner{ 354 | position:absolute; 355 | right:0; 356 | top:0; 357 | z-index:10; 358 | } 359 | 360 | .arrow{ 361 | transform: rotate(270deg); 362 | margin-left:-35px; 363 | } 364 | .arrow span{ 365 | display: block; 366 | width: 30px; 367 | height: 30px; 368 | border-bottom: 5px solid #F0563A; 369 | border-right: 5px solid #F0563A; 370 | 371 | transform: rotate(45deg); 372 | margin: -10px; 373 | animation: animate 2s infinite; 374 | } 375 | .arrow span:nth-child(2){ 376 | animation-delay: -0.2s; 377 | } 378 | .arrow span:nth-child(3){ 379 | animation-delay: -0.4s; 380 | } 381 | @keyframes animate { 382 | 0%{ 383 | opacity: 0; 384 | transform: rotate(45deg) translate(-20px,-20px); 385 | } 386 | 50%{ 387 | opacity: 1; 388 | } 389 | 75%{ 390 | } 391 | 100%{ 392 | opacity: 0; 393 | transform: rotate(45deg) translate(20px,20px); 394 | border-bottom: 5px solid #06A8FF; 395 | border-right: 5px solid #06A8FF; 396 | } 397 | } 398 | @keyframes fade { 399 | 0%{ 400 | opacity: 0; 401 | } 402 | 100%{ 403 | opacity: 1; 404 | } 405 | } 406 | 407 | 408 | 409 | -------------------------------------------------------------------------------- /test/tuttle.js: -------------------------------------------------------------------------------- 1 | import assert from 'node:assert'; 2 | import { 3 | auth, 4 | axios, 5 | ensureTuttleIsInstalled, 6 | getResource, 7 | install, 8 | putResource, 9 | remove, 10 | } from './util.js'; 11 | import { before, describe, it } from 'node:test'; 12 | import { readFile } from 'node:fs/promises'; 13 | 14 | import { DOMParser } from 'slimdom'; 15 | 16 | export default () => 17 | describe('Tuttle', async function () { 18 | before(async () => { 19 | await ensureTuttleIsInstalled(); 20 | }); 21 | const defaultCollection = 'tuttle-sample-data'; 22 | 23 | describe('git/status', async function () { 24 | let res, repos, defaultRepo 25 | before(async () => { 26 | const resultPromise = axios.get('git/status', { auth }); 27 | await assert.doesNotReject(resultPromise, 'The request should succeed'); 28 | res = await resultPromise; 29 | repos = res.data.repos; 30 | defaultRepo = res.data.default; 31 | }) 32 | 33 | it('returns status 200', async function () { 34 | assert.strictEqual(res.status, 200); 35 | }); 36 | 37 | it('default repo', async function () { 38 | assert.ok(defaultRepo); 39 | assert.strictEqual(defaultRepo, defaultCollection); 40 | }); 41 | 42 | it('lists repos', async function () { 43 | assert.ok(repos); 44 | assert(repos.length > 0); 45 | }); 46 | 47 | it('github sample repo is up to date', async function () { 48 | assert.deepStrictEqual(repos[0], { 49 | baseurl: 'https://api.github.com/', 50 | collection: defaultCollection, 51 | deployed: '5006b2cd6552e2b09ba94d597cf89c100de3399e', 52 | hookuser: 'admin', 53 | message: 'remote found', 54 | owner: 'eeditiones', 55 | path: `/db/apps/${defaultCollection}`, 56 | 'project-id': null, 57 | ref: 'next', 58 | remote: '5006b2cd6552e2b09ba94d597cf89c100de3399e', 59 | repo: 'tuttle-sample-data', 60 | status: 'uptodate', 61 | url: 'https://github.com/eeditiones/tuttle-sample-data', 62 | type: 'github', 63 | }); 64 | }); 65 | 66 | it('gitlab sample repo is not authorized', async function () { 67 | assert.deepStrictEqual(repos[1], { 68 | baseurl: 'https://gitlab.com/api/v4/', 69 | collection: 'tuttle-sample-gitlab', 70 | deployed: 'd80c71f0ac63d355f1583cfe2777fe3dcde4d8bc', 71 | hookuser: 'admin', 72 | message: 'remote found', 73 | owner: 'line-o', 74 | path: '/db/apps/tuttle-sample-gitlab', 75 | 'project-id': '50872175', 76 | ref: 'main', 77 | remote: 'd80c71f0ac63d355f1583cfe2777fe3dcde4d8bc', 78 | repo: 'tuttle-sample-data', 79 | status: 'uptodate', 80 | type: 'gitlab', 81 | url: 'https://gitlab.com/line-o/tuttle-sample-data.git' 82 | }); 83 | }); 84 | }); 85 | 86 | describe('git/lockfile', async function () { 87 | let res 88 | before(async () => { 89 | const resultPromise = axios.get('git/lockfile', { auth }); 90 | await assert.doesNotReject(resultPromise, 'The request should succeed'); 91 | res = await resultPromise; 92 | }); 93 | 94 | it('returns status 200', async function () { 95 | assert.strictEqual(res.status, 200); 96 | }); 97 | 98 | it('confirms no lockfile to be present', async function () { 99 | assert.strictEqual( 100 | res.data.message, 101 | `No lockfile for '${defaultCollection}' found.`, 102 | ); 103 | }); 104 | }); 105 | 106 | describe(`git/${defaultCollection}/lockfile`, async function () { 107 | let res 108 | before(async () => { 109 | const resultPromise = axios.get(`git/${defaultCollection}/lockfile`, { auth }); 110 | await assert.doesNotReject(resultPromise, 'The request should succeed'); 111 | res = await resultPromise; 112 | }) 113 | 114 | it('returns status 200', async function () { 115 | assert.strictEqual(res.status, 200); 116 | }); 117 | 118 | it('confirms no lockfile to be present', async function () { 119 | assert.strictEqual( 120 | res.data.message, 121 | `No lockfile for '${defaultCollection}' found.`, 122 | ); 123 | }); 124 | }); 125 | 126 | describe('git/status with different settings', async function () { 127 | let repos,res 128 | before(async () => { 129 | const buffer = await readFile('./test/fixtures/alt-tuttle.xml'); 130 | await putResource(buffer, '/db/apps/tuttle/data/tuttle.xml'); 131 | const buffer2 = await readFile('./test/fixtures/test.xqm'); 132 | await putResource(buffer2, '/db/apps/tuttle/modules/test.xqm'); 133 | 134 | const resultPromise = axios.get('git/status', { auth }); 135 | await assert.doesNotReject(resultPromise, 'The request should succeed'); 136 | res = await resultPromise; 137 | repos = res.data.repos; 138 | }) 139 | 140 | it('returns status 200', async function () { 141 | assert.strictEqual(res.status, 200); 142 | }); 143 | 144 | it('lists repos', async function () { 145 | assert.ok(repos); 146 | assert(repos.length > 0); 147 | }); 148 | 149 | it('has no default repo', async function () { 150 | assert.strictEqual(res.data.default, null); 151 | }); 152 | 153 | it('ref "nonexistent" cannot be found in github sample repo ', async function () { 154 | // const resultPromise = axios.get('git/status', { auth }); 155 | // await assert.doesNotReject(resultPromise, 'The request should succeed'); 156 | // const res = await resultPromise; 157 | // const repos = res.data.repos; 158 | 159 | assert.deepStrictEqual(repos[0], { 160 | baseurl: 'https://api.github.com/', 161 | collection: 'tuttle-sample-data', 162 | deployed: '5006b2cd6552e2b09ba94d597cf89c100de3399e', 163 | hookuser: 'admin', 164 | message: 'server connection failed: Not Found (404)', 165 | owner: 'eeditiones', 166 | path: '/db/apps/tuttle-sample-data', 167 | 'project-id': null, 168 | ref: 'nonexistent', 169 | repo: 'tuttle-sample-data', 170 | status: 'error', 171 | type: 'github', 172 | }); 173 | }); 174 | }); 175 | 176 | it('can also write hashes to repo.xml', async () => { 177 | await remove(); 178 | await install(); 179 | 180 | // Set up tuttle with a repo where repo.xml is used to store the git sha info 181 | const buffer = await readFile('./test/fixtures/alt-repoxml-tuttle.xml'); 182 | await putResource(buffer, '/db/apps/tuttle/data/tuttle.xml'); 183 | 184 | const resultPromise = axios.get('git/status', { auth }); 185 | await assert.doesNotReject(resultPromise); 186 | 187 | const stagingPromise = axios.get('git/tuttle-sample-data', { auth }); 188 | await assert.doesNotReject(stagingPromise, 'The request should succeed'); 189 | 190 | const deployPromise = axios.post('git/tuttle-sample-data', {}, { auth }); 191 | await assert.doesNotReject(deployPromise, 'The request should succeed'); 192 | 193 | const repoXML = await getResource('/db/apps/tuttle-sample-data/repo.xml'); 194 | 195 | const repo = new DOMParser().parseFromString(repoXML.toString(), 'text/xml').documentElement; 196 | assert.ok(repo.getAttribute('commit-id'), 'The commit id should be set'); 197 | assert.ok(repo.getAttribute('commit-time'), 'The commit time should be set'); 198 | assert.ok(repo.getAttribute('commit-date'), 'The commit date should be set'); 199 | }); 200 | 201 | describe('large histories', async () => { 202 | before(async () => { 203 | await remove(); 204 | await install(); 205 | }); 206 | 207 | await it('can upgrade over a few hundred commits', async () => { 208 | // Set up tuttle with a repo with a ton of commits that it can upgrade over 209 | const buffer = await readFile('./test/fixtures/alt-big-repo-tuttle.xml'); 210 | await putResource(buffer, '/db/apps/tuttle/data/tuttle.xml'); 211 | 212 | const OLD_HASH = '41188098f120b6e70d1b0c3bb704a422eba43dfa'; 213 | const stageOldVersionPromise = axios.get(`git/tuttle-sample-data?hash=${OLD_HASH}`, { auth }); 214 | await assert.doesNotReject(stageOldVersionPromise); 215 | const deployOldVersionPromise = axios.post('git/tuttle-sample-data', {}, { auth }); 216 | await assert.doesNotReject(deployOldVersionPromise, 'The request should succeed'); 217 | 218 | const beforeString = await getResource('/db/apps/tuttle-sample-data/data/regular-changing-document.xml'); 219 | 220 | const before = new DOMParser().parseFromString(beforeString.toString(), 'text/xml').documentElement; 221 | assert.strictEqual(before.textContent, 'Initial version'); 222 | 223 | console.log('deployed older version of the sample data on the long-history branch') 224 | 225 | const resultPromise = axios.get('git/status', { auth }); 226 | await assert.doesNotReject(resultPromise); 227 | 228 | const incrementalPromise = axios.post('git/tuttle-sample-data/incremental', {}, { auth }); 229 | await assert.doesNotReject(incrementalPromise, 'The incremental request should succeed'); 230 | 231 | const afterString = await getResource('/db/apps/tuttle-sample-data/data/regular-changing-document.xml'); 232 | 233 | const after = new DOMParser().parseFromString(afterString.toString(), 'text/xml').documentElement; 234 | assert.strictEqual(after.textContent, 'change for 200'); 235 | }); 236 | }); 237 | }); 238 | -------------------------------------------------------------------------------- /src/resources/css/vars.css: -------------------------------------------------------------------------------- 1 | /* Material Design color palette for Google products */ 2 | html { 3 | --google-red-100: #f4c7c3; 4 | --google-red-300: #e67c73; 5 | --google-red-500: #db4437; 6 | --google-red-700: #c53929; 7 | 8 | --google-blue-100: #c6dafc; 9 | --google-blue-300: #7baaf7; 10 | --google-blue-500: #4285f4; 11 | --google-blue-700: #3367d6; 12 | 13 | --google-green-100: #b7e1cd; 14 | --google-green-300: #57bb8a; 15 | --google-green-500: #0f9d58; 16 | --google-green-700: #0b8043; 17 | 18 | --google-yellow-100: #fce8b2; 19 | --google-yellow-300: #f7cb4d; 20 | --google-yellow-500: #f4b400; 21 | --google-yellow-700: #f09300; 22 | 23 | --google-grey-100: #f5f5f5; 24 | --google-grey-300: #e0e0e0; 25 | --google-grey-500: #9e9e9e; 26 | --google-grey-700: #616161; 27 | 28 | /* Material Design color palette from online spec document */ 29 | 30 | --paper-red-50: #ffebee; 31 | --paper-red-100: #ffcdd2; 32 | --paper-red-200: #ef9a9a; 33 | --paper-red-300: #e57373; 34 | --paper-red-400: #ef5350; 35 | --paper-red-500: #f44336; 36 | --paper-red-600: #e53935; 37 | --paper-red-700: #d32f2f; 38 | --paper-red-800: #c62828; 39 | --paper-red-900: #b71c1c; 40 | --paper-red-a100: #ff8a80; 41 | --paper-red-a200: #ff5252; 42 | --paper-red-a400: #ff1744; 43 | --paper-red-a700: #d50000; 44 | 45 | --paper-pink-50: #fce4ec; 46 | --paper-pink-100: #f8bbd0; 47 | --paper-pink-200: #f48fb1; 48 | --paper-pink-300: #f06292; 49 | --paper-pink-400: #ec407a; 50 | --paper-pink-500: #e91e63; 51 | --paper-pink-600: #d81b60; 52 | --paper-pink-700: #c2185b; 53 | --paper-pink-800: #ad1457; 54 | --paper-pink-900: #880e4f; 55 | --paper-pink-a100: #ff80ab; 56 | --paper-pink-a200: #ff4081; 57 | --paper-pink-a400: #f50057; 58 | --paper-pink-a700: #c51162; 59 | 60 | --paper-purple-50: #f3e5f5; 61 | --paper-purple-100: #e1bee7; 62 | --paper-purple-200: #ce93d8; 63 | --paper-purple-300: #ba68c8; 64 | --paper-purple-400: #ab47bc; 65 | --paper-purple-500: #9c27b0; 66 | --paper-purple-600: #8e24aa; 67 | --paper-purple-700: #7b1fa2; 68 | --paper-purple-800: #6a1b9a; 69 | --paper-purple-900: #4a148c; 70 | --paper-purple-a100: #ea80fc; 71 | --paper-purple-a200: #e040fb; 72 | --paper-purple-a400: #d500f9; 73 | --paper-purple-a700: #aa00ff; 74 | 75 | --paper-deep-purple-50: #ede7f6; 76 | --paper-deep-purple-100: #d1c4e9; 77 | --paper-deep-purple-200: #b39ddb; 78 | --paper-deep-purple-300: #9575cd; 79 | --paper-deep-purple-400: #7e57c2; 80 | --paper-deep-purple-500: #673ab7; 81 | --paper-deep-purple-600: #5e35b1; 82 | --paper-deep-purple-700: #512da8; 83 | --paper-deep-purple-800: #4527a0; 84 | --paper-deep-purple-900: #311b92; 85 | --paper-deep-purple-a100: #b388ff; 86 | --paper-deep-purple-a200: #7c4dff; 87 | --paper-deep-purple-a400: #651fff; 88 | --paper-deep-purple-a700: #6200ea; 89 | 90 | --paper-indigo-50: #e8eaf6; 91 | --paper-indigo-100: #c5cae9; 92 | --paper-indigo-200: #9fa8da; 93 | --paper-indigo-300: #7986cb; 94 | --paper-indigo-400: #5c6bc0; 95 | --paper-indigo-500: #3f51b5; 96 | --paper-indigo-600: #3949ab; 97 | --paper-indigo-700: #303f9f; 98 | --paper-indigo-800: #283593; 99 | --paper-indigo-900: #1a237e; 100 | --paper-indigo-a100: #8c9eff; 101 | --paper-indigo-a200: #536dfe; 102 | --paper-indigo-a400: #3d5afe; 103 | --paper-indigo-a700: #304ffe; 104 | 105 | --paper-blue-50: #e3f2fd; 106 | --paper-blue-100: #bbdefb; 107 | --paper-blue-200: #90caf9; 108 | --paper-blue-300: #64b5f6; 109 | --paper-blue-400: #42a5f5; 110 | --paper-blue-500: #2196f3; 111 | --paper-blue-600: #1e88e5; 112 | --paper-blue-700: #1976d2; 113 | --paper-blue-800: #1565c0; 114 | --paper-blue-900: #0d47a1; 115 | --paper-blue-a100: #82b1ff; 116 | --paper-blue-a200: #448aff; 117 | --paper-blue-a400: #2979ff; 118 | --paper-blue-a700: #2962ff; 119 | 120 | --paper-light-blue-50: #e1f5fe; 121 | --paper-light-blue-100: #b3e5fc; 122 | --paper-light-blue-200: #81d4fa; 123 | --paper-light-blue-300: #4fc3f7; 124 | --paper-light-blue-400: #29b6f6; 125 | --paper-light-blue-500: #03a9f4; 126 | --paper-light-blue-600: #039be5; 127 | --paper-light-blue-700: #0288d1; 128 | --paper-light-blue-800: #0277bd; 129 | --paper-light-blue-900: #01579b; 130 | --paper-light-blue-a100: #80d8ff; 131 | --paper-light-blue-a200: #40c4ff; 132 | --paper-light-blue-a400: #00b0ff; 133 | --paper-light-blue-a700: #0091ea; 134 | 135 | --paper-cyan-50: #e0f7fa; 136 | --paper-cyan-100: #b2ebf2; 137 | --paper-cyan-200: #80deea; 138 | --paper-cyan-300: #4dd0e1; 139 | --paper-cyan-400: #26c6da; 140 | --paper-cyan-500: #00bcd4; 141 | --paper-cyan-600: #00acc1; 142 | --paper-cyan-700: #0097a7; 143 | --paper-cyan-800: #00838f; 144 | --paper-cyan-900: #006064; 145 | --paper-cyan-a100: #84ffff; 146 | --paper-cyan-a200: #18ffff; 147 | --paper-cyan-a400: #00e5ff; 148 | --paper-cyan-a700: #00b8d4; 149 | 150 | --paper-teal-50: #e0f2f1; 151 | --paper-teal-100: #b2dfdb; 152 | --paper-teal-200: #80cbc4; 153 | --paper-teal-300: #4db6ac; 154 | --paper-teal-400: #26a69a; 155 | --paper-teal-500: #009688; 156 | --paper-teal-600: #00897b; 157 | --paper-teal-700: #00796b; 158 | --paper-teal-800: #00695c; 159 | --paper-teal-900: #004d40; 160 | --paper-teal-a100: #a7ffeb; 161 | --paper-teal-a200: #64ffda; 162 | --paper-teal-a400: #1de9b6; 163 | --paper-teal-a700: #00bfa5; 164 | 165 | --paper-green-50: #e8f5e9; 166 | --paper-green-100: #c8e6c9; 167 | --paper-green-200: #a5d6a7; 168 | --paper-green-300: #81c784; 169 | --paper-green-400: #66bb6a; 170 | --paper-green-500: #4caf50; 171 | --paper-green-600: #43a047; 172 | --paper-green-700: #388e3c; 173 | --paper-green-800: #2e7d32; 174 | --paper-green-900: #1b5e20; 175 | --paper-green-a100: #b9f6ca; 176 | --paper-green-a200: #69f0ae; 177 | --paper-green-a400: #00e676; 178 | --paper-green-a700: #00c853; 179 | 180 | --paper-light-green-50: #f1f8e9; 181 | --paper-light-green-100: #dcedc8; 182 | --paper-light-green-200: #c5e1a5; 183 | --paper-light-green-300: #aed581; 184 | --paper-light-green-400: #9ccc65; 185 | --paper-light-green-500: #8bc34a; 186 | --paper-light-green-600: #7cb342; 187 | --paper-light-green-700: #689f38; 188 | --paper-light-green-800: #558b2f; 189 | --paper-light-green-900: #33691e; 190 | --paper-light-green-a100: #ccff90; 191 | --paper-light-green-a200: #b2ff59; 192 | --paper-light-green-a400: #76ff03; 193 | --paper-light-green-a700: #64dd17; 194 | 195 | --paper-lime-50: #f9fbe7; 196 | --paper-lime-100: #f0f4c3; 197 | --paper-lime-200: #e6ee9c; 198 | --paper-lime-300: #dce775; 199 | --paper-lime-400: #d4e157; 200 | --paper-lime-500: #cddc39; 201 | --paper-lime-600: #c0ca33; 202 | --paper-lime-700: #afb42b; 203 | --paper-lime-800: #9e9d24; 204 | --paper-lime-900: #827717; 205 | --paper-lime-a100: #f4ff81; 206 | --paper-lime-a200: #eeff41; 207 | --paper-lime-a400: #c6ff00; 208 | --paper-lime-a700: #aeea00; 209 | 210 | --paper-yellow-50: #fffde7; 211 | --paper-yellow-100: #fff9c4; 212 | --paper-yellow-200: #fff59d; 213 | --paper-yellow-300: #fff176; 214 | --paper-yellow-400: #ffee58; 215 | --paper-yellow-500: #ffeb3b; 216 | --paper-yellow-600: #fdd835; 217 | --paper-yellow-700: #fbc02d; 218 | --paper-yellow-800: #f9a825; 219 | --paper-yellow-900: #f57f17; 220 | --paper-yellow-a100: #ffff8d; 221 | --paper-yellow-a200: #ffff00; 222 | --paper-yellow-a400: #ffea00; 223 | --paper-yellow-a700: #ffd600; 224 | 225 | --paper-amber-50: #fff8e1; 226 | --paper-amber-100: #ffecb3; 227 | --paper-amber-200: #ffe082; 228 | --paper-amber-300: #ffd54f; 229 | --paper-amber-400: #ffca28; 230 | --paper-amber-500: #ffc107; 231 | --paper-amber-600: #ffb300; 232 | --paper-amber-700: #ffa000; 233 | --paper-amber-800: #ff8f00; 234 | --paper-amber-900: #ff6f00; 235 | --paper-amber-a100: #ffe57f; 236 | --paper-amber-a200: #ffd740; 237 | --paper-amber-a400: #ffc400; 238 | --paper-amber-a700: #ffab00; 239 | 240 | --paper-orange-50: #fff3e0; 241 | --paper-orange-100: #ffe0b2; 242 | --paper-orange-200: #ffcc80; 243 | --paper-orange-300: #ffb74d; 244 | --paper-orange-400: #ffa726; 245 | --paper-orange-500: #ff9800; 246 | --paper-orange-600: #fb8c00; 247 | --paper-orange-700: #f57c00; 248 | --paper-orange-800: #ef6c00; 249 | --paper-orange-900: #e65100; 250 | --paper-orange-a100: #ffd180; 251 | --paper-orange-a200: #ffab40; 252 | --paper-orange-a400: #ff9100; 253 | --paper-orange-a700: #ff6500; 254 | 255 | --paper-deep-orange-50: #fbe9e7; 256 | --paper-deep-orange-100: #ffccbc; 257 | --paper-deep-orange-200: #ffab91; 258 | --paper-deep-orange-300: #ff8a65; 259 | --paper-deep-orange-400: #ff7043; 260 | --paper-deep-orange-500: #ff5722; 261 | --paper-deep-orange-600: #f4511e; 262 | --paper-deep-orange-700: #e64a19; 263 | --paper-deep-orange-800: #d84315; 264 | --paper-deep-orange-900: #bf360c; 265 | --paper-deep-orange-a100: #ff9e80; 266 | --paper-deep-orange-a200: #ff6e40; 267 | --paper-deep-orange-a400: #ff3d00; 268 | --paper-deep-orange-a700: #dd2c00; 269 | 270 | --paper-brown-50: #efebe9; 271 | --paper-brown-100: #d7ccc8; 272 | --paper-brown-200: #bcaaa4; 273 | --paper-brown-300: #a1887f; 274 | --paper-brown-400: #8d6e63; 275 | --paper-brown-500: #795548; 276 | --paper-brown-600: #6d4c41; 277 | --paper-brown-700: #5d4037; 278 | --paper-brown-800: #4e342e; 279 | --paper-brown-900: #3e2723; 280 | 281 | --paper-grey-50: #fafafa; 282 | --paper-grey-100: #f5f5f5; 283 | --paper-grey-200: #eeeeee; 284 | --paper-grey-300: #e0e0e0; 285 | --paper-grey-400: #bdbdbd; 286 | --paper-grey-500: #9e9e9e; 287 | --paper-grey-600: #757575; 288 | --paper-grey-700: #616161; 289 | --paper-grey-800: #424242; 290 | --paper-grey-900: #212121; 291 | 292 | --paper-blue-grey-50: #eceff1; 293 | --paper-blue-grey-100: #cfd8dc; 294 | --paper-blue-grey-200: #b0bec5; 295 | --paper-blue-grey-300: #90a4ae; 296 | --paper-blue-grey-400: #78909c; 297 | --paper-blue-grey-500: #607d8b; 298 | --paper-blue-grey-600: #546e7a; 299 | --paper-blue-grey-700: #455a64; 300 | --paper-blue-grey-800: #37474f; 301 | --paper-blue-grey-900: #263238; 302 | 303 | /* opacity for dark text on a light background */ 304 | --dark-divider-opacity: 0.12; 305 | --dark-disabled-opacity: 0.38; /* or hint text or icon */ 306 | --dark-secondary-opacity: 0.54; 307 | --dark-primary-opacity: 0.87; 308 | 309 | /* opacity for light text on a dark background */ 310 | --light-divider-opacity: 0.12; 311 | --light-disabled-opacity: 0.3; /* or hint text or icon */ 312 | --light-secondary-opacity: 0.7; 313 | --light-primary-opacity: 1.0; 314 | 315 | 316 | /* model element styles for debugging */ 317 | --model-element-padding:10px; 318 | --model-element-margin:10px; 319 | } 320 | 321 | -------------------------------------------------------------------------------- /src/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | Tuttle Dashboard 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 18 | 19 |
20 | 21 |

22 | Tuttle Git DashboardAPI

23 | 24 | 25 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | 40 | 41 | 42 | {} 43 | 44 | 45 | 46 | 47 | 48 | 49 | 50 | 51 | 52 | 53 | 54 | 55 | 56 | 61 | 62 | 63 | 64 | 65 | 66 | 67 | Login failed 68 | 69 | 70 | 76 | 77 | 78 | 79 | loaded 80 | 81 | 82 | 83 | 84 | loaded 85 | 86 | 87 | 88 | 89 | 94 | 95 | 96 | fetching data... 97 | progress-wrapper 98 | 99 | 100 | Data fetched 101 | 102 | 103 | 104 | 105 | 106 | 107 | 108 | 114 | 115 | 116 | Data deployed to database 117 | No expath-pkg.xml or repo.xml in repo 118 | 119 | 120 | 121 | 122 | 123 | 124 | 129 | 130 | 131 | 132 | progress-wrapper 133 | 134 | 135 | 136 | incremental update done 137 | 138 | 139 | 140 | 141 | Update Failed! 142 | 143 | 144 | 145 | 146 | 147 | return btoa(encodeURI($input)); 148 | 149 | 150 | 151 |
152 |
153 | 154 |
155 | 156 | 157 | 158 |
159 | 160 |
161 |
162 | 163 | 164 | 179 | 180 | 181 | 182 | 183 | Documentation 184 |

Git to DBimport data from Git into eXist-db

185 |

Git Repositories

186 | 187 | 208 | 209 |
210 |
211 |
212 |
213 | 214 |
215 | 216 | 217 | -------------------------------------------------------------------------------- /src/modules/gitlab.xqm: -------------------------------------------------------------------------------- 1 | xquery version "3.1"; 2 | 3 | module namespace gitlab="http://e-editiones.org/tuttle/gitlab"; 4 | 5 | import module namespace app="http://e-editiones.org/tuttle/app" at "app.xqm"; 6 | import module namespace config="http://e-editiones.org/tuttle/config" at "config.xqm"; 7 | 8 | declare namespace http="http://expath.org/ns/http-client"; 9 | 10 | declare function gitlab:repo-url($config as map(*)) as xs:string { 11 | ``[`{$config?baseurl}`projects/`{$config?project-id}`/repository]`` 12 | }; 13 | 14 | declare function gitlab:commit-by-ref-url($config as map(*), $ref as xs:string) as xs:string { 15 | gitlab:repo-url($config) || "/commits/" || $ref 16 | }; 17 | 18 | (: 19 | the `commits` API endpoint is _paged_ and will only return _20_ commits by default 20 | ?ref_name={ref}&per_page=100 21 | will always return 100 commits, which might result in to much overhead 22 | :) 23 | declare function gitlab:commit-ref-url($config as map(*)) as xs:string { 24 | gitlab:repo-url($config) || "/commits/?ref_name=" || $config?ref 25 | }; 26 | 27 | declare function gitlab:commit-ref-url($config as map(*), $per-page as xs:integer) as xs:string { 28 | gitlab:commit-ref-url($config) || "&per_page=" || $per-page 29 | }; 30 | 31 | (: 32 | The Gitlab API allows to specify a revision range as the value for ref_name 33 | ?ref_name={ref}...{deployed_hash} 34 | This will only retrieve commits since deployed_hash but the result is paged as well. 35 | 36 | ?ref_name={ref}...{deployed_hash}&per_page=100 37 | will return _up to_ 100 commits until deployed_hash is reached. 38 | :) 39 | declare function gitlab:newer-commits-url($config as map(*), $base as xs:string, $per-page as xs:integer) as xs:string { 40 | gitlab:repo-url($config) || "/commits?ref_name=" || $config?ref || "..." || $base || "&per_page=" || $per-page 41 | }; 42 | 43 | (:~ 44 | : clone defines Version repo 45 | :) 46 | declare function gitlab:get-archive($config as map(*), $sha as xs:string) { 47 | gitlab:request( 48 | gitlab:repo-url($config) || "/archive.zip?sha=" || $sha, $config?token) 49 | }; 50 | 51 | (:~ 52 | : Get commit info for a specific sha 53 | :) 54 | declare function gitlab:get-specific-commit($config as map(*), $ref as xs:string) as map(*) { 55 | let $commit := 56 | gitlab:request-json( 57 | gitlab:commit-by-ref-url($config, $ref), $config?token) 58 | 59 | return 60 | map { 61 | "sha" : $commit?id, 62 | "date": $commit?committed_date 63 | } 64 | }; 65 | 66 | (:~ 67 | : Get the last commit 68 | :) 69 | declare function gitlab:get-last-commit($config as map(*)) { 70 | let $commit := 71 | array:head( 72 | gitlab:request-json( 73 | gitlab:commit-ref-url($config, 1), $config?token)) 74 | 75 | return 76 | map { 77 | "sha" : $commit?id, 78 | "date": $commit?committed_date 79 | } 80 | }; 81 | 82 | (:~ 83 | : Get all commits 84 | :) 85 | declare function gitlab:get-commits($config as map(*)) as array(*)* { 86 | gitlab:get-commits($config, 100) 87 | }; 88 | 89 | (:~ 90 | : Get N commits 91 | :) 92 | declare function gitlab:get-commits($config as map(*), $count as xs:integer) as array(*)* { 93 | if ($count <= 0) 94 | then error(xs:QName("gitlab:illegal-argument"), "$count must be greater than zero in gitlab:get-commits") 95 | else 96 | let $json := gitlab:get-raw-commits($config, $count) 97 | let $commits := 98 | if (empty($json)) 99 | then [] 100 | else if ($count >= array:size($json)) (: return everything :) 101 | then $json 102 | else array:subarray($json, 1, $count) 103 | 104 | return 105 | array:for-each($commits, gitlab:short-commit-info#1) 106 | }; 107 | 108 | declare %private function gitlab:short-commit-info ($commit-info as map(*)) as array(*) { 109 | [ 110 | $commit-info?id, 111 | $commit-info?message 112 | ] 113 | }; 114 | 115 | (:~ 116 | : Get commits in full 117 | :) 118 | declare function gitlab:get-raw-commits($config as map(*), $count as xs:integer) as array(*)? { 119 | gitlab:request-json( 120 | gitlab:commit-ref-url($config, $count), $config?token) 121 | }; 122 | 123 | (:~ 124 | : Get diff between production collection and gitlab-newest 125 | :) 126 | declare function gitlab:get-newest-commits($config as map(*)) { 127 | reverse( 128 | gitlab:request-json( 129 | gitlab:newer-commits-url($config, $config?deployed, 100), $config?token) 130 | ?*) 131 | }; 132 | 133 | (:~ 134 | : Check if sha exist 135 | :) 136 | declare function gitlab:available-sha($config as map(*), $sha as xs:string) as xs:boolean { 137 | $sha = gitlab:get-commits($config)?*?1 138 | }; 139 | 140 | (:~ 141 | : Get files removed and added from commit 142 | :) 143 | declare function gitlab:get-commit-files($config as map(*), $sha as xs:string) as array(*) { 144 | gitlab:request-json( 145 | gitlab:repo-url($config) || "/commits/" || $sha || "/diff?per_page=100", $config?token) 146 | }; 147 | 148 | (:~ 149 | : Get blob of a file 150 | :) 151 | declare function gitlab:get-blob($config as map(*), $filename as xs:string, $sha as xs:string) { 152 | let $file := escape-html-uri(replace($filename,"/", "%2f")) 153 | let $file-url := gitlab:repo-url($config) || "/files/" || $file || "?ref=" || $sha 154 | let $json := gitlab:request-json($file-url, $config?token) 155 | 156 | return 157 | util:base64-decode($json?content) 158 | }; 159 | 160 | (:~ 161 | : Get HTTP-URL 162 | :) 163 | declare function gitlab:get-url($config as map(*)) { 164 | let $info := gitlab:request-json($config?baseurl || "/projects/" || $config?project-id, $config?token) 165 | 166 | return $info?http_url_to_repo 167 | }; 168 | 169 | (:~ 170 | : Handle edge case where a file created in this changeset is also removed 171 | : 172 | : So, in order to not fire useless and potentially harmful side-effects like 173 | : triggers or indexing we filter out all of these documents as if they were 174 | : never there. 175 | :) 176 | declare function gitlab:remove-or-ignore ($changes as map(*), $filename as xs:string) as map(*) { 177 | if ($filename = $changes?new) 178 | then map:put($changes, "new", $changes?new[. ne $filename]) (: filter document from new :) 179 | else map:put($changes, "del", ($changes?del, $filename)) (: add document to be removed :) 180 | }; 181 | 182 | declare %private function gitlab:aggregate-filechanges($changes as map(*), $next as map(*)) as map(*) { 183 | if ($next?renamed_file) then 184 | gitlab:remove-or-ignore($changes, $next?old_path) 185 | => map:put("new", ($changes?new, $next?new_path)) 186 | else if ($next?deleted_file) then 187 | gitlab:remove-or-ignore($changes, $next?new_path) 188 | (: added or modified :) 189 | else if ($next?new_file or $next?new_path = $next?old_path) then 190 | map:put($changes, "new", ($changes?new, $next?new_path)) 191 | else $changes 192 | }; 193 | 194 | declare function gitlab:get-changes ($collection-config as map(*)) as map(*) { 195 | let $changes := 196 | for $commit in gitlab:get-newest-commits($collection-config) 197 | return gitlab:get-commit-files($collection-config, $commit?short_id)?* 198 | 199 | let $aggregated := fold-left($changes, map{}, gitlab:aggregate-filechanges#2) 200 | let $filtered := fold-left($aggregated?new, map{}, app:ignore-reducer#2) 201 | return map { 202 | "del": $aggregated?del, 203 | "new": $filtered?new, 204 | "ignored": $filtered?ignored 205 | } 206 | }; 207 | 208 | (:~ 209 | : Run incremental update on collection in dry mode 210 | :) 211 | declare function gitlab:incremental-dry($config as map(*)) as map(*) { 212 | let $changes := gitlab:get-changes($config) 213 | return map { 214 | 'new': array{ $changes?new }, 215 | 'del': array{ $changes?del }, 216 | 'ignored': array{ $changes?ignored } 217 | } 218 | }; 219 | 220 | (:~ 221 | : Run incremental update on collection 222 | :) 223 | declare function gitlab:incremental($config as map(*)) as map(*) { 224 | let $last-commit := gitlab:get-last-commit($config) 225 | let $changes := gitlab:get-changes($config) 226 | let $new := gitlab:incremental-add($config, $changes?new, $last-commit?sha) 227 | let $del := gitlab:incremental-delete($config, $changes?del) 228 | let $writesha := app:write-commit-info($config?path, $last-commit) 229 | return map { 230 | 'new': array{ $new }, 231 | 'del': array{ $del }, 232 | 'ignored': array{ $changes?ignored } 233 | } 234 | }; 235 | 236 | declare function gitlab:check-signature ($collection as xs:string, $apikey as xs:string) as xs:boolean { 237 | request:get-header("X-Gitlab-Token") = $apikey 238 | }; 239 | 240 | (:~ 241 | : Incremental updates delete files 242 | :) 243 | declare %private function gitlab:incremental-delete($config as map(*), $files as xs:string*) as array(*)* { 244 | for $filepath in $files 245 | return 246 | try { 247 | [ $filepath, app:delete-resource($config, $filepath) ] 248 | } 249 | catch * { 250 | [ $filepath, false(), map{ 251 | "code": $err:code, "description": $err:description, "value": $err:value, 252 | "line": $err:line-number, "column": $err:column-number, "module": $err:module 253 | }] 254 | } 255 | }; 256 | 257 | (:~ 258 | : Incremental update fetch and add files from git 259 | :) 260 | declare %private function gitlab:incremental-add($config as map(*), $files as xs:string*, $sha as xs:string) as array(*)* { 261 | for $filepath in $files 262 | return 263 | try { 264 | [ $filepath, 265 | app:add-resource($config, $filepath, 266 | gitlab:get-blob($config, $filepath, $sha))] 267 | } 268 | catch * { 269 | [ $filepath, false(), map{ 270 | "code": $err:code, "description": $err:description, "value": $err:value, 271 | "line": $err:line-number, "column": $err:column-number, "module": $err:module 272 | }] 273 | } 274 | }; 275 | 276 | (:~ 277 | : Gitlab request 278 | :) 279 | 280 | (: If the response header `x-next-page` has a value, there are commits missing. :) 281 | declare %private function gitlab:has-next-page($response as element(http:response)) as xs:boolean { 282 | let $x-next-page := $response//http:header[@name="x-next-page"] 283 | return exists($x-next-page/@value) and $x-next-page/@value/string() ne '' 284 | }; 285 | 286 | declare %private function gitlab:request-json($url as xs:string, $token as xs:string?) { 287 | let $response := app:request-json(gitlab:build-request($url, $token)) 288 | 289 | return ( 290 | if (gitlab:has-next-page($response[1])) 291 | then util:log("warn", ('Paged gitlab request has next page! URL:', $url)) 292 | else (), 293 | $response[2] 294 | ) 295 | }; 296 | 297 | declare %private function gitlab:request($url as xs:string, $token as xs:string?) { 298 | app:request(gitlab:build-request($url, $token))[2] 299 | }; 300 | 301 | declare %private function gitlab:build-request($url as xs:string, $token as xs:string) as element(http:request) { 302 | 303 | { 304 | if (empty($token) or $token = "") 305 | then () 306 | else 307 | } 308 | 309 | }; 310 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | 2 | # Tuttle - a Git-integration for eXist-db 3 | 4 | Synchronizes your data collection with GitHub and GitLab. 5 | 6 | ## User Documentation 7 | 8 | [User Documentation](https://eeditiones.github.io/tuttle-doc/) 9 | 10 | ## Functionality 11 | 12 | * Sync data collection from Git to DB 13 | * Deal with multiple repositories 14 | * Incremental updates 15 | * Works with private or public repositories 16 | * Works with self hosted instances 17 | * Extendable to other git services 18 | 19 | ## Requirements 20 | 21 | - [node](https://nodejs.org/en/): `v22` 22 | - [exist-db](https://www.exist-db.org): `v5.5.1+ < 7.0.0` 23 | 24 | ## Installation 25 | 26 | Pre-built packages are available 27 | - as [github-releases](https://github.com/eeditiones/tuttle/releases) 28 | ```bash 29 | xst package install github-release tuttle --owner eeditiones 30 | ``` 31 | 32 | - and on [exist-db's public package registry](https://exist-db.org/exist/apps/public-repo/packages/tuttle?eXist-db-min-version=5.5.1). 33 | ```bash 34 | xst package install from-registry tuttle 35 | ``` 36 | 37 | 38 | ## Building from source 39 | 40 | Tuttle uses Gulp as its build tool which itself builds on NPM. 41 | To initialize the project and load dependencies run 42 | 43 | ``` 44 | npm install 45 | ``` 46 | 47 | | Run | Description | 48 | |---------|-------------| 49 | |```npm run build``` | builds the Tuttle package | 50 | |```npm run deploy``` | build and install Tuttle in one go | 51 | 52 | > Note: the `deploy` commands below assume that you have a local eXist-db running on port 8080. However the database connection can be configured (see gulp-exist documentation) 53 | 54 | ## Testing 55 | 56 | To run the local test suite you need 57 | 58 | * an instance of eXist running on `localhost:8080` and 59 | * `npm` to be available in your path 60 | * a GitHub personal access token with read access to public repositories 61 | * a gitlab personal access token with read access to public repositories 62 | 63 | In CI these access tokens are read from environment variables. 64 | You can do the same with 65 | ```bash 66 | export tuttle_token_tuttle_sample_data=; \ 67 | export tuttle_token_gitlab_sample_data=; \ 68 | path/to/startup.sh 69 | ``` 70 | 71 | Alternatively, you can modify `/db/apps/tuttle/data/tuttle.xml` _and_ `test/fixtures/alt-tuttle.xml`, `test/fixtures/alt-repo-xml-tuttle.xml` to include your tokens. But remember to never commit them! 72 | 73 | Run tests with 74 | 75 | ``` 76 | npm test 77 | ``` 78 | 79 | ## Configuration 80 | 81 | Tuttle is configured in `data/tuttle.xml`. 82 | 83 | New with version 2.0.0: 84 | 85 | A commented example configuration is available `data/tuttle-example-config.xml`. 86 | If you want to update tuttle your modified configuration file will be backed up to 87 | `/db/tuttle-backup/tuttle.xml` and restored on installation of the new version. 88 | 89 | Otherwise, when no back up of an existing config-file is found, the example configuration is copied to `data/tuttle.xml`. 90 | 91 | > [!TIP] 92 | > When migrating from an earlier version you can copy your existing configuration to the backup location: 93 | > `xmldb:copy-resource('/db/apps/tuttle/data', 'tuttle.xml', '/db/tuttle-backup', 'tuttle.xml')` 94 | 95 | ### Repository configuration 96 | 97 | The repositories to keep in sync with a gitservice are all listed under the repos-element. 98 | 99 | The name-attribute refers to the **destination collection** also known as the **target collection**. 100 | 101 | #### Collection 102 | 103 | An example: `` 104 | The collection `/db/apps/tuttle-sample-data` is now considered to be kept in sync with a git repository. 105 | 106 | ```xml 107 | 108 | true 109 | 110 | github 111 | https://api.github.com/ 112 | 113 | tuttle-sample-data 114 | tuttle-sample-data 115 | 116 | a-personal-access-token 117 | 118 | a-branch 119 | 120 | a-exist-user 121 | that-users-password 122 | 123 | ``` 124 | 125 | #### type 126 | 127 | ```xml 128 | gitlab 129 | ``` 130 | 131 | There are two supported git services at the moment `github` and `gitlab` 132 | 133 | #### baseurl 134 | 135 | ```xml 136 | https://api.server/ 137 | ``` 138 | 139 | * For github the baseurl is `https://api.github.com/` or your github-enterprise API endpoint 140 | * For gitlab the baseurl is `https://gitlab.com/api/v4/` but can also be your private gitlab server egg 'https://gitlab.existsolutions.com/api/v4/' 141 | 142 | #### repo, owner and project-id 143 | 144 | * For github you **have to** specify the owner and the repo 145 | * For gitlab you **have to** specify the project-id of the repository 146 | 147 | 148 | #### ref 149 | 150 | ```xml 151 | main 152 | ``` 153 | 154 | Defines the branch you want to track. 155 | 156 | #### hookuser & hookpasswd 157 | 158 | #### token 159 | 160 | If a token is specified Tuttle authenticates against GitHub or GitLab. When a token is not defined, Tuttle assumes a public repository without any authentication. 161 | 162 | > [!NOTE] 163 | > Be aware of the rate limits for unauthenticated requests 164 | > GitHub allows 60 unauthenticated requests per hour but 5,000 for authenticated requests 165 | 166 | > [!TIP] 167 | > It is also possible to pass the token via an environment variable. The name of the variable have to be `tuttle_token_ + collection` (all dashes must be replaces by underscore). Example: `tuttle_token_tuttle_sample_data` 168 | 169 | ##### Create API-Keys for Github / Gitlab 170 | 171 | At this stage of development, the API keys must be generated via the API endpoint `/git/apikey` or for a specific collection `/git/{collection}/apikey`. 172 | 173 | In the configuration `tuttle.xml` the "hookuser" is used to define the dbuser which executes the update. 174 | 175 | Example configuration for GitHub: 176 | * 'Payload URL': https://existdb:8443/exist/apps/tuttle/git/hook 177 | * 'Content type': application/json 178 | 179 | Example configuration for GitLab: 180 | * 'URL' : https://46.23.86.66:8443/exist/apps/tuttle/git/hook 181 | 182 | 183 | ## Dashboard 184 | 185 | The dashboard lists all configured collections showing the health 186 | of all of them at a glance. 187 | Here, you can trigger a full deployment or an incremental update for each collection. 188 | 189 | Full deployment clones the repository from git at ref and installs it as a `.xar` file or just moves the staging collection. 190 | This is a way to get to a known state in case you encounter issues. 191 | An incremental update only applies those changes to the target collection that happened in the repository after the last synchronization. 192 | 193 | > [!NOTE] 194 | > Tuttle is built to keep track of **data collections** 195 | 196 | > [!NOTE] 197 | > Tuttle does not run pre- or post install scripts nor change the index configuration on incremental updates! 198 | 199 | ### Let's start 200 | 201 | 1) customize the configuration (`data/tuttle.xml`) 202 | 2) login to the dashboard 203 | 2) click on 'full' to trigger a full deployment from git to existdb 204 | 3) now you can update your collection with a click on 'incremental' 205 | 206 | Repositories from which a valid XAR (existing `expath-pkg.xml`) package can be generated are installed as a package, all others are created purely on the DB. 207 | 208 | > [!NOTE] 209 | > Note that there may be index problems if a collection is not installed as a package. 210 | 211 | ## API 212 | 213 | The page below is reachable via [api.html](api.html) in your installed tuttle app. 214 | 215 | ![Tuttle](doc/Tuttle-OpenAPI.png) 216 | 217 | ### API endpoint description 218 | 219 | Calling the API without {collection} ``config:default-collection()`` is chosen. 220 | 221 | #### Fetch to staging collection 222 | 223 | `` GET ~/tuttle/{collection}/git`` 224 | 225 | With this most basic endpoint the complete data repository is pulled from the gitservice. 226 | The data will not directly update the target collection but be stored in a staging 227 | collection. 228 | 229 | To update the target collection use another POST request to `/tuttle/git`. 230 | 231 | The data collection is stored in `/db/app/sample-collection-staging`. 232 | 233 | #### Deploy the collection 234 | 235 | `` POST ~/tuttle/{collection}/git`` 236 | 237 | The staging collection `/db/app/sample-collection-staging` is deployed to `/db/app/sample-collection`. All permissions are set and a pre-install function is called if needed. 238 | 239 | #### Incremental update 240 | 241 | `` POST ~/tuttle/{collection}/git`` 242 | 243 | All commits since the last update are applied.To ensure the integrity of the collection, all commits are deployed individually. 244 | 245 | #### Get the repository hashed 246 | 247 | `` GET ~/tuttle/{collection}/hash`` 248 | 249 | Reports the GIT hashed of all participating collections and the hash of the remote repository. 250 | 251 | #### Get Commits 252 | 253 | `` GET ~/tuttle/{collection}/commits`` 254 | 255 | Displays all commits with commit message of the repository. 256 | 257 | #### Hook Trigger 258 | 259 | `` GET ~/tuttle/{collection}/hook`` 260 | 261 | The webhook is usually triggered by GitHub or GitLab. 262 | An incremental update is triggered. 263 | Authentication is done by APIKey. The APIKey must be set in the header of the request. 264 | 265 | #### Example für GitLab 266 | ``` curl --header 'X-Gitlab-Token: RajWFNCILBuQ8SWRfAAAJr7pHxo7WIF8Fe70SGV2Ah' http://127.0.0.1:8080/exist/apps/tuttle/git/hook``` 267 | 268 | ### Generate the APIKey 269 | 270 | `` GET ~/tuttle/{collection}/apikey`` 271 | 272 | The APIKey is generated and displayed once. If forgotten, it must be generated again. 273 | 274 | 275 | ### Display the Repository configuration and status 276 | 277 | `` GET ~/tuttle/config `` 278 | 279 | Displays the configuration and the state of the git repository. 280 | 281 | States: 282 | - uptodate: Collection is up to date with GIT 283 | - behind: Collection is behind GIT and need an update 284 | - new: Collection is not a tuttle collection, full deployment is needed 285 | 286 | ```xml 287 | 288 | sample-collection-github 289 | 290 | 291 | 292 | 293 | 294 | ``` 295 | 296 | ### Remove Lockfile 297 | 298 | `` POST ~/tuttle/{collection}/lockfile `` 299 | 300 | Remove lockfile after anything goes wrong. 301 | 302 | #### Print Lockfile 303 | 304 | `` GET ~/tuttle/{collection}/lockfile `` 305 | 306 | The running task is stored in the lockfile. It ensures that two tasks do not run at the same time. 307 | 308 | 309 | ## Access token for gitservice (incomplete) 310 | 311 | To talk to the configured gitservice Tuttle needs an access token. These can 312 | be obtained from the respective service. 313 | 314 | * see [Creating a personal access token](https://docs.github.com/en/github/authenticating-to-github/creating-a-personal-access-token) for github 315 | * see [Personal access tokens](https://docs.gitlab.com/ee/user/profile/personal_access_tokens.html) for Gitlab 316 | 317 | The key for the gitservice must be configured in Gitservice configuration as shown above. 318 | 319 | ## Roadmap 320 | 321 | - [ ] DB to Git 322 | 323 | ## Honorable mentions: 324 | 325 | ![Horace Parnell Tuttle](src/resources/images/HPTuttle-1866.png) 326 | 327 | [Horace Parnell Tuttle - American astronomer](http://www.klima-luft.de/steinicke/ngcic/persons/tuttle.htm) 328 | 329 | [Archibald "Harry" Tuttle - Robert de Niro in Terry Gilliams' 'Brazil'](https://en.wikipedia.org/wiki/Brazil_(1985_film)) 330 | -------------------------------------------------------------------------------- /src/modules/app.xqm: -------------------------------------------------------------------------------- 1 | xquery version "3.1"; 2 | 3 | module namespace app="http://e-editiones.org/tuttle/app"; 4 | 5 | import module namespace xmldb="http://exist-db.org/xquery/xmldb"; 6 | import module namespace http="http://expath.org/ns/http-client"; 7 | import module namespace compression="http://exist-db.org/xquery/compression"; 8 | import module namespace sm="http://exist-db.org/xquery/securitymanager"; 9 | 10 | import module namespace collection="http://existsolutions.com/modules/collection"; 11 | 12 | import module namespace config="http://e-editiones.org/tuttle/config" at "config.xqm"; 13 | 14 | declare namespace repo="http://exist-db.org/xquery/repo"; 15 | 16 | 17 | declare function app:ignore-reducer($res, $next) { 18 | if ($next = ("build.xml") or starts-with($next, ".git")) 19 | then map:put($res, 'ignored', ($res?ignored, $next)) 20 | else map:put($res, 'new', ($res?new, $next)) 21 | }; 22 | 23 | declare function app:extract-archive($zip as xs:base64Binary, $collection as xs:string) { 24 | compression:unzip($zip, 25 | app:unzip-filter#3, config:ignore(), 26 | app:unzip-store#4, $collection) 27 | }; 28 | 29 | (:~ 30 | : Unzip helper function 31 | :) 32 | declare function app:unzip-store($path as xs:string, $data-type as xs:string, $data as item()?, $base as xs:string) as map(*) { 33 | if ($data-type = 'folder') then ( 34 | let $create := collection:create($base || "/" || substring-after($path, '/')) 35 | return map { "path": $path } 36 | ) else ( 37 | try { 38 | let $resource := app:file-to-resource($base, substring-after($path, '/')) 39 | let $collection-check := collection:create($resource?collection) 40 | let $store := xmldb:store($resource?collection, $resource?name, $data) 41 | return map { "path": $path } 42 | } 43 | catch * { 44 | map { "path": $path, "error": $err:description } 45 | } 46 | ) 47 | }; 48 | 49 | (:~ 50 | : Filter out ignored resources 51 | : returning true() _will_ extract the file or folder 52 | :) 53 | declare function app:unzip-filter($path as xs:string, $data-type as xs:string, $ignore as xs:string*) as xs:boolean { 54 | not(substring-after($path, '/') = $ignore) 55 | }; 56 | 57 | (:~ 58 | : Move staging collection to final collection 59 | :) 60 | declare function app:move-collection($collection-source as xs:string, $collection-target as xs:string) { 61 | xmldb:get-child-collections($collection-source) 62 | ! xmldb:move($collection-source || "/" || ., $collection-target), 63 | xmldb:get-child-resources($collection-source) 64 | ! xmldb:move($collection-source, $collection-target, .) 65 | }; 66 | 67 | (:~ 68 | : Cleanup destination collection - delete collections from target collection 69 | :) 70 | declare function app:cleanup-collection($collection as xs:string) { 71 | let $ignore := (config:ignore(), config:lock()) 72 | return ( 73 | xmldb:get-child-collections($collection)[not(.= $ignore)] 74 | ! xmldb:remove($collection || "/" || .), 75 | xmldb:get-child-resources($collection)[not(.= $ignore)] 76 | ! xmldb:remove($collection, .) 77 | ) 78 | }; 79 | 80 | (:~ 81 | : Random apikey generator 82 | :) 83 | declare function app:random-key($length as xs:int) { 84 | let $secret := 85 | for $loop in 1 to $length 86 | let $random1 := util:random(9)+48 87 | let $random2 := util:random(25)+65 88 | let $random3 := util:random(25)+97 89 | return 90 | if (util:random(2) = 1) then 91 | fn:codepoints-to-string(($random2)) 92 | else if (util:random(2) = 1) then 93 | fn:codepoints-to-string(($random3)) 94 | else 95 | fn:codepoints-to-string(($random1)) 96 | 97 | return string-join($secret) 98 | }; 99 | 100 | (:~ 101 | : Write api key to config:apikeys() 102 | :) 103 | declare function app:write-apikey($collection as xs:string, $apikey as xs:string) { 104 | try { 105 | let $collection-prefix := tokenize(config:apikeys(), '[^/]+$')[1] 106 | let $apikey-resource := xmldb:encode(replace(config:apikeys(), $collection-prefix, "")) 107 | let $collection-check := collection:create($collection-prefix) 108 | 109 | return 110 | if (doc(config:apikeys())//apikeys/collection[name = $collection]/key/text()) then 111 | update replace doc(config:apikeys())//apikeys/collection[name = $collection]/key with {$apikey} 112 | else if (doc(config:apikeys())//apikeys) then 113 | let $add := {$collection}{$apikey} 114 | return update insert $add into doc(config:apikeys())//apikeys 115 | else 116 | let $add := {$collection}{$apikey} 117 | let $store := xmldb:store($collection-prefix, $apikey-resource, $add) 118 | let $chmod := sm:chmod(config:apikeys(), "rw-r-----") 119 | return $store 120 | } 121 | catch * { 122 | map { 123 | "_error": map { 124 | "code": $err:code, "description": $err:description, "value": $err:value, 125 | "line": $err:line-number, "column": $err:column-number, "module": $err:module 126 | } 127 | } 128 | } 129 | }; 130 | 131 | (:~ 132 | : Write lock file 133 | :) 134 | declare function app:lock-write($collection as xs:string, $task as xs:string) { 135 | if (xmldb:collection-available($collection)) then ( 136 | xmldb:store($collection, config:lock(), 137 | { $task }) 138 | ) else () 139 | }; 140 | 141 | (:~ 142 | : Delete lock file 143 | :) 144 | declare function app:lock-remove($collection as xs:string) { 145 | if (doc-available($collection || "/" || config:lock())) then ( 146 | xmldb:remove($collection, config:lock()) 147 | ) else () 148 | }; 149 | 150 | (:~ 151 | : Set permissions to collection recursively 152 | :) 153 | declare function app:set-permission($collection as xs:string) { 154 | let $permissions := app:get-permissions($collection) 155 | let $callback := app:set-permission(?, ?, $permissions) 156 | 157 | return 158 | collection:scan($collection, $callback) 159 | }; 160 | 161 | declare function app:get-permissions ($collection as xs:string) { 162 | if ( 163 | doc-available($collection || "/repo.xml") and 164 | exists(doc($collection || "/repo.xml")//repo:permissions) 165 | ) then ( 166 | let $repo := doc($collection || "/repo.xml")//repo:permissions 167 | return map { 168 | "user": $repo/@user/string(), 169 | "group": $repo/@group/string(), 170 | "mode": $repo/@mode/string() 171 | } 172 | ) else ( 173 | config:sm() 174 | ) 175 | }; 176 | 177 | (:~ 178 | : Set permissions for either a collection or resource 179 | :) 180 | declare function app:set-permission($collection as xs:string, $resource as xs:string?, $permissions as map(*)) { 181 | if (exists($resource)) then ( 182 | xs:anyURI($resource) ! ( 183 | sm:chown(., $permissions?user), 184 | sm:chgrp(., $permissions?group), 185 | sm:chmod(., $permissions?mode) 186 | ) 187 | ) else ( 188 | xs:anyURI($collection) ! ( 189 | sm:chown(., $permissions?user), 190 | sm:chgrp(., $permissions?group), 191 | sm:chmod(., replace($permissions?mode, "(r.)-", "$1x")) 192 | ) 193 | ) 194 | }; 195 | 196 | (:~ 197 | : Write sha and commit time to repo.xml file. 198 | : Falls back to gitsha.xml for collections 199 | : that do not have a repo.xml 200 | :) 201 | declare function app:write-commit-info($collection as xs:string, $commit as map(*)) { 202 | if (doc-available($collection || '/repo.xml')) then ( 203 | let $repoXML := doc($collection || '/repo.xml')//repo:meta 204 | let $updated := 205 | { 206 | $repoXML/@* except ($repoXML/@commit-id, $repoXML/@commit-time, $repoXML/@commit-date), 207 | attribute commit-id { $commit?sha }, 208 | attribute commit-time { app:iso-to-epoch($commit?date) }, 209 | attribute commit-date { $commit?date }, 210 | $repoXML/node() 211 | } 212 | return xmldb:store($collection, "repo.xml", $updated) 213 | ) else ( 214 | (: No repo.xml, write gitsha.xml :) 215 | let $contents := 216 | 217 | { $commit?sha } 218 | { $commit?date } 219 | 220 | return xmldb:store($collection, "gitsha.xml", $contents) 221 | ) 222 | }; 223 | 224 | declare function app:request-json($request as element(http:request)) as item()+ { 225 | let $raw := app:request($request) 226 | return ( 227 | $raw[1], 228 | parse-json(util:base64-decode($raw[2])) 229 | ) 230 | }; 231 | 232 | (:~ 233 | : Github request 234 | :) 235 | declare function app:request($request as element(http:request)) { 236 | (: let $_ := util:log("info", $request/@href) :) 237 | let $response := http:send-request($request) 238 | let $status-code := xs:integer($response[1]/@status) 239 | 240 | return 241 | if ($status-code >= 400) then ( 242 | error(xs:QName("app:connection-error"), "server connection failed: " || $response[1]/@message || " (" || $status-code || ")", $response[1]) 243 | ) else ( 244 | $response 245 | ) 246 | }; 247 | 248 | (:~ 249 | : Resolve relative file path against a base collection 250 | : app:file-to-resource("/db", "a/b/c") -> map { "name": "c", "collection": "/db/a/b/"} 251 | : 252 | : @param $base the absolute DB path to a collection; no slash at the end 253 | : @param $filepath never begins with slash and always points to a resource 254 | : @return a map with name and collection 255 | :) 256 | declare %private function app:file-to-resource($base as xs:string, $filepath as xs:string) as map(*) { 257 | let $parts := tokenize($filepath, '/') 258 | let $rel-path := subsequence($parts, 0, count($parts)) (: cut off last part :) 259 | return map { 260 | "name": xmldb:encode($parts[last()]), 261 | "collection": string-join(($base, $rel-path), "/") || "/" 262 | } 263 | }; 264 | 265 | declare function app:delete-resource($config as map(*), $filepath as xs:string) as xs:boolean { 266 | let $resource := app:file-to-resource($config?path, $filepath) 267 | let $remove := xmldb:remove($resource?collection, $resource?name) 268 | let $remove-empty-col := 269 | if (empty(xmldb:get-child-resources($resource?collection))) then ( 270 | xmldb:remove($resource?collection) 271 | ) else () 272 | 273 | return true() 274 | }; 275 | 276 | (:~ 277 | : Incremental update fetch and add files from git 278 | :) 279 | declare function app:add-resource($config as map(*), $filepath as xs:string, $data as item()) as xs:boolean { 280 | let $resource := app:file-to-resource($config?path, $filepath) 281 | let $permissions := app:get-permissions($config?path) 282 | let $collection-check := 283 | if (xmldb:collection-available($resource?collection)) then ( 284 | ) else ( 285 | collection:create($resource?collection), 286 | app:set-permission($resource?collection, (), $permissions) 287 | ) 288 | 289 | let $store := xmldb:store($resource?collection, $resource?name, $data) 290 | let $chmod := app:set-permission($resource?collection, $store, $permissions) 291 | return true() 292 | }; 293 | 294 | declare function app:iso-to-epoch ($commit-time as xs:string) as xs:integer { 295 | (xs:dateTime($commit-time) - xs:dateTime('1970-01-01T00:00:00')) div xs:dayTimeDuration('PT1S') 296 | }; 297 | -------------------------------------------------------------------------------- /src/modules/github.xqm: -------------------------------------------------------------------------------- 1 | xquery version "3.1"; 2 | 3 | module namespace github="http://e-editiones.org/tuttle/github"; 4 | 5 | import module namespace crypto="http://expath.org/ns/crypto"; 6 | 7 | import module namespace app="http://e-editiones.org/tuttle/app" at "app.xqm"; 8 | import module namespace config="http://e-editiones.org/tuttle/config" at "config.xqm"; 9 | 10 | declare namespace http="http://expath.org/ns/http-client"; 11 | 12 | declare variable $github:max-page-size := 100; 13 | declare variable $github:max-total-result-size := 500; 14 | 15 | declare function github:repo-url($config as map(*)) as xs:string { 16 | ``[`{$config?baseurl}`repos/`{$config?owner}`/`{$config?repo}`]`` 17 | }; 18 | 19 | declare function github:commit-by-ref-url($config as map(*), $ref as xs:string) as xs:string { 20 | github:repo-url($config) || "/commits/" || $ref 21 | }; 22 | 23 | (: 24 | The `commits` API endpoint is _paged_ and will only return _30_ commits by default 25 | it is possible to add the parameter `&per_page=100` (100 being the maximum) 26 | But this will _always_ return 100 commits, which might result in to much overhead 27 | and still might not be enough. 28 | 29 | Pagination: https://docs.github.com/en/rest/guides/using-pagination-in-the-rest-api?apiVersion=2022-11-28 30 | :) 31 | declare function github:commit-ref-url($config as map(*)) as xs:string { 32 | github:repo-url($config) || "/commits?sha=" || $config?ref 33 | }; 34 | 35 | declare function github:commit-ref-url($config as map(*), $per-page as xs:integer) as xs:string { 36 | github:commit-ref-url($config) || "&per_page=" || $per-page 37 | }; 38 | 39 | (:~ 40 | : Clone defines Version repo 41 | :) 42 | declare function github:get-archive($config as map(*), $sha as xs:string) as xs:base64Binary { 43 | github:download-file( 44 | github:repo-url($config) || "/zipball/" || $sha, $config?token) 45 | }; 46 | 47 | (:~ 48 | : Get commit info for a specific sha 49 | :) 50 | declare function github:get-specific-commit($config as map(*), $ref as xs:string) as map(*) { 51 | let $commit := 52 | github:request-json-ignore-pages( 53 | github:commit-by-ref-url($config, $ref), $config?token) 54 | 55 | return map { 56 | "sha" : $commit?sha, 57 | "date": $commit?commit?committer?date 58 | } 59 | }; 60 | 61 | (:~ 62 | : Get the last commit 63 | :) 64 | declare function github:get-last-commit($config as map(*)) as map(*) { 65 | let $commit := 66 | array:head( 67 | github:request-json-ignore-pages( 68 | github:commit-ref-url($config, 1), $config?token)) 69 | 70 | return map { 71 | "sha" : $commit?sha, 72 | "date": $commit?commit?committer?date 73 | } 74 | }; 75 | 76 | (:~ 77 | : Get all commits 78 | :) 79 | declare function github:get-commits($config as map(*)) as array(*)* { 80 | github:get-commits($config, $github:max-page-size) 81 | }; 82 | 83 | (:~ 84 | : Get N commits 85 | :) 86 | declare function github:get-commits($config as map(*), $count as xs:integer) as array(*)* { 87 | if ($count <= 0) 88 | then error(xs:QName("github:illegal-argument"), "$count must be greater than zero in github:get-commits") 89 | else 90 | let $json := github:get-raw-commits($config, $count) 91 | let $commits := 92 | if (empty($json)) 93 | then [] 94 | else if ($count >= array:size($json)) (: return everything :) 95 | then $json 96 | else array:subarray($json, 1, $count) 97 | 98 | return 99 | array:for-each($commits, github:short-commit-info#1) 100 | }; 101 | 102 | declare %private function github:short-commit-info ($commit-info as map(*)) as array(*) { 103 | [ 104 | $commit-info?sha, 105 | $commit-info?commit?message 106 | ] 107 | }; 108 | 109 | (:~ 110 | : Get commits in full 111 | :) 112 | declare function github:get-raw-commits($config as map(*), $count as xs:integer) as array(*)? { 113 | github:get-raw-commits($config, $count, ()) 114 | }; 115 | 116 | (:~ 117 | : Get commits in full, going over pages until we find the commit with the correct hash 118 | :) 119 | declare function github:get-raw-commits ( 120 | $config as map(*), 121 | $count as xs:integer, 122 | $stop-at-commit-id as xs:string? 123 | ) as array(*)? { 124 | let $stop-condition := if (empty($stop-at-commit-id)) then 125 | function ($_) { 126 | (: We are not looking for any SHA. Prevent going over all of the commits in a possibly big repo :) 127 | true() 128 | } 129 | else 130 | function ($results-on-page) { 131 | (: We can stop searching once we have all the commits between 'now' and the commit we looked for :) 132 | let $found-commits := $results-on-page?*?sha 133 | return $found-commits = $stop-at-commit-id 134 | } 135 | 136 | let $results := github:request-json-all-pages( 137 | github:commit-ref-url($config, $count), 138 | $config?token, 139 | $stop-condition 140 | ) 141 | return array { $results?* } 142 | }; 143 | 144 | (:~ 145 | : Get diff between production collection and github-newest 146 | :) 147 | declare function github:get-newest-commits($config as map(*)) as xs:string* { 148 | let $deployed := $config?deployed 149 | let $commits := github:get-raw-commits($config, $github:max-page-size, $deployed) 150 | let $sha := $commits?*?sha 151 | let $how-many := index-of($sha, $deployed) - 1 152 | return 153 | if (empty($how-many)) then ( 154 | error( 155 | xs:QName("github:commit-not-found"), 156 | 'The deployed commit hash ' || $deployed || ' was not found in the list of commits on the remote. Tuttle can only process incremental upgrades of ' || $github:max-total-result-size || '.') 157 | ) else ( 158 | reverse(subsequence($sha, 1, $how-many)) 159 | ) 160 | }; 161 | 162 | (:~ 163 | : Check if sha exist 164 | : TODO: github API might offer a better way to check not only if the commit exists 165 | : but also if this commit is part of `ref` 166 | :) 167 | declare function github:available-sha($config as map(*), $sha as xs:string) as xs:boolean { 168 | $sha = github:get-commits($config)?*?1 169 | }; 170 | 171 | declare function github:get-changes ($collection-config as map(*)) as map(*) { 172 | let $changes := 173 | for $sha in github:get-newest-commits($collection-config) 174 | return github:get-commit-files($collection-config, $sha)?* 175 | 176 | (: aggregate file changes :) 177 | let $aggregated := fold-left($changes, map{}, github:aggregate-filechanges#2) 178 | let $filtered := fold-left($aggregated?new, map{}, app:ignore-reducer#2) 179 | return map { 180 | "del": $aggregated?del, 181 | "new": $filtered?new, 182 | "ignored": $filtered?ignored 183 | } 184 | }; 185 | 186 | (:~ 187 | : Handle edge case where a file created in this changeset is also removed 188 | : 189 | : So, in order to not fire useless and potentially harmful side-effects like 190 | : triggers or indexing we filter out all of these documents as if they were 191 | : never there. 192 | :) 193 | declare function github:aggregate-filechanges ($changes as map(*), $next as map(*)) as map(*) { 194 | switch ($next?status) 195 | case "added" return 196 | let $new := map:put($changes, "new", ($changes?new, $next?filename)) 197 | (: if same file was re-added then remove from it "del" list :) 198 | return map:put($new, "del", $changes?del[. ne $next?filename]) 199 | case "modified" return 200 | (: add to "new" list, make sure each entry is in there only once :) 201 | map:put($changes, "new", ($changes?new[. ne $next?filename], $next?filename)) 202 | case "renamed" return 203 | let $new := map:put($changes, "new", ($changes?new, $next?filename)) 204 | (: account for files that existed, were removed in one commit and then reinstated by renaming a file :) 205 | return map:put($new, "del", ($changes?del[. ne $next?filename], $next?previous_filename)) 206 | case "removed" return 207 | (: ignore this document, if it was added _and_ removed in the same changeset :) 208 | if ($next?filename = $changes?new) 209 | then map:put($changes, "new", $changes?new[. ne $next?filename]) 210 | (: guard against duplicates in deletions :) 211 | else map:put($changes, "del", ($changes?del[. ne $next?filename], $next?filename)) 212 | default return 213 | (: unhandled cases: "copied", "changed", "unchanged" :) 214 | $changes 215 | }; 216 | 217 | (:~ 218 | : Run incremental update on collection in dry mode 219 | :) 220 | declare function github:incremental-dry($config as map(*)) { 221 | let $changes := github:get-changes($config) 222 | return map { 223 | 'new': array{ $changes?new }, 224 | 'del': array{ $changes?del }, 225 | 'ignored': array{ $changes?ignored } 226 | } 227 | }; 228 | 229 | (:~ 230 | : Run incremental update on collection 231 | :) 232 | declare function github:incremental($config as map(*)) { 233 | let $last-commit := github:get-last-commit($config) 234 | let $changes := github:get-changes($config) 235 | let $del := github:incremental-delete($config, $changes?del) 236 | let $new := github:incremental-add($config, $changes?new, $last-commit?sha) 237 | let $writesha := app:write-commit-info($config?path, $last-commit) 238 | return map { 239 | 'new': array{ $new }, 240 | 'del': array{ $del }, 241 | 'ignored': array{ $changes?ignored } 242 | } 243 | }; 244 | 245 | 246 | (:~ 247 | : Get files removed and added from commit 248 | :) 249 | declare function github:get-commit-files($config as map(*), $sha as xs:string) as array(*) { 250 | let $url := github:repo-url($config) || "/commits/" || $sha 251 | let $commit := github:request-json-all-pages($url, $config?token) 252 | 253 | return array { $commit?files?* } 254 | }; 255 | 256 | (: TODO: make raw url configurable :) 257 | declare variable $github:raw-usercontent-endpoint := "https://raw.githubusercontent.com"; 258 | 259 | (:~ 260 | : Get blob of a file 261 | : https://raw.githubusercontent.com//// 262 | :) 263 | declare %private function github:get-blob($config as map(*), $filename as xs:string, $sha as xs:string) { 264 | if (not(starts-with($config?baseurl, "https://api.github.com"))) then ( 265 | (: for GitHub enterprise we have to query for the download url, this might return the contents directly :) 266 | let $blob-url := github:repo-url($config) || "/contents/" || escape-html-uri($filename) || "?ref=" || $sha 267 | let $json := github:request-json($blob-url, $config?token) 268 | let $content := $json?content 269 | 270 | return 271 | if ($json?content = "") (: endpoint did not return base64 encoded contents :) 272 | then github:download-file($json?download_url, $config?token) 273 | else util:base64-decode($content) 274 | ) else ( 275 | (: for github.com we can construct the download url :) 276 | let $blob-url := string-join(( 277 | $github:raw-usercontent-endpoint, 278 | $config?owner, 279 | $config?repo, 280 | $sha, 281 | escape-html-uri($filename) 282 | ), "/") 283 | return github:download-file($blob-url, $config?token) 284 | ) 285 | }; 286 | 287 | (:~ 288 | : Get HTTP-URL 289 | :) 290 | declare function github:get-url($config as map(*)) { 291 | let $repo-info := github:request-json(github:repo-url($config), $config?token) 292 | return $repo-info?html_url 293 | }; 294 | 295 | (:~ 296 | : Check signature for Webhook 297 | :) 298 | declare function github:check-signature($collection as xs:string, $apikey as xs:string) as xs:boolean { 299 | let $signature := request:get-header("X-Hub-Signature-256") 300 | let $payload := util:binary-to-string(request:get-data()) 301 | let $private-key := doc(config:apikeys())//apikeys/collection[name = $collection]/key/string() 302 | let $expected-signature := "sha256=" || crypto:hmac($payload, $private-key, "HmacSha256", "hex") 303 | 304 | return $signature = $expected-signature 305 | }; 306 | 307 | (:~ 308 | : Incremental updates delete files 309 | :) 310 | declare %private function github:incremental-delete($config as map(*), $files as xs:string*) as array(*)* { 311 | for $filepath in $files 312 | return 313 | try { 314 | [ $filepath, app:delete-resource($config, $filepath) ] 315 | } 316 | catch * { 317 | if (contains($err:description, "not found")) then ( 318 | [ $filepath, true()] 319 | ) else ( 320 | [ $filepath, false(), map{ 321 | "code": $err:code, "description": $err:description, "value": $err:value, 322 | "line": $err:line-number, "column": $err:column-number, "module": $err:module 323 | }] 324 | ) 325 | } 326 | }; 327 | 328 | (:~ 329 | : Incremental update fetch and add files from git 330 | :) 331 | declare %private function github:incremental-add($config as map(*), $files as xs:string*, $sha as xs:string) as array(*)* { 332 | for $filepath in $files 333 | return 334 | try { 335 | [ $filepath, 336 | app:add-resource($config, $filepath, 337 | github:get-blob($config, $filepath, $sha))] 338 | } 339 | catch * { 340 | [ $filepath, false(), map{ 341 | "code": $err:code, "description": $err:description, "value": $err:value, 342 | "line": $err:line-number, "column": $err:column-number, "module": $err:module 343 | }] 344 | } 345 | }; 346 | 347 | (:~ 348 | : Github request 349 | :) 350 | 351 | (:~ 352 | : If the response header `link` contains rel="next", there are commits missing. 353 | 356 | :) 357 | declare %private function github:has-next-page($response as element(http:response)) { 358 | exists($response/http:header[@name="link"]) 359 | }; 360 | 361 | declare %private function github:parse-link-header($link-header as xs:string) as map(*) { 362 | map:merge( 363 | tokenize($link-header, ', ') 364 | ! array { tokenize(., '; ') } 365 | ! map { 366 | replace(?2, "rel=""(.*?)""", "$1") : substring(?1, 2, string-length(?1) - 2) 367 | } 368 | ) 369 | }; 370 | 371 | declare variable $github:accept-header := ; 372 | 373 | (: api calls :) 374 | declare %private function github:request-json($url as xs:string, $token as xs:string?) { 375 | let $response := 376 | app:request-json( 377 | github:build-request($url, ( 378 | $github:accept-header, 379 | github:auth-header($token)) 380 | )) 381 | 382 | return ( 383 | if (github:has-next-page($response[1])) then ( 384 | error( 385 | xs:QName("github:next-page"), 386 | 'Paged github request has next page! URL:' || $url, 387 | github:parse-link-header($response[1]/http:header[@name="link"]/@value)?next 388 | ) 389 | ) else (), 390 | $response[2] 391 | ) 392 | }; 393 | 394 | (:~ 395 | : Get all pages of a specified URL. Github has some paginated endpoints, This function traverses all of 396 | : those and joins the results. 397 | :) 398 | declare %private function github:request-json-all-pages($url as xs:string, $token as xs:string?) { 399 | github:request-json-all-pages($url, $token, function ($_) { (: Traverse all pages :) false() }, ()) 400 | }; 401 | 402 | (:~ 403 | : Overload, adds the $stop-condition callback which is given the contents of the current page 404 | : return `true()` to indicate there are sufficient results and we can stop 405 | :) 406 | declare %private function github:request-json-all-pages( 407 | $url as xs:string, 408 | $token as xs:string?, 409 | $stop-condition as function(map(*)) as xs:boolean 410 | ) { 411 | github:request-json-all-pages($url, $token, $stop-condition, ()) 412 | }; 413 | 414 | declare %private function github:request-json-all-pages( 415 | $url as xs:string, 416 | $token as xs:string?, 417 | $stop-condition as function(map(*)) as xs:boolean, 418 | $acc 419 | ) { 420 | let $response := 421 | app:request-json( 422 | github:build-request($url, ( 423 | $github:accept-header, 424 | github:auth-header($token)) 425 | )) 426 | 427 | let $next-url := 428 | if (github:has-next-page($response[1])) then ( 429 | github:parse-link-header($response[1]/http:header[@name="link"]/@value)?next 430 | ) else () 431 | 432 | let $results-in-this-page := $response[2] 433 | let $all := ($acc, $results-in-this-page) 434 | 435 | let $should-stop := $stop-condition($results-in-this-page) 436 | 437 | return ( 438 | if (not($should-stop) and count($all?*) < $github:max-total-result-size and exists($next-url)) then ( 439 | github:request-json-all-pages($next-url, $token, $stop-condition, $all) 440 | ) else ( 441 | $all 442 | ) 443 | ) 444 | }; 445 | 446 | (:~ 447 | : api calls where it is clear that more pages will be returned but we do not need them 448 | : for instance when the limit is set to 1 result per page when we only need the head commit 449 | :) 450 | declare %private function github:request-json-ignore-pages($url as xs:string, $token as xs:string?) { 451 | app:request-json( 452 | github:build-request($url, ( 453 | $github:accept-header, 454 | github:auth-header($token)) 455 | ))[2] 456 | }; 457 | 458 | (: raw file downloads :) 459 | declare %private function github:download-file ($url as xs:string, $token as xs:string?) { 460 | app:request( 461 | github:build-request($url, 462 | github:auth-header($token)))[2] 463 | }; 464 | 465 | declare %private function github:auth-header($token as xs:string?) as element(http:header)? { 466 | if (empty($token) or $token = "") 467 | then () 468 | else 469 | }; 470 | 471 | declare %private function github:build-request($url as xs:string, $headers as element(http:header)*) as element(http:request) { 472 | { $headers } 473 | }; 474 | -------------------------------------------------------------------------------- /src/modules/api.xq: -------------------------------------------------------------------------------- 1 | xquery version "3.1"; 2 | 3 | declare namespace api="http://exist-db.org/apps/tuttle/api"; 4 | declare namespace output="http://www.w3.org/2010/xslt-xquery-serialization"; 5 | 6 | import module namespace roaster="http://e-editiones.org/roaster"; 7 | import module namespace xmldb="http://exist-db.org/xquery/xmldb"; 8 | import module namespace compression="http://exist-db.org/xquery/compression"; 9 | 10 | import module namespace vcs="http://e-editiones.org/tuttle/vcs" at "vcs.xqm"; 11 | import module namespace app="http://e-editiones.org/tuttle/app" at "app.xqm"; 12 | import module namespace config="http://e-editiones.org/tuttle/config" at "config.xqm"; 13 | import module namespace collection="http://existsolutions.com/modules/collection"; 14 | 15 | 16 | (:~ 17 | : list of definition files to use 18 | :) 19 | declare variable $api:definitions := ("api.json"); 20 | 21 | (:~ 22 | : Post git status 23 | :) 24 | declare function api:get-status($request as map(*)) { 25 | if (contains(request:get-header('Accept'), 'application/json')) 26 | then 27 | map { 28 | 'default': config:default-collection(), 29 | 'repos': array { 30 | for $collection-info in config:list-collections() 31 | return 32 | api:collection-info($collection-info) 33 | } 34 | } 35 | else 36 | 37 | {config:default-collection()} 38 | { 39 | for $collection-info in config:list-collections() 40 | return 41 | api:repo-xml( 42 | api:collection-info($collection-info)) 43 | } 44 | 45 | }; 46 | 47 | declare function api:repo-xml ($info as map(*)) as element(repo) { 48 | element repo { 49 | map:for-each($info, function ($name as xs:string, $value) as attribute() { 50 | attribute { $name } { $value } 51 | }) 52 | } 53 | }; 54 | 55 | declare function api:collection-info ($collection as xs:string) as map(*) { 56 | let $collection-config := api:get-collection-config($collection) 57 | (: hide passwords and tokens :) 58 | let $masked := 59 | map:remove( 60 | map:remove($collection-config, "hookpasswd"), "token") 61 | 62 | return 63 | try { 64 | let $actions := vcs:get-actions($collection-config?type) 65 | let $url := $actions?get-url($collection-config) 66 | let $last-remote-commit := $actions?get-last-commit($collection-config) 67 | let $remote-sha := $last-remote-commit?sha 68 | 69 | let $status := 70 | if ($remote-sha = "") 71 | then "error" 72 | else if (empty($collection-config?deployed)) 73 | then "new" 74 | else if ($collection-config?deployed = $remote-sha) 75 | then "uptodate" 76 | else "behind" 77 | 78 | let $message := 79 | if ($status = "error" ) 80 | then "no commit on remote" 81 | else "remote found" 82 | 83 | return map:merge(( $masked, map { 84 | 'url': $url, 85 | 'remote': $remote-sha, 86 | 'message': $message, 87 | 'status': $status 88 | })) 89 | } 90 | catch * { 91 | map:merge(( $masked, map { 92 | 'message': $err:description, 93 | 'status': 'error' 94 | })) 95 | } 96 | }; 97 | 98 | (:~ 99 | : Post current hash and remote hash 100 | :) 101 | declare function api:get-hash($request as map(*)) as map(*) { 102 | try { 103 | let $collection-config := api:get-collection-config($request?parameters?collection) 104 | let $actions := vcs:get-actions($collection-config?type) 105 | let $collection-staging := $collection-config?path || config:suffix() || "/gitsha.xml" 106 | 107 | let $last-remote-commit := $actions?get-last-commit($collection-config) 108 | 109 | return map { 110 | "remote-hash": $last-remote-commit?sha, 111 | "local-hash": $collection-config?deployed, 112 | "local-staging-hash": doc($collection-staging)/hash/value/string() 113 | } 114 | } 115 | catch * { 116 | api:catch-error("api:get-hash", map { 117 | "code": $err:code, "description": $err:description, "value": $err:value, 118 | "line": $err:line-number, "column": $err:column-number, "module": $err:module 119 | }) 120 | } 121 | }; 122 | 123 | (:~ 124 | : Remove lockfile 125 | :) 126 | declare function api:lock-remove($request as map(*)) as map(*) { 127 | try { 128 | let $config := api:get-collection-config($request?parameters?collection) 129 | let $lockfile := $config?path || "/" || config:lock() 130 | 131 | let $message := 132 | if (not(doc-available($lockfile))) 133 | then "Lockfile " || $lockfile || " does not exist" 134 | else 135 | let $remove := xmldb:remove($config?path, config:lock()) 136 | return "Removed lockfile: " || $lockfile 137 | 138 | return map { "message": $message } 139 | } 140 | catch * { 141 | api:catch-error("api:lock-remove", map { 142 | "code": $err:code, "description": $err:description, "value": $err:value, 143 | "line": $err:line-number, "column": $err:column-number, "module": $err:module 144 | }) 145 | } 146 | }; 147 | 148 | (:~ 149 | : Print lockfile 150 | :) 151 | declare function api:lock-print($request as map(*)) as map(*) { 152 | try { 153 | let $config := api:get-collection-config($request?parameters?collection) 154 | let $lockfile := $config?path || '/' || config:lock() 155 | let $message := 156 | if (not(doc-available($lockfile))) 157 | then "No lockfile for '" || $config?collection || "' found." 158 | else doc($lockfile)/task/value/string() || " in progress" 159 | 160 | return map { "message": $message } 161 | } 162 | catch * { 163 | api:catch-error("api:lock-print", map { 164 | "code": $err:code, "description": $err:description, "value": $err:value, 165 | "line": $err:line-number, "column": $err:column-number, "module": $err:module 166 | }) 167 | } 168 | }; 169 | 170 | (:~ 171 | : Load repository state to staging collection 172 | :) 173 | declare function api:git-pull($request as map(*)) as map(*) { 174 | api:pull( 175 | api:get-collection-config($request?parameters?collection), 176 | $request?parameters?hash) 177 | }; 178 | 179 | (:~ 180 | : Load default repository state to staging collection 181 | :) 182 | declare function api:git-pull-default($request as map(*)) as map(*) { 183 | api:pull( 184 | api:get-default-collection-config(), 185 | $request?parameters?hash) 186 | }; 187 | 188 | (:~ 189 | : Load repository state to staging collection 190 | :) 191 | declare %private function api:pull($config as map(*), $hash as xs:string?) as map(*) { 192 | try { 193 | if (doc-available($config?collection || "/" || config:lock())) then ( 194 | roaster:response(403, "application/json", map { 195 | "message" : doc($config?collection || "/" || config:lock())/task/value/text() || " in progress" 196 | }) 197 | ) 198 | else ( 199 | let $actions := vcs:get-actions($config?type) 200 | let $write-lock := app:lock-write($config?collection, "git-pull") 201 | 202 | let $staging-collection := $config?path || config:suffix() 203 | 204 | let $delete-collection := collection:remove($staging-collection, true()) 205 | let $create-collection := collection:create($staging-collection) 206 | 207 | let $commit := 208 | if (exists($hash)) then ( 209 | $actions?get-specific-commit($config, $hash) 210 | ) else ( 211 | $actions?get-last-commit($config) 212 | ) 213 | 214 | let $zip := $actions?get-archive($config, $commit?sha) 215 | let $extract := app:extract-archive($zip, $staging-collection) 216 | let $_ := app:write-commit-info($staging-collection, $commit) 217 | 218 | let $remove-lock := app:lock-remove($config?collection) 219 | 220 | return map { 221 | "message" : "success", 222 | "hash": $commit?sha, 223 | "collection": $staging-collection 224 | } 225 | ) 226 | } 227 | catch * { 228 | api:catch-error("api:git-deploy", map { 229 | "code": $err:code, "description": $err:description, "value": $err:value, 230 | "line": $err:line-number, "column": $err:column-number, "module": $err:module 231 | }) 232 | } 233 | }; 234 | 235 | (:~ 236 | : Deploy Repo from staging collection to its final destination 237 | :) 238 | declare function api:git-deploy($request as map(*)) as map(*) { 239 | try { 240 | let $config := api:get-collection-config($request?parameters?collection) 241 | let $destination := $config?path 242 | let $lockfile := $config?path || "/" || config:lock() 243 | let $staging := $config?path || config:suffix() 244 | 245 | let $ensure-destination-collection := collection:create($destination) 246 | return 247 | if (not(xmldb:collection-available($staging))) 248 | then map { "message" : "Staging collection '" || $staging || "' does not exist!" } 249 | else if (doc-available($lockfile)) 250 | then map { "message" : doc($lockfile)/task/value/text() || " in progress!" } 251 | else if (exists($ensure-destination-collection?error)) 252 | then map { "message" : "Could not create destination collection!", "error": $ensure-destination-collection?error } 253 | else 254 | let $write-lock := app:lock-write($destination, "deploy") 255 | let $is-expath-package := xmldb:get-child-resources($staging) = ("expath-pkg.xml") 256 | let $deploy := 257 | if ($is-expath-package) 258 | then ( 259 | let $package := doc(concat($staging, "/expath-pkg.xml"))//@name/string() 260 | let $remove-pkg := 261 | if ($package = repo:list()) 262 | then ( 263 | let $undeploy := repo:undeploy($package) 264 | let $remove := repo:remove($package) 265 | return ($undeploy, $remove) 266 | ) 267 | else () 268 | 269 | let $xar := 270 | xmldb:store-as-binary( 271 | $staging, "pkg.xar", 272 | compression:zip(xs:anyURI($staging), true(), $staging)) 273 | 274 | let $install := repo:install-and-deploy-from-db($xar) 275 | return "package installation" 276 | ) 277 | else ( 278 | let $cleanup-col := app:cleanup-collection($destination) 279 | let $move-col := app:move-collection($staging, $destination) 280 | let $set-permissions := app:set-permission($destination) 281 | return "data move" 282 | ) 283 | 284 | let $remove-staging := collection:remove($staging, true()) 285 | let $remove-lock := app:lock-remove($destination) 286 | let $reindex := xmldb:reindex($destination) 287 | 288 | return map { 289 | "hash": config:deployed-sha($destination), 290 | "message": "success" 291 | } 292 | 293 | } 294 | catch * { 295 | api:catch-error("api:git-deploy", map { 296 | "code": $err:code, "description": $err:description, "value": $err:value, 297 | "line": $err:line-number, "column": $err:column-number, "module": $err:module 298 | }) 299 | } 300 | }; 301 | 302 | (:~ 303 | : get commits and comments 304 | :) 305 | declare function api:get-commits($request as map(*)) as map(*) { 306 | try { 307 | let $config := api:get-collection-config($request?parameters?collection) 308 | let $actions := vcs:get-actions($config?type) 309 | 310 | return map { 311 | 'commits': $actions?get-commits($config, $request?parameters?count) 312 | } 313 | } 314 | catch * { 315 | api:catch-error("api:git-commits", map { 316 | "code": $err:code, "description": $err:description, "value": $err:value, 317 | "line": $err:line-number, "column": $err:column-number, "module": $err:module 318 | }) 319 | } 320 | }; 321 | 322 | (:~ 323 | : get commits and comments 324 | :) 325 | declare function api:get-commits-default($request as map(*)) as map(*) { 326 | try { 327 | let $config := api:get-default-collection-config() 328 | let $actions := vcs:get-actions($config?type) 329 | 330 | return map { 331 | 'commits': $actions?get-commits($config, $request?parameters?count) 332 | } 333 | } 334 | catch * { 335 | api:catch-error("api:git-commits-default", map { 336 | "code": $err:code, "description": $err:description, "value": $err:value, 337 | "line": $err:line-number, "column": $err:column-number, "module": $err:module 338 | }) 339 | } 340 | }; 341 | 342 | (:~ 343 | : Trigger incremental update 344 | :) 345 | declare function api:incremental($request as map(*)) as map(*) { 346 | try { 347 | let $config := api:get-collection-config($request?parameters?collection) 348 | let $lockfile := $config?path || "/" || config:lock() 349 | let $actions := vcs:get-actions($config?type) 350 | 351 | let $extend-str := function ($result as xs:string) { 352 | map{ "path": $result } 353 | } 354 | let $extend-arr := function ($result as array(*)) { 355 | map{ 356 | "path": $result?1, 357 | "success": $result?2, 358 | "error": if ($result?2) then () else $result?3 359 | } 360 | } 361 | 362 | return 363 | if (not(xmldb:collection-available($config?path))) then ( 364 | roaster:response(403, map { "message" : "Destination collection not exist" }) 365 | ) 366 | else if (empty($config?deployed)) then ( 367 | roaster:response(403, map { "message" : "Collection not managed by Tuttle" }) 368 | ) 369 | else if ($request?parameters?dry) then 370 | map { 371 | "changes" : 372 | let $changes := $actions?incremental-dry($config) 373 | return map { 374 | 'new': array:for-each($changes?new, $extend-str), 375 | 'del': array:for-each($changes?del, $extend-str), 376 | 'ignored': array:for-each($changes?ignored, $extend-str) 377 | }, 378 | "message" : "dry-run" 379 | } 380 | else if (doc-available($lockfile)) then ( 381 | roaster:response(403, map { "message" : doc($lockfile)/task/value/text() || " in progress" }) 382 | ) 383 | else ( 384 | let $write-lock := app:lock-write($config?path, "incremental") 385 | 386 | let $incremental := 387 | let $changes := $actions?incremental($config) 388 | return map { 389 | 'new': array:for-each($changes?new, $extend-arr), 390 | 'del': array:for-each($changes?del, $extend-arr), 391 | 'ignored': array:for-each($changes?ignored, $extend-str) 392 | } 393 | 394 | (: run callback if configured :) 395 | let $callback := config:get-callback($config) 396 | 397 | let $callback-result := 398 | if (exists($callback)) then 399 | try { 400 | map { "result": $callback($config, $incremental), "success": true() } 401 | } catch * { 402 | map { 403 | "result": (), 404 | "success": false(), 405 | "error": map{ 406 | "code": $err:code, 407 | "description": $err:description 408 | } 409 | } 410 | } 411 | else () 412 | 413 | (: 414 | Check if any of the previous additions, deletions or callback did not succeed 415 | Each action is an array with [result, success (, error)] 416 | :) 417 | let $results := ($incremental?new?*, $incremental?del?*, $callback-result) 418 | let $all-errored-operations := filter($results, function ($a) { exists($a?error) }) 419 | 420 | (: Q: should the lock be upheld in case of errors? :) 421 | return 422 | if (exists($all-errored-operations)) then ( 423 | roaster:response(500, "application/json", map { 424 | "hash": config:deployed-sha($config?path), 425 | "message": "ended with errors", 426 | "changes": $incremental, 427 | "callback": $callback-result, 428 | "errors" : array{ $all-errored-operations } 429 | }) 430 | ) else ( 431 | app:lock-remove($config?path), 432 | map { 433 | "hash": config:deployed-sha($config?path), 434 | "message": "success", 435 | "changes": $incremental, 436 | "callback": $callback-result 437 | } 438 | ) 439 | ) 440 | } 441 | catch * { 442 | api:catch-error("api:incremental", map { 443 | "code": $err:code, "description": $err:description, "value": $err:value, 444 | "line": $err:line-number, "column": $err:column-number, "module": $err:module 445 | }) 446 | } 447 | }; 448 | 449 | (:~ 450 | : APIKey generation for webhooks 451 | :) 452 | declare function api:api-keygen($request as map(*)) as map(*) { 453 | try { 454 | let $config := api:get-collection-config($request?parameters?collection) 455 | let $apikey := app:random-key(42) 456 | let $write-apikey := app:write-apikey($config?collection, $apikey) 457 | 458 | return map { "APIKey" : $apikey } 459 | } 460 | catch * { 461 | api:catch-error("api:api-keygen", map { 462 | "code": $err:code, "description": $err:description, "value": $err:value, 463 | "line": $err:line-number, "column": $err:column-number, "module": $err:module 464 | }) 465 | } 466 | }; 467 | 468 | (:~ 469 | : Webhook function 470 | :) 471 | declare function api:hook($request as map(*)) as map(*) { 472 | try { 473 | let $config := api:get-collection-config($request?parameters?collection) 474 | let $apikey := doc(config:apikeys())//apikeys/collection[name = $config?collection]/key/string() 475 | let $lockfile := $config?path || "/" || config:lock() 476 | let $actions := vcs:get-actions($config?type) 477 | 478 | return 479 | if (empty($apikey)) then ( 480 | roaster:response(403, "application/json", map { "message": "apikey does not exist" }) 481 | ) 482 | else if (doc-available($lockfile)) then ( 483 | roaster:response(403, "application/json", map { "message" : doc($lockfile)/task/value/text() || " in progress" }) 484 | ) 485 | else if (not($actions?check-signature($config?collection, $apikey))) then ( 486 | roaster:response(401, "application/json", map { "message": "Unauthorized"}) 487 | ) 488 | else ( 489 | let $collection-destination-sha := $config?path || "/gitsha.xml" 490 | let $login := xmldb:login($config?path, $config?hookuser, $config?hookpasswd) 491 | let $write-lock := app:lock-write($config?path, "hook") 492 | 493 | let $incremental := $actions?incremental($config) 494 | 495 | let $remove-lock := app:lock-remove($config?path) 496 | 497 | return 498 | map { 499 | "sha": config:deployed-sha($config?path), 500 | "message": "success" 501 | } 502 | ) 503 | } 504 | catch * { 505 | api:catch-error("api:hook", map { 506 | "code": $err:code, "description": $err:description, "value": $err:value, 507 | "line": $err:line-number, "column": $err:column-number, "module": $err:module 508 | }) 509 | } 510 | }; 511 | 512 | (:~ 513 | : This is used as an error-handler in the API definition 514 | :) 515 | declare function api:handle-error($error as map(*)) as element(html) { 516 | 517 | 518 |

Error [{$error?code}]

519 |

{ 520 | if (map:contains($error, "module")) 521 | then ``[An error occurred in `{$error?module}` at line `{$error?line}`, column `{$error?column}`]`` 522 | else "An error occurred!" 523 | }

524 |

Description

525 |

{$error?description}

526 | 527 | 528 | }; 529 | 530 | declare %private 531 | function api:catch-error($function, $error as map(*)) { 532 | util:log('error', ($function, ': [', $error?code, '] ', $error?description)), 533 | roaster:response(500, "application/json", map { 534 | "message": $error?description, "error": $error 535 | }) 536 | }; 537 | 538 | declare %private function api:get-default-collection-config() as map(*)? { 539 | config:collections(config:default-collection()) 540 | }; 541 | 542 | declare %private function api:get-collection-config($collection as xs:string?) as map(*)? { 543 | let $git-collection := 544 | if (exists($collection) and $collection ne '') 545 | then xmldb:decode-uri($collection) 546 | else config:default-collection() 547 | 548 | let $collection-config := config:collections($git-collection) 549 | 550 | return 551 | if (empty($git-collection)) 552 | then error((), "git collection not found!") 553 | else if (empty($collection-config)) 554 | then error((), "collection config " || $git-collection || " not found!") 555 | else $collection-config 556 | }; 557 | 558 | (: end of route handlers :) 559 | 560 | (:~ 561 | : This function "knows" all modules and their functions 562 | : that are imported here 563 | : You can leave it as it is, but it has to be here 564 | :) 565 | declare function api:lookup ($name as xs:string) { 566 | function-lookup(xs:QName($name), 1) 567 | }; 568 | 569 | roaster:route($api:definitions, api:lookup#1) 570 | --------------------------------------------------------------------------------