first commit
This commit is contained in:
5
.dockerignore
Normal file
5
.dockerignore
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
frontend/node_modules/
|
||||||
|
frontend/build/
|
||||||
|
frontend/.svelte-kit/
|
||||||
|
|
||||||
|
backend/target/
|
||||||
3
.env
Normal file
3
.env
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
SITE_URL=localhost
|
||||||
|
DB_PASSWORD=changeme
|
||||||
|
POSTGRES_VERSION=13.3.0
|
||||||
7
.github/Dockerfile
vendored
Normal file
7
.github/Dockerfile
vendored
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
FROM nikolaik/python-nodejs
|
||||||
|
|
||||||
|
RUN npm install -g @apidevtools/swagger-cli
|
||||||
|
RUN pip install openapi-python-client
|
||||||
|
RUN pip install poetry
|
||||||
|
|
||||||
|
|
||||||
38
.github/ISSUE_TEMPLATE/bug_report.md
vendored
Normal file
38
.github/ISSUE_TEMPLATE/bug_report.md
vendored
Normal file
@@ -0,0 +1,38 @@
|
|||||||
|
---
|
||||||
|
name: Bug report
|
||||||
|
about: Create a report to help us improve
|
||||||
|
title: ''
|
||||||
|
labels: ''
|
||||||
|
assignees: ''
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
**Describe the bug**
|
||||||
|
A clear and concise description of what the bug is.
|
||||||
|
|
||||||
|
**To Reproduce**
|
||||||
|
Steps to reproduce the behavior:
|
||||||
|
1. Go to '...'
|
||||||
|
2. Click on '....'
|
||||||
|
3. Scroll down to '....'
|
||||||
|
4. See error
|
||||||
|
|
||||||
|
**Expected behavior**
|
||||||
|
A clear and concise description of what you expected to happen.
|
||||||
|
|
||||||
|
**Screenshots**
|
||||||
|
If applicable, add screenshots to help explain your problem.
|
||||||
|
|
||||||
|
**Desktop (please complete the following information):**
|
||||||
|
- OS: [e.g. iOS]
|
||||||
|
- Browser [e.g. chrome, safari]
|
||||||
|
- Version [e.g. 22]
|
||||||
|
|
||||||
|
**Smartphone (please complete the following information):**
|
||||||
|
- Device: [e.g. iPhone6]
|
||||||
|
- OS: [e.g. iOS8.1]
|
||||||
|
- Browser [e.g. stock browser, safari]
|
||||||
|
- Version [e.g. 22]
|
||||||
|
|
||||||
|
**Additional context**
|
||||||
|
Add any other context about the problem here.
|
||||||
16
.github/change-versions.sh
vendored
Executable file
16
.github/change-versions.sh
vendored
Executable file
@@ -0,0 +1,16 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
|
||||||
|
VERSION=$1
|
||||||
|
echo "Updating versions to: $VERSION"
|
||||||
|
|
||||||
|
sed -i -e "/^version =/s/= .*/= \"$VERSION\"/" backend/Cargo.toml
|
||||||
|
sed -i -e "/version: /s/: .*/: $VERSION/" backend/openapi.yaml
|
||||||
|
sed -i -e "/\"version\": /s/: .*,/: \"$VERSION\",/" frontend/package.json
|
||||||
|
sed -i -e "/^version =/s/= .*/= \"$VERSION\"/" python-client/wmill/pyproject.toml
|
||||||
|
sed -i -e "/^windmill-api =/s/= .*/= \"\\^$VERSION\"/" python-client/wmill/pyproject.toml
|
||||||
|
sed -i -e "/^version =/s/= .*/= \"$VERSION\"/" python-client/wmill_pg/pyproject.toml
|
||||||
|
sed -i -e "/^wmill =/s/= .*/= \"\\^$VERSION\"/" python-client/wmill_pg/pyproject.toml
|
||||||
|
sed -i -e "/^wmill =/s/= .*/= \">=$VERSION\"/" Pipfile
|
||||||
|
sed -i -e "/^wmill_pg =/s/= .*/= \">=$VERSION\"/" Pipfile
|
||||||
|
|
||||||
|
sed -i -zE "s/name = \"windmill\"\nversion = \"[^\"]*\"\\n(.*)/name = \"windmill\"\nversion = \"$VERSION\"\\n\\1/" backend/Cargo.lock
|
||||||
14
.github/workflows/change-versions.yml
vendored
Normal file
14
.github/workflows/change-versions.yml
vendored
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
name: Change versions
|
||||||
|
on:
|
||||||
|
pull_request:
|
||||||
|
types: [opened, synchronize, reopened]
|
||||||
|
paths:
|
||||||
|
- "version.txt"
|
||||||
|
jobs:
|
||||||
|
change_version:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v2
|
||||||
|
- name: Change versions
|
||||||
|
run: ./.github/change-versions.sh "$(cat version.txt)"
|
||||||
|
- uses: stefanzweifel/git-auto-commit-action@v4
|
||||||
13
.github/workflows/clean-docker.yml
vendored
Normal file
13
.github/workflows/clean-docker.yml
vendored
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
name: Clean docker
|
||||||
|
on:
|
||||||
|
schedule:
|
||||||
|
# * is a special character in YAML so you have to quote this string
|
||||||
|
- cron: "0 0 */2 * *"
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
build:
|
||||||
|
runs-on: [self-hosted, new]
|
||||||
|
steps:
|
||||||
|
- name: clean docker
|
||||||
|
run: |
|
||||||
|
sudo docker system prune -f
|
||||||
18
.github/workflows/deploy_to_windmill.yml
vendored
Normal file
18
.github/workflows/deploy_to_windmill.yml
vendored
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
name: Deploy to windmill.dev
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches: [main]
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
deploy:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v3
|
||||||
|
- name: Deploy to windmill.dev
|
||||||
|
uses: windmill-labs/windmill-gh-action-deploy@v1.0.0
|
||||||
|
with:
|
||||||
|
dry_run: false
|
||||||
|
input_dir: community
|
||||||
|
windmill_workspace: starter
|
||||||
|
windmill_token: ${{ secrets.WINDMILL_API_TOKEN }}
|
||||||
39
.github/workflows/docker-image.yml
vendored
Normal file
39
.github/workflows/docker-image.yml
vendored
Normal file
@@ -0,0 +1,39 @@
|
|||||||
|
name: Docker Image CI
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches: [main]
|
||||||
|
pull_request:
|
||||||
|
types: [opened, synchronize, reopened]
|
||||||
|
|
||||||
|
concurrency:
|
||||||
|
group: ${{ github.ref }}
|
||||||
|
cancel-in-progress: true
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
build:
|
||||||
|
runs-on: [self-hosted, new]
|
||||||
|
env:
|
||||||
|
DOCKER_BUILDKIT: 1
|
||||||
|
steps:
|
||||||
|
# - name: Wait for release to succeed
|
||||||
|
# if: github.ref == 'refs/heads/main'
|
||||||
|
# uses: lewagon/wait-on-check-action@v1.0.0
|
||||||
|
# with:
|
||||||
|
# ref: ${{ github.ref }}
|
||||||
|
# check-name: "Release please"
|
||||||
|
# repo-token: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
# wait-interval: 10
|
||||||
|
- uses: actions/checkout@v2
|
||||||
|
with:
|
||||||
|
fetch-depth: 0
|
||||||
|
- name: deploy staging stack
|
||||||
|
run: |
|
||||||
|
docker build . --cache-from "registry.wimill.xyz/windmill:staging" -t "registry.wimill.xyz/windmill:staging" --build-arg BUILDKIT_INLINE_CACHE=1
|
||||||
|
docker push "registry.wimill.xyz/windmill:staging"
|
||||||
|
- name: deploy demo stack
|
||||||
|
if: github.ref == 'refs/heads/main'
|
||||||
|
run: |
|
||||||
|
docker tag registry.wimill.xyz/windmill:staging registry.wimill.xyz/windmill:main
|
||||||
|
docker push registry.wimill.xyz/windmill:main
|
||||||
|
# - name: pruning unused images
|
||||||
|
# run: sudo docker image prune -a
|
||||||
38
.github/workflows/on-release.yml
vendored
Normal file
38
.github/workflows/on-release.yml
vendored
Normal file
@@ -0,0 +1,38 @@
|
|||||||
|
name: Build LSP Docker
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches: [main]
|
||||||
|
paths:
|
||||||
|
- "python-client/**"
|
||||||
|
- "Pipfile"
|
||||||
|
- ".github/workflows/on-release.yml"
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
build_lsp:
|
||||||
|
runs-on: [self-hosted, new]
|
||||||
|
steps:
|
||||||
|
- name: Wait for release to succeed
|
||||||
|
if: github.ref == 'refs/heads/main'
|
||||||
|
uses: lewagon/wait-on-check-action@v1.0.0
|
||||||
|
with:
|
||||||
|
ref: ${{ github.ref }}
|
||||||
|
check-name: "Release please"
|
||||||
|
repo-token: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
wait-interval: 10
|
||||||
|
- uses: actions/checkout@v2
|
||||||
|
- name: Upload python client
|
||||||
|
env:
|
||||||
|
PYPI_PASSWORD: ${{ secrets.PYPI_PASSWORD }}
|
||||||
|
run: |
|
||||||
|
cd python-client
|
||||||
|
export PATH=$PATH:/usr/local/bin
|
||||||
|
export PATH=$PATH:/root/.local/bin
|
||||||
|
./publish.sh
|
||||||
|
- name: Build the Docker image
|
||||||
|
run: |
|
||||||
|
echo "branch main"
|
||||||
|
sudo docker pull "registry.wimill.xyz/lsp:main" || true
|
||||||
|
sudo docker build -f DockerfileLSP . --cache-from "registry.wimill.xyz/lsp:main" -t "registry.wimill.xyz/lsp:main" --build-arg BUILDKIT_INLINE_CACHE=1
|
||||||
|
- name: push to registry
|
||||||
|
run: |
|
||||||
|
sudo docker push "registry.wimill.xyz/lsp:main"
|
||||||
15
.github/workflows/release-please.yml
vendored
Normal file
15
.github/workflows/release-please.yml
vendored
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches:
|
||||||
|
- main
|
||||||
|
name: release-please
|
||||||
|
jobs:
|
||||||
|
release-please:
|
||||||
|
name: "Release please"
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- uses: GoogleCloudPlatform/release-please-action@v2
|
||||||
|
with:
|
||||||
|
release-type: simple
|
||||||
|
package-name: windmill
|
||||||
|
token: ${{ secrets.PAT_TOKEN }}
|
||||||
5
.gitignore
vendored
Normal file
5
.gitignore
vendored
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
target/
|
||||||
|
.DS_Store
|
||||||
|
local/
|
||||||
|
frontend/src/routes/test.svelte
|
||||||
|
CaddyfileRemoteMalo
|
||||||
72
CHANGELOG.md
Normal file
72
CHANGELOG.md
Normal file
@@ -0,0 +1,72 @@
|
|||||||
|
# Changelog
|
||||||
|
|
||||||
|
## [1.5.0](https://www.github.com/windmill-labs/windmill-server/compare/v1.4.2...v1.5.0) (2022-05-02)
|
||||||
|
|
||||||
|
|
||||||
|
### Features
|
||||||
|
|
||||||
|
* dynamic input ([2aac191](https://www.github.com/windmill-labs/windmill-server/commit/2aac1917c295f478c5d81ed0346857385e591ceb))
|
||||||
|
|
||||||
|
### [1.4.2](https://www.github.com/windmill-labs/windmill-server/compare/v1.4.1...v1.4.2) (2022-04-29)
|
||||||
|
|
||||||
|
|
||||||
|
### Bug Fixes
|
||||||
|
|
||||||
|
* fix release ([13f8e39](https://www.github.com/windmill-labs/windmill-server/commit/13f8e39a11025b82c45dd16092df438f19d910e5))
|
||||||
|
|
||||||
|
### [1.4.1](https://www.github.com/windmill-labs/windmill-server/compare/v1.4.0...v1.4.1) (2022-04-29)
|
||||||
|
|
||||||
|
|
||||||
|
### Bug Fixes
|
||||||
|
|
||||||
|
* base url python client ([b221bf0](https://www.github.com/windmill-labs/windmill-server/commit/b221bf01e30155007a0dcaac2799cc762a7b5f3b))
|
||||||
|
|
||||||
|
## [1.4.0](https://www.github.com/windmill-labs/windmill-server/compare/v1.3.0...v1.4.0) (2022-04-27)
|
||||||
|
|
||||||
|
|
||||||
|
### Features
|
||||||
|
|
||||||
|
* variables backend logic ([9864028](https://www.github.com/windmill-labs/windmill-server/commit/9864028c648cec368f4541145e8b21e10d81627b))
|
||||||
|
* variables backend logic ([d762f93](https://www.github.com/windmill-labs/windmill-server/commit/d762f93a0c75ad73229fd7a56ae3372ff2e8e41a))
|
||||||
|
* variables backend logic ([3e567a8](https://www.github.com/windmill-labs/windmill-server/commit/3e567a8782d0377afde9a362d4bea6dff6cd5b3f))
|
||||||
|
|
||||||
|
## [1.3.0](https://www.github.com/windmill-labs/windmill-server/compare/v1.2.0...v1.3.0) (2022-04-27)
|
||||||
|
|
||||||
|
|
||||||
|
### Features
|
||||||
|
|
||||||
|
* secret decryption is audited ([d5c5877](https://www.github.com/windmill-labs/windmill-server/commit/d5c58771e1ce0acf766843bc38d724044c905569))
|
||||||
|
|
||||||
|
## [1.2.0](https://www.github.com/windmill-labs/windmill-server/compare/v1.1.1...v1.2.0) (2022-04-15)
|
||||||
|
|
||||||
|
|
||||||
|
### Features
|
||||||
|
|
||||||
|
* custom env ([a89e807](https://www.github.com/windmill-labs/windmill-server/commit/a89e807aa4f5ec3584285a1e9ef126a0a77cf766))
|
||||||
|
|
||||||
|
### [1.1.1](https://www.github.com/windmill-labs/windmill-server/compare/v1.1.0...v1.1.1) (2022-03-14)
|
||||||
|
|
||||||
|
|
||||||
|
### Bug Fixes
|
||||||
|
|
||||||
|
* pg commit ([35967d5](https://www.github.com/windmill-labs/windmill-server/commit/35967d50461a5a53ca3d975e908f74ae54c02100))
|
||||||
|
|
||||||
|
## [1.1.0](https://www.github.com/windmill-labs/windmill-server/compare/v1.0.0...v1.1.0) (2022-03-13)
|
||||||
|
|
||||||
|
|
||||||
|
### Features
|
||||||
|
|
||||||
|
* query_pg is in integrated into wmill ([2e36244](https://www.github.com/windmill-labs/windmill-server/commit/2e3624447216009e1e8ec1ba416952697a0ef4d4))
|
||||||
|
|
||||||
|
## 1.0.0 (2022-02-06)
|
||||||
|
|
||||||
|
|
||||||
|
### Features
|
||||||
|
|
||||||
|
* functioning workspaces ([38de8c3](https://www.github.com/windmill-labs/windmill/commit/38de8c3f572f3bd3b8a3252079d930572723ab8a))
|
||||||
|
|
||||||
|
## [0.9.0](https://www.github.com/windmill-labs/windmill/compare/v0.0.1...v0.9.0) (2021-12-30)
|
||||||
|
|
||||||
|
### Features
|
||||||
|
|
||||||
|
- Renaming to windmill
|
||||||
4
Caddyfile
Normal file
4
Caddyfile
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
{$SITE_URL} {
|
||||||
|
bind {$ADDRESS}
|
||||||
|
reverse_proxy /* server:8000
|
||||||
|
}
|
||||||
99
Dockerfile
Normal file
99
Dockerfile
Normal file
@@ -0,0 +1,99 @@
|
|||||||
|
FROM python:3.10-slim-buster as nsjail
|
||||||
|
|
||||||
|
WORKDIR /nsjail
|
||||||
|
|
||||||
|
RUN apt-get -y update \
|
||||||
|
&& apt-get install -y \
|
||||||
|
bison=2:3.3.* \
|
||||||
|
flex=2.6.* \
|
||||||
|
g++=4:8.3.* \
|
||||||
|
gcc=4:8.3.* \
|
||||||
|
git=1:2.20.* \
|
||||||
|
libprotobuf-dev=3.6.* \
|
||||||
|
libnl-route-3-dev=3.4.* \
|
||||||
|
make=4.2.* \
|
||||||
|
pkg-config=0.29-6 \
|
||||||
|
protobuf-compiler=3.6.*
|
||||||
|
|
||||||
|
RUN git clone -b master --single-branch https://github.com/google/nsjail.git . \
|
||||||
|
&& git checkout dccf911fd2659e7b08ce9507c25b2b38ec2c5800
|
||||||
|
RUN make
|
||||||
|
|
||||||
|
FROM mhart/alpine-node:14 as frontend
|
||||||
|
|
||||||
|
# install dependencies
|
||||||
|
WORKDIR /frontend
|
||||||
|
COPY ./frontend/package.json ./frontend/package-lock.json ./
|
||||||
|
RUN npm ci
|
||||||
|
|
||||||
|
# Copy all local files into the image.
|
||||||
|
COPY frontend .
|
||||||
|
RUN mkdir /backend
|
||||||
|
COPY /backend/openapi.yaml /backend/openapi.yaml
|
||||||
|
RUN npm run generate-backend-client
|
||||||
|
RUN npm run build
|
||||||
|
|
||||||
|
FROM rust:slim-buster as builder
|
||||||
|
|
||||||
|
RUN apt-get update && apt-get install -y git libssl-dev pkg-config
|
||||||
|
|
||||||
|
RUN USER=root cargo new --bin windmill
|
||||||
|
WORKDIR /windmill
|
||||||
|
|
||||||
|
COPY ./backend/Cargo.toml .
|
||||||
|
COPY ./backend/Cargo.lock .
|
||||||
|
COPY ./backend/.cargo/ .cargo/
|
||||||
|
|
||||||
|
RUN apt-get -y update \
|
||||||
|
&& apt-get install -y \
|
||||||
|
curl
|
||||||
|
|
||||||
|
ENV CARGO_INCREMENTAL=1
|
||||||
|
|
||||||
|
RUN cargo build --release
|
||||||
|
RUN rm src/*.rs
|
||||||
|
|
||||||
|
RUN rm ./target/release/deps/windmill*
|
||||||
|
ENV SQLX_OFFLINE=true
|
||||||
|
|
||||||
|
ADD ./backend ./
|
||||||
|
ADD ./nsjail /nsjail
|
||||||
|
|
||||||
|
COPY --from=1 /frontend /frontend
|
||||||
|
ADD .git/ .git/
|
||||||
|
|
||||||
|
RUN cargo build --release
|
||||||
|
|
||||||
|
|
||||||
|
FROM debian:buster-slim
|
||||||
|
ARG APP=/usr/src/app
|
||||||
|
|
||||||
|
RUN apt-get update \
|
||||||
|
&& apt-get install -y ca-certificates tzdata libpq5 python3 python3-pip \
|
||||||
|
make build-essential libssl-dev zlib1g-dev libbz2-dev libreadline-dev \
|
||||||
|
libsqlite3-dev wget curl llvm libncurses5-dev libncursesw5-dev xz-utils tk-dev libxml2-dev \
|
||||||
|
libxmlsec1-dev libffi-dev liblzma-dev mecab-ipadic-utf8 libgdbm-dev libc6-dev git libprotobuf-dev=3.6.* libnl-route-3-dev=3.4.* \
|
||||||
|
libv8-dev \
|
||||||
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
|
ENV TZ=Etc/UTC
|
||||||
|
|
||||||
|
ENV PYTHON_VERSION 3.10.4
|
||||||
|
|
||||||
|
RUN wget https://www.python.org/ftp/python/${PYTHON_VERSION}/Python-${PYTHON_VERSION}.tgz \
|
||||||
|
&& tar -xf Python-${PYTHON_VERSION}.tgz && cd Python-${PYTHON_VERSION}/ && ./configure --enable-optimizations \
|
||||||
|
&& make -j 4 && make install
|
||||||
|
|
||||||
|
RUN python3 -m pip install pip-tools
|
||||||
|
|
||||||
|
COPY --from=builder /windmill/target/release/windmill ${APP}/windmill
|
||||||
|
|
||||||
|
COPY --from=nsjail /nsjail/nsjail /bin/nsjail
|
||||||
|
|
||||||
|
RUN mkdir -p ${APP}
|
||||||
|
|
||||||
|
WORKDIR ${APP}
|
||||||
|
|
||||||
|
EXPOSE 8000
|
||||||
|
|
||||||
|
CMD ["./windmill"]
|
||||||
16
DockerfileLSP
Normal file
16
DockerfileLSP
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
FROM nikolaik/python-nodejs
|
||||||
|
|
||||||
|
RUN yarn global add diagnostic-languageserver
|
||||||
|
RUN yarn global add pyright
|
||||||
|
RUN pip3 install black tornado python-lsp-jsonrpc
|
||||||
|
|
||||||
|
COPY Pipfile .
|
||||||
|
|
||||||
|
RUN cat Pipfile
|
||||||
|
|
||||||
|
RUN pipenv install
|
||||||
|
|
||||||
|
COPY pyls_launcher.py .
|
||||||
|
|
||||||
|
CMD ["python3" ,"pyls_launcher.py"]
|
||||||
|
|
||||||
12
LICENSE
Normal file
12
LICENSE
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
|
||||||
|
Source code in this repository is variously licensed under the Apache License
|
||||||
|
Version 2.0 (see file ./LICENSE-APACHE),or the AGPLv3 License (see file ./LICENSE-AGPL)
|
||||||
|
|
||||||
|
Every file is under copyright (c) Ruben Fiszel 2021 unless otherwise specified.
|
||||||
|
Every file is under License AGPL unless otherwise specified
|
||||||
|
or belonging to one of the below cases:
|
||||||
|
|
||||||
|
The files under backend/ are AGPL Licensed.
|
||||||
|
The files under frontend/ are AGPL Licensed.
|
||||||
|
The files under python-client/ are Apache 2.0 Licensed.
|
||||||
|
The files under community/ are Apache 2.0 Licensed.
|
||||||
661
LICENSE-AGPL
Normal file
661
LICENSE-AGPL
Normal file
@@ -0,0 +1,661 @@
|
|||||||
|
GNU AFFERO GENERAL PUBLIC LICENSE
|
||||||
|
Version 3, 19 November 2007
|
||||||
|
|
||||||
|
Copyright (C) 2007 Free Software Foundation, Inc. <https://fsf.org/>
|
||||||
|
Everyone is permitted to copy and distribute verbatim copies
|
||||||
|
of this license document, but changing it is not allowed.
|
||||||
|
|
||||||
|
Preamble
|
||||||
|
|
||||||
|
The GNU Affero General Public License is a free, copyleft license for
|
||||||
|
software and other kinds of works, specifically designed to ensure
|
||||||
|
cooperation with the community in the case of network server software.
|
||||||
|
|
||||||
|
The licenses for most software and other practical works are designed
|
||||||
|
to take away your freedom to share and change the works. By contrast,
|
||||||
|
our General Public Licenses are intended to guarantee your freedom to
|
||||||
|
share and change all versions of a program--to make sure it remains free
|
||||||
|
software for all its users.
|
||||||
|
|
||||||
|
When we speak of free software, we are referring to freedom, not
|
||||||
|
price. Our General Public Licenses are designed to make sure that you
|
||||||
|
have the freedom to distribute copies of free software (and charge for
|
||||||
|
them if you wish), that you receive source code or can get it if you
|
||||||
|
want it, that you can change the software or use pieces of it in new
|
||||||
|
free programs, and that you know you can do these things.
|
||||||
|
|
||||||
|
Developers that use our General Public Licenses protect your rights
|
||||||
|
with two steps: (1) assert copyright on the software, and (2) offer
|
||||||
|
you this License which gives you legal permission to copy, distribute
|
||||||
|
and/or modify the software.
|
||||||
|
|
||||||
|
A secondary benefit of defending all users' freedom is that
|
||||||
|
improvements made in alternate versions of the program, if they
|
||||||
|
receive widespread use, become available for other developers to
|
||||||
|
incorporate. Many developers of free software are heartened and
|
||||||
|
encouraged by the resulting cooperation. However, in the case of
|
||||||
|
software used on network servers, this result may fail to come about.
|
||||||
|
The GNU General Public License permits making a modified version and
|
||||||
|
letting the public access it on a server without ever releasing its
|
||||||
|
source code to the public.
|
||||||
|
|
||||||
|
The GNU Affero General Public License is designed specifically to
|
||||||
|
ensure that, in such cases, the modified source code becomes available
|
||||||
|
to the community. It requires the operator of a network server to
|
||||||
|
provide the source code of the modified version running there to the
|
||||||
|
users of that server. Therefore, public use of a modified version, on
|
||||||
|
a publicly accessible server, gives the public access to the source
|
||||||
|
code of the modified version.
|
||||||
|
|
||||||
|
An older license, called the Affero General Public License and
|
||||||
|
published by Affero, was designed to accomplish similar goals. This is
|
||||||
|
a different license, not a version of the Affero GPL, but Affero has
|
||||||
|
released a new version of the Affero GPL which permits relicensing under
|
||||||
|
this license.
|
||||||
|
|
||||||
|
The precise terms and conditions for copying, distribution and
|
||||||
|
modification follow.
|
||||||
|
|
||||||
|
TERMS AND CONDITIONS
|
||||||
|
|
||||||
|
0. Definitions.
|
||||||
|
|
||||||
|
"This License" refers to version 3 of the GNU Affero General Public License.
|
||||||
|
|
||||||
|
"Copyright" also means copyright-like laws that apply to other kinds of
|
||||||
|
works, such as semiconductor masks.
|
||||||
|
|
||||||
|
"The Program" refers to any copyrightable work licensed under this
|
||||||
|
License. Each licensee is addressed as "you". "Licensees" and
|
||||||
|
"recipients" may be individuals or organizations.
|
||||||
|
|
||||||
|
To "modify" a work means to copy from or adapt all or part of the work
|
||||||
|
in a fashion requiring copyright permission, other than the making of an
|
||||||
|
exact copy. The resulting work is called a "modified version" of the
|
||||||
|
earlier work or a work "based on" the earlier work.
|
||||||
|
|
||||||
|
A "covered work" means either the unmodified Program or a work based
|
||||||
|
on the Program.
|
||||||
|
|
||||||
|
To "propagate" a work means to do anything with it that, without
|
||||||
|
permission, would make you directly or secondarily liable for
|
||||||
|
infringement under applicable copyright law, except executing it on a
|
||||||
|
computer or modifying a private copy. Propagation includes copying,
|
||||||
|
distribution (with or without modification), making available to the
|
||||||
|
public, and in some countries other activities as well.
|
||||||
|
|
||||||
|
To "convey" a work means any kind of propagation that enables other
|
||||||
|
parties to make or receive copies. Mere interaction with a user through
|
||||||
|
a computer network, with no transfer of a copy, is not conveying.
|
||||||
|
|
||||||
|
An interactive user interface displays "Appropriate Legal Notices"
|
||||||
|
to the extent that it includes a convenient and prominently visible
|
||||||
|
feature that (1) displays an appropriate copyright notice, and (2)
|
||||||
|
tells the user that there is no warranty for the work (except to the
|
||||||
|
extent that warranties are provided), that licensees may convey the
|
||||||
|
work under this License, and how to view a copy of this License. If
|
||||||
|
the interface presents a list of user commands or options, such as a
|
||||||
|
menu, a prominent item in the list meets this criterion.
|
||||||
|
|
||||||
|
1. Source Code.
|
||||||
|
|
||||||
|
The "source code" for a work means the preferred form of the work
|
||||||
|
for making modifications to it. "Object code" means any non-source
|
||||||
|
form of a work.
|
||||||
|
|
||||||
|
A "Standard Interface" means an interface that either is an official
|
||||||
|
standard defined by a recognized standards body, or, in the case of
|
||||||
|
interfaces specified for a particular programming language, one that
|
||||||
|
is widely used among developers working in that language.
|
||||||
|
|
||||||
|
The "System Libraries" of an executable work include anything, other
|
||||||
|
than the work as a whole, that (a) is included in the normal form of
|
||||||
|
packaging a Major Component, but which is not part of that Major
|
||||||
|
Component, and (b) serves only to enable use of the work with that
|
||||||
|
Major Component, or to implement a Standard Interface for which an
|
||||||
|
implementation is available to the public in source code form. A
|
||||||
|
"Major Component", in this context, means a major essential component
|
||||||
|
(kernel, window system, and so on) of the specific operating system
|
||||||
|
(if any) on which the executable work runs, or a compiler used to
|
||||||
|
produce the work, or an object code interpreter used to run it.
|
||||||
|
|
||||||
|
The "Corresponding Source" for a work in object code form means all
|
||||||
|
the source code needed to generate, install, and (for an executable
|
||||||
|
work) run the object code and to modify the work, including scripts to
|
||||||
|
control those activities. However, it does not include the work's
|
||||||
|
System Libraries, or general-purpose tools or generally available free
|
||||||
|
programs which are used unmodified in performing those activities but
|
||||||
|
which are not part of the work. For example, Corresponding Source
|
||||||
|
includes interface definition files associated with source files for
|
||||||
|
the work, and the source code for shared libraries and dynamically
|
||||||
|
linked subprograms that the work is specifically designed to require,
|
||||||
|
such as by intimate data communication or control flow between those
|
||||||
|
subprograms and other parts of the work.
|
||||||
|
|
||||||
|
The Corresponding Source need not include anything that users
|
||||||
|
can regenerate automatically from other parts of the Corresponding
|
||||||
|
Source.
|
||||||
|
|
||||||
|
The Corresponding Source for a work in source code form is that
|
||||||
|
same work.
|
||||||
|
|
||||||
|
2. Basic Permissions.
|
||||||
|
|
||||||
|
All rights granted under this License are granted for the term of
|
||||||
|
copyright on the Program, and are irrevocable provided the stated
|
||||||
|
conditions are met. This License explicitly affirms your unlimited
|
||||||
|
permission to run the unmodified Program. The output from running a
|
||||||
|
covered work is covered by this License only if the output, given its
|
||||||
|
content, constitutes a covered work. This License acknowledges your
|
||||||
|
rights of fair use or other equivalent, as provided by copyright law.
|
||||||
|
|
||||||
|
You may make, run and propagate covered works that you do not
|
||||||
|
convey, without conditions so long as your license otherwise remains
|
||||||
|
in force. You may convey covered works to others for the sole purpose
|
||||||
|
of having them make modifications exclusively for you, or provide you
|
||||||
|
with facilities for running those works, provided that you comply with
|
||||||
|
the terms of this License in conveying all material for which you do
|
||||||
|
not control copyright. Those thus making or running the covered works
|
||||||
|
for you must do so exclusively on your behalf, under your direction
|
||||||
|
and control, on terms that prohibit them from making any copies of
|
||||||
|
your copyrighted material outside their relationship with you.
|
||||||
|
|
||||||
|
Conveying under any other circumstances is permitted solely under
|
||||||
|
the conditions stated below. Sublicensing is not allowed; section 10
|
||||||
|
makes it unnecessary.
|
||||||
|
|
||||||
|
3. Protecting Users' Legal Rights From Anti-Circumvention Law.
|
||||||
|
|
||||||
|
No covered work shall be deemed part of an effective technological
|
||||||
|
measure under any applicable law fulfilling obligations under article
|
||||||
|
11 of the WIPO copyright treaty adopted on 20 December 1996, or
|
||||||
|
similar laws prohibiting or restricting circumvention of such
|
||||||
|
measures.
|
||||||
|
|
||||||
|
When you convey a covered work, you waive any legal power to forbid
|
||||||
|
circumvention of technological measures to the extent such circumvention
|
||||||
|
is effected by exercising rights under this License with respect to
|
||||||
|
the covered work, and you disclaim any intention to limit operation or
|
||||||
|
modification of the work as a means of enforcing, against the work's
|
||||||
|
users, your or third parties' legal rights to forbid circumvention of
|
||||||
|
technological measures.
|
||||||
|
|
||||||
|
4. Conveying Verbatim Copies.
|
||||||
|
|
||||||
|
You may convey verbatim copies of the Program's source code as you
|
||||||
|
receive it, in any medium, provided that you conspicuously and
|
||||||
|
appropriately publish on each copy an appropriate copyright notice;
|
||||||
|
keep intact all notices stating that this License and any
|
||||||
|
non-permissive terms added in accord with section 7 apply to the code;
|
||||||
|
keep intact all notices of the absence of any warranty; and give all
|
||||||
|
recipients a copy of this License along with the Program.
|
||||||
|
|
||||||
|
You may charge any price or no price for each copy that you convey,
|
||||||
|
and you may offer support or warranty protection for a fee.
|
||||||
|
|
||||||
|
5. Conveying Modified Source Versions.
|
||||||
|
|
||||||
|
You may convey a work based on the Program, or the modifications to
|
||||||
|
produce it from the Program, in the form of source code under the
|
||||||
|
terms of section 4, provided that you also meet all of these conditions:
|
||||||
|
|
||||||
|
a) The work must carry prominent notices stating that you modified
|
||||||
|
it, and giving a relevant date.
|
||||||
|
|
||||||
|
b) The work must carry prominent notices stating that it is
|
||||||
|
released under this License and any conditions added under section
|
||||||
|
7. This requirement modifies the requirement in section 4 to
|
||||||
|
"keep intact all notices".
|
||||||
|
|
||||||
|
c) You must license the entire work, as a whole, under this
|
||||||
|
License to anyone who comes into possession of a copy. This
|
||||||
|
License will therefore apply, along with any applicable section 7
|
||||||
|
additional terms, to the whole of the work, and all its parts,
|
||||||
|
regardless of how they are packaged. This License gives no
|
||||||
|
permission to license the work in any other way, but it does not
|
||||||
|
invalidate such permission if you have separately received it.
|
||||||
|
|
||||||
|
d) If the work has interactive user interfaces, each must display
|
||||||
|
Appropriate Legal Notices; however, if the Program has interactive
|
||||||
|
interfaces that do not display Appropriate Legal Notices, your
|
||||||
|
work need not make them do so.
|
||||||
|
|
||||||
|
A compilation of a covered work with other separate and independent
|
||||||
|
works, which are not by their nature extensions of the covered work,
|
||||||
|
and which are not combined with it such as to form a larger program,
|
||||||
|
in or on a volume of a storage or distribution medium, is called an
|
||||||
|
"aggregate" if the compilation and its resulting copyright are not
|
||||||
|
used to limit the access or legal rights of the compilation's users
|
||||||
|
beyond what the individual works permit. Inclusion of a covered work
|
||||||
|
in an aggregate does not cause this License to apply to the other
|
||||||
|
parts of the aggregate.
|
||||||
|
|
||||||
|
6. Conveying Non-Source Forms.
|
||||||
|
|
||||||
|
You may convey a covered work in object code form under the terms
|
||||||
|
of sections 4 and 5, provided that you also convey the
|
||||||
|
machine-readable Corresponding Source under the terms of this License,
|
||||||
|
in one of these ways:
|
||||||
|
|
||||||
|
a) Convey the object code in, or embodied in, a physical product
|
||||||
|
(including a physical distribution medium), accompanied by the
|
||||||
|
Corresponding Source fixed on a durable physical medium
|
||||||
|
customarily used for software interchange.
|
||||||
|
|
||||||
|
b) Convey the object code in, or embodied in, a physical product
|
||||||
|
(including a physical distribution medium), accompanied by a
|
||||||
|
written offer, valid for at least three years and valid for as
|
||||||
|
long as you offer spare parts or customer support for that product
|
||||||
|
model, to give anyone who possesses the object code either (1) a
|
||||||
|
copy of the Corresponding Source for all the software in the
|
||||||
|
product that is covered by this License, on a durable physical
|
||||||
|
medium customarily used for software interchange, for a price no
|
||||||
|
more than your reasonable cost of physically performing this
|
||||||
|
conveying of source, or (2) access to copy the
|
||||||
|
Corresponding Source from a network server at no charge.
|
||||||
|
|
||||||
|
c) Convey individual copies of the object code with a copy of the
|
||||||
|
written offer to provide the Corresponding Source. This
|
||||||
|
alternative is allowed only occasionally and noncommercially, and
|
||||||
|
only if you received the object code with such an offer, in accord
|
||||||
|
with subsection 6b.
|
||||||
|
|
||||||
|
d) Convey the object code by offering access from a designated
|
||||||
|
place (gratis or for a charge), and offer equivalent access to the
|
||||||
|
Corresponding Source in the same way through the same place at no
|
||||||
|
further charge. You need not require recipients to copy the
|
||||||
|
Corresponding Source along with the object code. If the place to
|
||||||
|
copy the object code is a network server, the Corresponding Source
|
||||||
|
may be on a different server (operated by you or a third party)
|
||||||
|
that supports equivalent copying facilities, provided you maintain
|
||||||
|
clear directions next to the object code saying where to find the
|
||||||
|
Corresponding Source. Regardless of what server hosts the
|
||||||
|
Corresponding Source, you remain obligated to ensure that it is
|
||||||
|
available for as long as needed to satisfy these requirements.
|
||||||
|
|
||||||
|
e) Convey the object code using peer-to-peer transmission, provided
|
||||||
|
you inform other peers where the object code and Corresponding
|
||||||
|
Source of the work are being offered to the general public at no
|
||||||
|
charge under subsection 6d.
|
||||||
|
|
||||||
|
A separable portion of the object code, whose source code is excluded
|
||||||
|
from the Corresponding Source as a System Library, need not be
|
||||||
|
included in conveying the object code work.
|
||||||
|
|
||||||
|
A "User Product" is either (1) a "consumer product", which means any
|
||||||
|
tangible personal property which is normally used for personal, family,
|
||||||
|
or household purposes, or (2) anything designed or sold for incorporation
|
||||||
|
into a dwelling. In determining whether a product is a consumer product,
|
||||||
|
doubtful cases shall be resolved in favor of coverage. For a particular
|
||||||
|
product received by a particular user, "normally used" refers to a
|
||||||
|
typical or common use of that class of product, regardless of the status
|
||||||
|
of the particular user or of the way in which the particular user
|
||||||
|
actually uses, or expects or is expected to use, the product. A product
|
||||||
|
is a consumer product regardless of whether the product has substantial
|
||||||
|
commercial, industrial or non-consumer uses, unless such uses represent
|
||||||
|
the only significant mode of use of the product.
|
||||||
|
|
||||||
|
"Installation Information" for a User Product means any methods,
|
||||||
|
procedures, authorization keys, or other information required to install
|
||||||
|
and execute modified versions of a covered work in that User Product from
|
||||||
|
a modified version of its Corresponding Source. The information must
|
||||||
|
suffice to ensure that the continued functioning of the modified object
|
||||||
|
code is in no case prevented or interfered with solely because
|
||||||
|
modification has been made.
|
||||||
|
|
||||||
|
If you convey an object code work under this section in, or with, or
|
||||||
|
specifically for use in, a User Product, and the conveying occurs as
|
||||||
|
part of a transaction in which the right of possession and use of the
|
||||||
|
User Product is transferred to the recipient in perpetuity or for a
|
||||||
|
fixed term (regardless of how the transaction is characterized), the
|
||||||
|
Corresponding Source conveyed under this section must be accompanied
|
||||||
|
by the Installation Information. But this requirement does not apply
|
||||||
|
if neither you nor any third party retains the ability to install
|
||||||
|
modified object code on the User Product (for example, the work has
|
||||||
|
been installed in ROM).
|
||||||
|
|
||||||
|
The requirement to provide Installation Information does not include a
|
||||||
|
requirement to continue to provide support service, warranty, or updates
|
||||||
|
for a work that has been modified or installed by the recipient, or for
|
||||||
|
the User Product in which it has been modified or installed. Access to a
|
||||||
|
network may be denied when the modification itself materially and
|
||||||
|
adversely affects the operation of the network or violates the rules and
|
||||||
|
protocols for communication across the network.
|
||||||
|
|
||||||
|
Corresponding Source conveyed, and Installation Information provided,
|
||||||
|
in accord with this section must be in a format that is publicly
|
||||||
|
documented (and with an implementation available to the public in
|
||||||
|
source code form), and must require no special password or key for
|
||||||
|
unpacking, reading or copying.
|
||||||
|
|
||||||
|
7. Additional Terms.
|
||||||
|
|
||||||
|
"Additional permissions" are terms that supplement the terms of this
|
||||||
|
License by making exceptions from one or more of its conditions.
|
||||||
|
Additional permissions that are applicable to the entire Program shall
|
||||||
|
be treated as though they were included in this License, to the extent
|
||||||
|
that they are valid under applicable law. If additional permissions
|
||||||
|
apply only to part of the Program, that part may be used separately
|
||||||
|
under those permissions, but the entire Program remains governed by
|
||||||
|
this License without regard to the additional permissions.
|
||||||
|
|
||||||
|
When you convey a copy of a covered work, you may at your option
|
||||||
|
remove any additional permissions from that copy, or from any part of
|
||||||
|
it. (Additional permissions may be written to require their own
|
||||||
|
removal in certain cases when you modify the work.) You may place
|
||||||
|
additional permissions on material, added by you to a covered work,
|
||||||
|
for which you have or can give appropriate copyright permission.
|
||||||
|
|
||||||
|
Notwithstanding any other provision of this License, for material you
|
||||||
|
add to a covered work, you may (if authorized by the copyright holders of
|
||||||
|
that material) supplement the terms of this License with terms:
|
||||||
|
|
||||||
|
a) Disclaiming warranty or limiting liability differently from the
|
||||||
|
terms of sections 15 and 16 of this License; or
|
||||||
|
|
||||||
|
b) Requiring preservation of specified reasonable legal notices or
|
||||||
|
author attributions in that material or in the Appropriate Legal
|
||||||
|
Notices displayed by works containing it; or
|
||||||
|
|
||||||
|
c) Prohibiting misrepresentation of the origin of that material, or
|
||||||
|
requiring that modified versions of such material be marked in
|
||||||
|
reasonable ways as different from the original version; or
|
||||||
|
|
||||||
|
d) Limiting the use for publicity purposes of names of licensors or
|
||||||
|
authors of the material; or
|
||||||
|
|
||||||
|
e) Declining to grant rights under trademark law for use of some
|
||||||
|
trade names, trademarks, or service marks; or
|
||||||
|
|
||||||
|
f) Requiring indemnification of licensors and authors of that
|
||||||
|
material by anyone who conveys the material (or modified versions of
|
||||||
|
it) with contractual assumptions of liability to the recipient, for
|
||||||
|
any liability that these contractual assumptions directly impose on
|
||||||
|
those licensors and authors.
|
||||||
|
|
||||||
|
All other non-permissive additional terms are considered "further
|
||||||
|
restrictions" within the meaning of section 10. If the Program as you
|
||||||
|
received it, or any part of it, contains a notice stating that it is
|
||||||
|
governed by this License along with a term that is a further
|
||||||
|
restriction, you may remove that term. If a license document contains
|
||||||
|
a further restriction but permits relicensing or conveying under this
|
||||||
|
License, you may add to a covered work material governed by the terms
|
||||||
|
of that license document, provided that the further restriction does
|
||||||
|
not survive such relicensing or conveying.
|
||||||
|
|
||||||
|
If you add terms to a covered work in accord with this section, you
|
||||||
|
must place, in the relevant source files, a statement of the
|
||||||
|
additional terms that apply to those files, or a notice indicating
|
||||||
|
where to find the applicable terms.
|
||||||
|
|
||||||
|
Additional terms, permissive or non-permissive, may be stated in the
|
||||||
|
form of a separately written license, or stated as exceptions;
|
||||||
|
the above requirements apply either way.
|
||||||
|
|
||||||
|
8. Termination.
|
||||||
|
|
||||||
|
You may not propagate or modify a covered work except as expressly
|
||||||
|
provided under this License. Any attempt otherwise to propagate or
|
||||||
|
modify it is void, and will automatically terminate your rights under
|
||||||
|
this License (including any patent licenses granted under the third
|
||||||
|
paragraph of section 11).
|
||||||
|
|
||||||
|
However, if you cease all violation of this License, then your
|
||||||
|
license from a particular copyright holder is reinstated (a)
|
||||||
|
provisionally, unless and until the copyright holder explicitly and
|
||||||
|
finally terminates your license, and (b) permanently, if the copyright
|
||||||
|
holder fails to notify you of the violation by some reasonable means
|
||||||
|
prior to 60 days after the cessation.
|
||||||
|
|
||||||
|
Moreover, your license from a particular copyright holder is
|
||||||
|
reinstated permanently if the copyright holder notifies you of the
|
||||||
|
violation by some reasonable means, this is the first time you have
|
||||||
|
received notice of violation of this License (for any work) from that
|
||||||
|
copyright holder, and you cure the violation prior to 30 days after
|
||||||
|
your receipt of the notice.
|
||||||
|
|
||||||
|
Termination of your rights under this section does not terminate the
|
||||||
|
licenses of parties who have received copies or rights from you under
|
||||||
|
this License. If your rights have been terminated and not permanently
|
||||||
|
reinstated, you do not qualify to receive new licenses for the same
|
||||||
|
material under section 10.
|
||||||
|
|
||||||
|
9. Acceptance Not Required for Having Copies.
|
||||||
|
|
||||||
|
You are not required to accept this License in order to receive or
|
||||||
|
run a copy of the Program. Ancillary propagation of a covered work
|
||||||
|
occurring solely as a consequence of using peer-to-peer transmission
|
||||||
|
to receive a copy likewise does not require acceptance. However,
|
||||||
|
nothing other than this License grants you permission to propagate or
|
||||||
|
modify any covered work. These actions infringe copyright if you do
|
||||||
|
not accept this License. Therefore, by modifying or propagating a
|
||||||
|
covered work, you indicate your acceptance of this License to do so.
|
||||||
|
|
||||||
|
10. Automatic Licensing of Downstream Recipients.
|
||||||
|
|
||||||
|
Each time you convey a covered work, the recipient automatically
|
||||||
|
receives a license from the original licensors, to run, modify and
|
||||||
|
propagate that work, subject to this License. You are not responsible
|
||||||
|
for enforcing compliance by third parties with this License.
|
||||||
|
|
||||||
|
An "entity transaction" is a transaction transferring control of an
|
||||||
|
organization, or substantially all assets of one, or subdividing an
|
||||||
|
organization, or merging organizations. If propagation of a covered
|
||||||
|
work results from an entity transaction, each party to that
|
||||||
|
transaction who receives a copy of the work also receives whatever
|
||||||
|
licenses to the work the party's predecessor in interest had or could
|
||||||
|
give under the previous paragraph, plus a right to possession of the
|
||||||
|
Corresponding Source of the work from the predecessor in interest, if
|
||||||
|
the predecessor has it or can get it with reasonable efforts.
|
||||||
|
|
||||||
|
You may not impose any further restrictions on the exercise of the
|
||||||
|
rights granted or affirmed under this License. For example, you may
|
||||||
|
not impose a license fee, royalty, or other charge for exercise of
|
||||||
|
rights granted under this License, and you may not initiate litigation
|
||||||
|
(including a cross-claim or counterclaim in a lawsuit) alleging that
|
||||||
|
any patent claim is infringed by making, using, selling, offering for
|
||||||
|
sale, or importing the Program or any portion of it.
|
||||||
|
|
||||||
|
11. Patents.
|
||||||
|
|
||||||
|
A "contributor" is a copyright holder who authorizes use under this
|
||||||
|
License of the Program or a work on which the Program is based. The
|
||||||
|
work thus licensed is called the contributor's "contributor version".
|
||||||
|
|
||||||
|
A contributor's "essential patent claims" are all patent claims
|
||||||
|
owned or controlled by the contributor, whether already acquired or
|
||||||
|
hereafter acquired, that would be infringed by some manner, permitted
|
||||||
|
by this License, of making, using, or selling its contributor version,
|
||||||
|
but do not include claims that would be infringed only as a
|
||||||
|
consequence of further modification of the contributor version. For
|
||||||
|
purposes of this definition, "control" includes the right to grant
|
||||||
|
patent sublicenses in a manner consistent with the requirements of
|
||||||
|
this License.
|
||||||
|
|
||||||
|
Each contributor grants you a non-exclusive, worldwide, royalty-free
|
||||||
|
patent license under the contributor's essential patent claims, to
|
||||||
|
make, use, sell, offer for sale, import and otherwise run, modify and
|
||||||
|
propagate the contents of its contributor version.
|
||||||
|
|
||||||
|
In the following three paragraphs, a "patent license" is any express
|
||||||
|
agreement or commitment, however denominated, not to enforce a patent
|
||||||
|
(such as an express permission to practice a patent or covenant not to
|
||||||
|
sue for patent infringement). To "grant" such a patent license to a
|
||||||
|
party means to make such an agreement or commitment not to enforce a
|
||||||
|
patent against the party.
|
||||||
|
|
||||||
|
If you convey a covered work, knowingly relying on a patent license,
|
||||||
|
and the Corresponding Source of the work is not available for anyone
|
||||||
|
to copy, free of charge and under the terms of this License, through a
|
||||||
|
publicly available network server or other readily accessible means,
|
||||||
|
then you must either (1) cause the Corresponding Source to be so
|
||||||
|
available, or (2) arrange to deprive yourself of the benefit of the
|
||||||
|
patent license for this particular work, or (3) arrange, in a manner
|
||||||
|
consistent with the requirements of this License, to extend the patent
|
||||||
|
license to downstream recipients. "Knowingly relying" means you have
|
||||||
|
actual knowledge that, but for the patent license, your conveying the
|
||||||
|
covered work in a country, or your recipient's use of the covered work
|
||||||
|
in a country, would infringe one or more identifiable patents in that
|
||||||
|
country that you have reason to believe are valid.
|
||||||
|
|
||||||
|
If, pursuant to or in connection with a single transaction or
|
||||||
|
arrangement, you convey, or propagate by procuring conveyance of, a
|
||||||
|
covered work, and grant a patent license to some of the parties
|
||||||
|
receiving the covered work authorizing them to use, propagate, modify
|
||||||
|
or convey a specific copy of the covered work, then the patent license
|
||||||
|
you grant is automatically extended to all recipients of the covered
|
||||||
|
work and works based on it.
|
||||||
|
|
||||||
|
A patent license is "discriminatory" if it does not include within
|
||||||
|
the scope of its coverage, prohibits the exercise of, or is
|
||||||
|
conditioned on the non-exercise of one or more of the rights that are
|
||||||
|
specifically granted under this License. You may not convey a covered
|
||||||
|
work if you are a party to an arrangement with a third party that is
|
||||||
|
in the business of distributing software, under which you make payment
|
||||||
|
to the third party based on the extent of your activity of conveying
|
||||||
|
the work, and under which the third party grants, to any of the
|
||||||
|
parties who would receive the covered work from you, a discriminatory
|
||||||
|
patent license (a) in connection with copies of the covered work
|
||||||
|
conveyed by you (or copies made from those copies), or (b) primarily
|
||||||
|
for and in connection with specific products or compilations that
|
||||||
|
contain the covered work, unless you entered into that arrangement,
|
||||||
|
or that patent license was granted, prior to 28 March 2007.
|
||||||
|
|
||||||
|
Nothing in this License shall be construed as excluding or limiting
|
||||||
|
any implied license or other defenses to infringement that may
|
||||||
|
otherwise be available to you under applicable patent law.
|
||||||
|
|
||||||
|
12. No Surrender of Others' Freedom.
|
||||||
|
|
||||||
|
If conditions are imposed on you (whether by court order, agreement or
|
||||||
|
otherwise) that contradict the conditions of this License, they do not
|
||||||
|
excuse you from the conditions of this License. If you cannot convey a
|
||||||
|
covered work so as to satisfy simultaneously your obligations under this
|
||||||
|
License and any other pertinent obligations, then as a consequence you may
|
||||||
|
not convey it at all. For example, if you agree to terms that obligate you
|
||||||
|
to collect a royalty for further conveying from those to whom you convey
|
||||||
|
the Program, the only way you could satisfy both those terms and this
|
||||||
|
License would be to refrain entirely from conveying the Program.
|
||||||
|
|
||||||
|
13. Remote Network Interaction; Use with the GNU General Public License.
|
||||||
|
|
||||||
|
Notwithstanding any other provision of this License, if you modify the
|
||||||
|
Program, your modified version must prominently offer all users
|
||||||
|
interacting with it remotely through a computer network (if your version
|
||||||
|
supports such interaction) an opportunity to receive the Corresponding
|
||||||
|
Source of your version by providing access to the Corresponding Source
|
||||||
|
from a network server at no charge, through some standard or customary
|
||||||
|
means of facilitating copying of software. This Corresponding Source
|
||||||
|
shall include the Corresponding Source for any work covered by version 3
|
||||||
|
of the GNU General Public License that is incorporated pursuant to the
|
||||||
|
following paragraph.
|
||||||
|
|
||||||
|
Notwithstanding any other provision of this License, you have
|
||||||
|
permission to link or combine any covered work with a work licensed
|
||||||
|
under version 3 of the GNU General Public License into a single
|
||||||
|
combined work, and to convey the resulting work. The terms of this
|
||||||
|
License will continue to apply to the part which is the covered work,
|
||||||
|
but the work with which it is combined will remain governed by version
|
||||||
|
3 of the GNU General Public License.
|
||||||
|
|
||||||
|
14. Revised Versions of this License.
|
||||||
|
|
||||||
|
The Free Software Foundation may publish revised and/or new versions of
|
||||||
|
the GNU Affero General Public License from time to time. Such new versions
|
||||||
|
will be similar in spirit to the present version, but may differ in detail to
|
||||||
|
address new problems or concerns.
|
||||||
|
|
||||||
|
Each version is given a distinguishing version number. If the
|
||||||
|
Program specifies that a certain numbered version of the GNU Affero General
|
||||||
|
Public License "or any later version" applies to it, you have the
|
||||||
|
option of following the terms and conditions either of that numbered
|
||||||
|
version or of any later version published by the Free Software
|
||||||
|
Foundation. If the Program does not specify a version number of the
|
||||||
|
GNU Affero General Public License, you may choose any version ever published
|
||||||
|
by the Free Software Foundation.
|
||||||
|
|
||||||
|
If the Program specifies that a proxy can decide which future
|
||||||
|
versions of the GNU Affero General Public License can be used, that proxy's
|
||||||
|
public statement of acceptance of a version permanently authorizes you
|
||||||
|
to choose that version for the Program.
|
||||||
|
|
||||||
|
Later license versions may give you additional or different
|
||||||
|
permissions. However, no additional obligations are imposed on any
|
||||||
|
author or copyright holder as a result of your choosing to follow a
|
||||||
|
later version.
|
||||||
|
|
||||||
|
15. Disclaimer of Warranty.
|
||||||
|
|
||||||
|
THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY
|
||||||
|
APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT
|
||||||
|
HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY
|
||||||
|
OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO,
|
||||||
|
THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR
|
||||||
|
PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM
|
||||||
|
IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF
|
||||||
|
ALL NECESSARY SERVICING, REPAIR OR CORRECTION.
|
||||||
|
|
||||||
|
16. Limitation of Liability.
|
||||||
|
|
||||||
|
IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING
|
||||||
|
WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS
|
||||||
|
THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY
|
||||||
|
GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE
|
||||||
|
USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF
|
||||||
|
DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD
|
||||||
|
PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS),
|
||||||
|
EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF
|
||||||
|
SUCH DAMAGES.
|
||||||
|
|
||||||
|
17. Interpretation of Sections 15 and 16.
|
||||||
|
|
||||||
|
If the disclaimer of warranty and limitation of liability provided
|
||||||
|
above cannot be given local legal effect according to their terms,
|
||||||
|
reviewing courts shall apply local law that most closely approximates
|
||||||
|
an absolute waiver of all civil liability in connection with the
|
||||||
|
Program, unless a warranty or assumption of liability accompanies a
|
||||||
|
copy of the Program in return for a fee.
|
||||||
|
|
||||||
|
END OF TERMS AND CONDITIONS
|
||||||
|
|
||||||
|
How to Apply These Terms to Your New Programs
|
||||||
|
|
||||||
|
If you develop a new program, and you want it to be of the greatest
|
||||||
|
possible use to the public, the best way to achieve this is to make it
|
||||||
|
free software which everyone can redistribute and change under these terms.
|
||||||
|
|
||||||
|
To do so, attach the following notices to the program. It is safest
|
||||||
|
to attach them to the start of each source file to most effectively
|
||||||
|
state the exclusion of warranty; and each file should have at least
|
||||||
|
the "copyright" line and a pointer to where the full notice is found.
|
||||||
|
|
||||||
|
<one line to give the program's name and a brief idea of what it does.>
|
||||||
|
Copyright (C) <year> <name of author>
|
||||||
|
|
||||||
|
This program is free software: you can redistribute it and/or modify
|
||||||
|
it under the terms of the GNU Affero General Public License as published
|
||||||
|
by the Free Software Foundation, either version 3 of the License, or
|
||||||
|
(at your option) any later version.
|
||||||
|
|
||||||
|
This program is distributed in the hope that it will be useful,
|
||||||
|
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
|
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||||
|
GNU Affero General Public License for more details.
|
||||||
|
|
||||||
|
You should have received a copy of the GNU Affero General Public License
|
||||||
|
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||||
|
|
||||||
|
Also add information on how to contact you by electronic and paper mail.
|
||||||
|
|
||||||
|
If your software can interact with users remotely through a computer
|
||||||
|
network, you should also make sure that it provides a way for users to
|
||||||
|
get its source. For example, if your program is a web application, its
|
||||||
|
interface could display a "Source" link that leads users to an archive
|
||||||
|
of the code. There are many ways you could offer source, and different
|
||||||
|
solutions will be better for different programs; see section 13 for the
|
||||||
|
specific requirements.
|
||||||
|
|
||||||
|
You should also get your employer (if you work as a programmer) or school,
|
||||||
|
if any, to sign a "copyright disclaimer" for the program, if necessary.
|
||||||
|
For more information on this, and how to apply and follow the GNU AGPL, see
|
||||||
|
<https://www.gnu.org/licenses/>.
|
||||||
201
LICENSE-APACHE
Normal file
201
LICENSE-APACHE
Normal file
@@ -0,0 +1,201 @@
|
|||||||
|
Apache License
|
||||||
|
Version 2.0, January 2004
|
||||||
|
http://www.apache.org/licenses/
|
||||||
|
|
||||||
|
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
|
||||||
|
|
||||||
|
1. Definitions.
|
||||||
|
|
||||||
|
"License" shall mean the terms and conditions for use, reproduction,
|
||||||
|
and distribution as defined by Sections 1 through 9 of this document.
|
||||||
|
|
||||||
|
"Licensor" shall mean the copyright owner or entity authorized by
|
||||||
|
the copyright owner that is granting the License.
|
||||||
|
|
||||||
|
"Legal Entity" shall mean the union of the acting entity and all
|
||||||
|
other entities that control, are controlled by, or are under common
|
||||||
|
control with that entity. For the purposes of this definition,
|
||||||
|
"control" means (i) the power, direct or indirect, to cause the
|
||||||
|
direction or management of such entity, whether by contract or
|
||||||
|
otherwise, or (ii) ownership of fifty percent (50%) or more of the
|
||||||
|
outstanding shares, or (iii) beneficial ownership of such entity.
|
||||||
|
|
||||||
|
"You" (or "Your") shall mean an individual or Legal Entity
|
||||||
|
exercising permissions granted by this License.
|
||||||
|
|
||||||
|
"Source" form shall mean the preferred form for making modifications,
|
||||||
|
including but not limited to software source code, documentation
|
||||||
|
source, and configuration files.
|
||||||
|
|
||||||
|
"Object" form shall mean any form resulting from mechanical
|
||||||
|
transformation or translation of a Source form, including but
|
||||||
|
not limited to compiled object code, generated documentation,
|
||||||
|
and conversions to other media types.
|
||||||
|
|
||||||
|
"Work" shall mean the work of authorship, whether in Source or
|
||||||
|
Object form, made available under the License, as indicated by a
|
||||||
|
copyright notice that is included in or attached to the work
|
||||||
|
(an example is provided in the Appendix below).
|
||||||
|
|
||||||
|
"Derivative Works" shall mean any work, whether in Source or Object
|
||||||
|
form, that is based on (or derived from) the Work and for which the
|
||||||
|
editorial revisions, annotations, elaborations, or other modifications
|
||||||
|
represent, as a whole, an original work of authorship. For the purposes
|
||||||
|
of this License, Derivative Works shall not include works that remain
|
||||||
|
separable from, or merely link (or bind by name) to the interfaces of,
|
||||||
|
the Work and Derivative Works thereof.
|
||||||
|
|
||||||
|
"Contribution" shall mean any work of authorship, including
|
||||||
|
the original version of the Work and any modifications or additions
|
||||||
|
to that Work or Derivative Works thereof, that is intentionally
|
||||||
|
submitted to Licensor for inclusion in the Work by the copyright owner
|
||||||
|
or by an individual or Legal Entity authorized to submit on behalf of
|
||||||
|
the copyright owner. For the purposes of this definition, "submitted"
|
||||||
|
means any form of electronic, verbal, or written communication sent
|
||||||
|
to the Licensor or its representatives, including but not limited to
|
||||||
|
communication on electronic mailing lists, source code control systems,
|
||||||
|
and issue tracking systems that are managed by, or on behalf of, the
|
||||||
|
Licensor for the purpose of discussing and improving the Work, but
|
||||||
|
excluding communication that is conspicuously marked or otherwise
|
||||||
|
designated in writing by the copyright owner as "Not a Contribution."
|
||||||
|
|
||||||
|
"Contributor" shall mean Licensor and any individual or Legal Entity
|
||||||
|
on behalf of whom a Contribution has been received by Licensor and
|
||||||
|
subsequently incorporated within the Work.
|
||||||
|
|
||||||
|
2. Grant of Copyright License. Subject to the terms and conditions of
|
||||||
|
this License, each Contributor hereby grants to You a perpetual,
|
||||||
|
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
|
||||||
|
copyright license to reproduce, prepare Derivative Works of,
|
||||||
|
publicly display, publicly perform, sublicense, and distribute the
|
||||||
|
Work and such Derivative Works in Source or Object form.
|
||||||
|
|
||||||
|
3. Grant of Patent License. Subject to the terms and conditions of
|
||||||
|
this License, each Contributor hereby grants to You a perpetual,
|
||||||
|
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
|
||||||
|
(except as stated in this section) patent license to make, have made,
|
||||||
|
use, offer to sell, sell, import, and otherwise transfer the Work,
|
||||||
|
where such license applies only to those patent claims licensable
|
||||||
|
by such Contributor that are necessarily infringed by their
|
||||||
|
Contribution(s) alone or by combination of their Contribution(s)
|
||||||
|
with the Work to which such Contribution(s) was submitted. If You
|
||||||
|
institute patent litigation against any entity (including a
|
||||||
|
cross-claim or counterclaim in a lawsuit) alleging that the Work
|
||||||
|
or a Contribution incorporated within the Work constitutes direct
|
||||||
|
or contributory patent infringement, then any patent licenses
|
||||||
|
granted to You under this License for that Work shall terminate
|
||||||
|
as of the date such litigation is filed.
|
||||||
|
|
||||||
|
4. Redistribution. You may reproduce and distribute copies of the
|
||||||
|
Work or Derivative Works thereof in any medium, with or without
|
||||||
|
modifications, and in Source or Object form, provided that You
|
||||||
|
meet the following conditions:
|
||||||
|
|
||||||
|
(a) You must give any other recipients of the Work or
|
||||||
|
Derivative Works a copy of this License; and
|
||||||
|
|
||||||
|
(b) You must cause any modified files to carry prominent notices
|
||||||
|
stating that You changed the files; and
|
||||||
|
|
||||||
|
(c) You must retain, in the Source form of any Derivative Works
|
||||||
|
that You distribute, all copyright, patent, trademark, and
|
||||||
|
attribution notices from the Source form of the Work,
|
||||||
|
excluding those notices that do not pertain to any part of
|
||||||
|
the Derivative Works; and
|
||||||
|
|
||||||
|
(d) If the Work includes a "NOTICE" text file as part of its
|
||||||
|
distribution, then any Derivative Works that You distribute must
|
||||||
|
include a readable copy of the attribution notices contained
|
||||||
|
within such NOTICE file, excluding those notices that do not
|
||||||
|
pertain to any part of the Derivative Works, in at least one
|
||||||
|
of the following places: within a NOTICE text file distributed
|
||||||
|
as part of the Derivative Works; within the Source form or
|
||||||
|
documentation, if provided along with the Derivative Works; or,
|
||||||
|
within a display generated by the Derivative Works, if and
|
||||||
|
wherever such third-party notices normally appear. The contents
|
||||||
|
of the NOTICE file are for informational purposes only and
|
||||||
|
do not modify the License. You may add Your own attribution
|
||||||
|
notices within Derivative Works that You distribute, alongside
|
||||||
|
or as an addendum to the NOTICE text from the Work, provided
|
||||||
|
that such additional attribution notices cannot be construed
|
||||||
|
as modifying the License.
|
||||||
|
|
||||||
|
You may add Your own copyright statement to Your modifications and
|
||||||
|
may provide additional or different license terms and conditions
|
||||||
|
for use, reproduction, or distribution of Your modifications, or
|
||||||
|
for any such Derivative Works as a whole, provided Your use,
|
||||||
|
reproduction, and distribution of the Work otherwise complies with
|
||||||
|
the conditions stated in this License.
|
||||||
|
|
||||||
|
5. Submission of Contributions. Unless You explicitly state otherwise,
|
||||||
|
any Contribution intentionally submitted for inclusion in the Work
|
||||||
|
by You to the Licensor shall be under the terms and conditions of
|
||||||
|
this License, without any additional terms or conditions.
|
||||||
|
Notwithstanding the above, nothing herein shall supersede or modify
|
||||||
|
the terms of any separate license agreement you may have executed
|
||||||
|
with Licensor regarding such Contributions.
|
||||||
|
|
||||||
|
6. Trademarks. This License does not grant permission to use the trade
|
||||||
|
names, trademarks, service marks, or product names of the Licensor,
|
||||||
|
except as required for reasonable and customary use in describing the
|
||||||
|
origin of the Work and reproducing the content of the NOTICE file.
|
||||||
|
|
||||||
|
7. Disclaimer of Warranty. Unless required by applicable law or
|
||||||
|
agreed to in writing, Licensor provides the Work (and each
|
||||||
|
Contributor provides its Contributions) on an "AS IS" BASIS,
|
||||||
|
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
|
||||||
|
implied, including, without limitation, any warranties or conditions
|
||||||
|
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
|
||||||
|
PARTICULAR PURPOSE. You are solely responsible for determining the
|
||||||
|
appropriateness of using or redistributing the Work and assume any
|
||||||
|
risks associated with Your exercise of permissions under this License.
|
||||||
|
|
||||||
|
8. Limitation of Liability. In no event and under no legal theory,
|
||||||
|
whether in tort (including negligence), contract, or otherwise,
|
||||||
|
unless required by applicable law (such as deliberate and grossly
|
||||||
|
negligent acts) or agreed to in writing, shall any Contributor be
|
||||||
|
liable to You for damages, including any direct, indirect, special,
|
||||||
|
incidental, or consequential damages of any character arising as a
|
||||||
|
result of this License or out of the use or inability to use the
|
||||||
|
Work (including but not limited to damages for loss of goodwill,
|
||||||
|
work stoppage, computer failure or malfunction, or any and all
|
||||||
|
other commercial damages or losses), even if such Contributor
|
||||||
|
has been advised of the possibility of such damages.
|
||||||
|
|
||||||
|
9. Accepting Warranty or Additional Liability. While redistributing
|
||||||
|
the Work or Derivative Works thereof, You may choose to offer,
|
||||||
|
and charge a fee for, acceptance of support, warranty, indemnity,
|
||||||
|
or other liability obligations and/or rights consistent with this
|
||||||
|
License. However, in accepting such obligations, You may act only
|
||||||
|
on Your own behalf and on Your sole responsibility, not on behalf
|
||||||
|
of any other Contributor, and only if You agree to indemnify,
|
||||||
|
defend, and hold each Contributor harmless for any liability
|
||||||
|
incurred by, or claims asserted against, such Contributor by reason
|
||||||
|
of your accepting any such warranty or additional liability.
|
||||||
|
|
||||||
|
END OF TERMS AND CONDITIONS
|
||||||
|
|
||||||
|
APPENDIX: How to apply the Apache License to your work.
|
||||||
|
|
||||||
|
To apply the Apache License to your work, attach the following
|
||||||
|
boilerplate notice, with the fields enclosed by brackets "[]"
|
||||||
|
replaced with your own identifying information. (Don't include
|
||||||
|
the brackets!) The text should be enclosed in the appropriate
|
||||||
|
comment syntax for the file format. We also recommend that a
|
||||||
|
file or class name and description of purpose be included on the
|
||||||
|
same "printed page" as the copyright notice for easier
|
||||||
|
identification within third-party archives.
|
||||||
|
|
||||||
|
Copyright 2021 Ruben Fiszel
|
||||||
|
|
||||||
|
Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
you may not use this file except in compliance with the License.
|
||||||
|
You may obtain a copy of the License at
|
||||||
|
|
||||||
|
http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
|
||||||
|
Unless required by applicable law or agreed to in writing, software
|
||||||
|
distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
See the License for the specific language governing permissions and
|
||||||
|
limitations under the License.
|
||||||
13
NOTICE
Normal file
13
NOTICE
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
Ruben Fiszel
|
||||||
|
|
||||||
|
Copyright (c) 2021 Ruben Fiszel
|
||||||
|
|
||||||
|
Source code in this repository is variously licensed under the Apache License
|
||||||
|
Version 2.0 or the GNU Affero General Public License. Please see
|
||||||
|
LICENSE for more information.
|
||||||
|
|
||||||
|
* For a copy of the Apache License Version 2.0, please see LICENSE-APACHE
|
||||||
|
as included in this repository's top-level directory.
|
||||||
|
|
||||||
|
* For a copy of the GNU Affero General Public License, please see LICENSE-ALPH
|
||||||
|
as included in this repository's top-level directory.
|
||||||
23
Pipfile
Normal file
23
Pipfile
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
[[source]]
|
||||||
|
url = "https://pypi.org/simple"
|
||||||
|
verify_ssl = true
|
||||||
|
name = "pypi"
|
||||||
|
|
||||||
|
[packages]
|
||||||
|
wmill = ">=1.5.0"
|
||||||
|
wmill_pg = ">=1.5.0"
|
||||||
|
requests = "*"
|
||||||
|
sendgrid = "*"
|
||||||
|
psycopg2-binary = "*"
|
||||||
|
mysql-connector-python = "*"
|
||||||
|
pymongo = "*"
|
||||||
|
slack_sdk = "*"
|
||||||
|
google-api-python-client = "*"
|
||||||
|
pandas = "*"
|
||||||
|
numpy = "*"
|
||||||
|
seaborn = "*"
|
||||||
|
yfinance = "*"
|
||||||
|
pyowm = "*"
|
||||||
|
pyairtable = "*"
|
||||||
|
matplotlib = "*"
|
||||||
|
|
||||||
74
README.md
Normal file
74
README.md
Normal file
@@ -0,0 +1,74 @@
|
|||||||
|
<p align="center">
|
||||||
|
<a href="https://alpha.windmill.dev"><img src="./windmill.svg" alt="windmill.dev"></a>
|
||||||
|
</p>
|
||||||
|
<p align="center">
|
||||||
|
<em>Windmill.dev is an OSS developer platform to quickly build production-grade multi-steps automations and internal apps from minimal Python and Typescript scripts.</em>
|
||||||
|
</p>
|
||||||
|
<p align="center">
|
||||||
|
<a href="https://pypi.org/project/wmill" target="_blank">
|
||||||
|
<img src="https://img.shields.io/pypi/v/wmill?color=%2334D058&label=pypi%20package" alt="Package version">
|
||||||
|
</a>
|
||||||
|
<a href="https://discord.gg/V7PM2YHsPB" target="_blank">
|
||||||
|
<img src="https://discordapp.com/api/guilds/930051556043276338/widget.png" alt="Discord Shield"/>
|
||||||
|
</a>
|
||||||
|
</p>
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
**Join the alpha (personal workspaces are free forever)**:
|
||||||
|
<https://alpha.windmill.dev>
|
||||||
|
|
||||||
|
**Documentation**: <https://docs.windmill.dev>
|
||||||
|
|
||||||
|
**Discord**: <https://discord.gg/V7PM2YHsPB>
|
||||||
|
|
||||||
|
**We are hiring**: Software Engineers, DevOps, Solutions Engineers, Growth:
|
||||||
|
<https://docs.windmill.dev/hiring>
|
||||||
|
|
||||||
|
If you would like to, you can show your support for the project by starring this
|
||||||
|
repo.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# Windmill
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
Windmill is fully open-sourced:
|
||||||
|
|
||||||
|
- community parts and python-client are Apache 2.0
|
||||||
|
- backend, frontend and everything else under AGPLv3.
|
||||||
|
|
||||||
|
## Stack
|
||||||
|
|
||||||
|
- postgres as the database
|
||||||
|
- backend in Rust with the follwing highly-available and horizontally scalable
|
||||||
|
architecture:
|
||||||
|
- stateless API backend
|
||||||
|
- workers that pull jobs from a queue
|
||||||
|
- frontend in svelte
|
||||||
|
- scripts executions are sandboxed using google's nsjail
|
||||||
|
- javascript runtime is deno_core rust library (which itself uses the rusty_v8
|
||||||
|
and hence V8 underneath)
|
||||||
|
- typescript runtime is deno
|
||||||
|
- python runtime is python3
|
||||||
|
|
||||||
|
### Developent stack
|
||||||
|
|
||||||
|
- caddy is the reverse proxy + handle https
|
||||||
|
|
||||||
|
## How to self-host
|
||||||
|
|
||||||
|
Complete instructions coming soon
|
||||||
|
|
||||||
|
## Copyright
|
||||||
|
|
||||||
|
2021 [Ruben Fiszel](https://github.com/rubenfiszel)
|
||||||
|
|
||||||
|
## Acknowledgement
|
||||||
|
|
||||||
|
This project is inspired from a previous project called
|
||||||
|
[Delightool](https://github.com/windmill-labs/delightool-legacy) which was also
|
||||||
|
build by [Ruben](https://github.com/rubenfiszel) but the frontend was realized
|
||||||
|
in large parts by [Malo Marrec]((https://github.com/malomarrec). Windmill is a
|
||||||
|
child but distinct project and realized with Malo's blessing.
|
||||||
3
backend/.cargo/config
Normal file
3
backend/.cargo/config
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
[build]
|
||||||
|
rustflags = ["--cfg", "tokio_unstable"]
|
||||||
|
incremental = true
|
||||||
2
backend/.gitignore
vendored
Normal file
2
backend/.gitignore
vendored
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
target/
|
||||||
|
.env
|
||||||
4170
backend/Cargo.lock
generated
Normal file
4170
backend/Cargo.lock
generated
Normal file
File diff suppressed because it is too large
Load Diff
62
backend/Cargo.toml
Normal file
62
backend/Cargo.toml
Normal file
@@ -0,0 +1,62 @@
|
|||||||
|
[package]
|
||||||
|
name = "windmill"
|
||||||
|
version = "1.5.0"
|
||||||
|
authors = ["Ruben Fiszel <ruben@rubenfiszel.com>"]
|
||||||
|
edition = "2021"
|
||||||
|
|
||||||
|
[build-dependencies]
|
||||||
|
deno_core = "^0"
|
||||||
|
|
||||||
|
[dependencies]
|
||||||
|
axum = { version = "^0", features = ["headers"] }
|
||||||
|
headers = "^0"
|
||||||
|
hyper = { version = "^0", features = ["full"] }
|
||||||
|
tokio = { version = "^1", features = ["full", "tracing"] }
|
||||||
|
tower = "^0"
|
||||||
|
tower-http = { version = "^0", features = ["trace"] }
|
||||||
|
tower-cookies = "^0"
|
||||||
|
serde = "^1"
|
||||||
|
serde_json = { version = "^1", features = ["preserve_order"] }
|
||||||
|
uuid = { version = "^0", features = ["serde", "v4"] }
|
||||||
|
thiserror = "^1"
|
||||||
|
anyhow = "^1"
|
||||||
|
chrono = { version = "^0", features = ["serde"]}
|
||||||
|
tracing = "^0"
|
||||||
|
tracing-subscriber = { version = "^0", features = ["env-filter", "json"]}
|
||||||
|
console-subscriber = "^0"
|
||||||
|
|
||||||
|
rust-embed = "^6"
|
||||||
|
mime_guess = "^2"
|
||||||
|
hex = "^0"
|
||||||
|
sql-builder = "^3"
|
||||||
|
argon2 = "^0"
|
||||||
|
retainer = "^0"
|
||||||
|
rand = "^0.8.4"
|
||||||
|
rand_core = { version = "^0.6.3", features = ["std"] }
|
||||||
|
magic-crypt = "^3"
|
||||||
|
git-version = "^0"
|
||||||
|
rustpython-parser = "^0"
|
||||||
|
cron = "^0"
|
||||||
|
external-ip = "^4"
|
||||||
|
lettre = { version = "^0.10.0-rc.4", features = ["rustls-tls", "tokio1", "tokio1-rustls-tls", "builder", "smtp-transport"], default-features = false}
|
||||||
|
urlencoding = "^2"
|
||||||
|
oauth2 = "^4"
|
||||||
|
url = "^2"
|
||||||
|
reqwest = { version = "^0", features = ["json"] }
|
||||||
|
time = "0.3.7"
|
||||||
|
slack-http-verifier = "^0"
|
||||||
|
serde_urlencoded = "^0"
|
||||||
|
tokio-tar = "^0"
|
||||||
|
tempfile = "^3"
|
||||||
|
tokio-util = { version = "0.7.0", features = ["io"] }
|
||||||
|
json-pointer = "^0"
|
||||||
|
itertools = "^0"
|
||||||
|
regex = "^1"
|
||||||
|
deno_core = "^0"
|
||||||
|
indexmap = "~1.6.2"
|
||||||
|
async-recursion = "^1"
|
||||||
|
|
||||||
|
sqlx = { version = "^0", features = ["macros", "offline", "migrate", "uuid", "json", "chrono", "postgres", "runtime-tokio-rustls"]}
|
||||||
|
dotenv = "^0"
|
||||||
|
ulid = { version = "^0", features = ["uuid"] }
|
||||||
|
futures = "^0"
|
||||||
95
backend/LICENSE
Normal file
95
backend/LICENSE
Normal file
@@ -0,0 +1,95 @@
|
|||||||
|
Business Source License 1.1
|
||||||
|
|
||||||
|
Parameters
|
||||||
|
|
||||||
|
Licensor: Ruben Fiszel
|
||||||
|
Licensed Work: windmill.dev backend 0.9.0
|
||||||
|
The Licensed Work is (c) 2021 Ruben Fiszel
|
||||||
|
Additional Use Grant: None
|
||||||
|
|
||||||
|
Change Date: 2026-01-01
|
||||||
|
|
||||||
|
Change License: Apache License, Version 2.0
|
||||||
|
|
||||||
|
For information about alternative licensing arrangements for the Software,
|
||||||
|
please visit: https://windmill.dev
|
||||||
|
|
||||||
|
Notice
|
||||||
|
|
||||||
|
The Business Source License (this document, or the “License”) is not an Open
|
||||||
|
Source license. However, the Licensed Work will eventually be made available
|
||||||
|
under an Open Source License, as stated in this License.
|
||||||
|
|
||||||
|
License text copyright (c) 2017 MariaDB Corporation Ab, All Rights Reserved.
|
||||||
|
“Business Source License” is a trademark of MariaDB Corporation Ab.
|
||||||
|
|
||||||
|
-----------------------------------------------------------------------------
|
||||||
|
|
||||||
|
Business Source License 1.1
|
||||||
|
|
||||||
|
Terms
|
||||||
|
|
||||||
|
The Licensor hereby grants you the right to copy, modify, create derivative
|
||||||
|
works, redistribute, and make non-production use of the Licensed Work. The
|
||||||
|
Licensor may make an Additional Use Grant, above, permitting limited
|
||||||
|
production use.
|
||||||
|
|
||||||
|
Effective on the Change Date, or the fourth anniversary of the first publicly
|
||||||
|
available distribution of a specific version of the Licensed Work under this
|
||||||
|
License, whichever comes first, the Licensor hereby grants you rights under
|
||||||
|
the terms of the Change License, and the rights granted in the paragraph
|
||||||
|
above terminate.
|
||||||
|
|
||||||
|
If your use of the Licensed Work does not comply with the requirements
|
||||||
|
currently in effect as described in this License, you must purchase a
|
||||||
|
commercial license from the Licensor, its affiliated entities, or authorized
|
||||||
|
resellers, or you must refrain from using the Licensed Work.
|
||||||
|
|
||||||
|
All copies of the original and modified Licensed Work, and derivative works
|
||||||
|
of the Licensed Work, are subject to this License. This License applies
|
||||||
|
separately for each version of the Licensed Work and the Change Date may vary
|
||||||
|
for each version of the Licensed Work released by Licensor.
|
||||||
|
|
||||||
|
You must conspicuously display this License on each original or modified copy
|
||||||
|
of the Licensed Work. If you receive the Licensed Work in original or
|
||||||
|
modified form from a third party, the terms and conditions set forth in this
|
||||||
|
License apply to your use of that work.
|
||||||
|
|
||||||
|
Any use of the Licensed Work in violation of this License will automatically
|
||||||
|
terminate your rights under this License for the current and all other
|
||||||
|
versions of the Licensed Work.
|
||||||
|
|
||||||
|
This License does not grant you any right in any trademark or logo of
|
||||||
|
Licensor or its affiliates (provided that you may use a trademark or logo of
|
||||||
|
Licensor as expressly required by this License).
|
||||||
|
|
||||||
|
TO THE EXTENT PERMITTED BY APPLICABLE LAW, THE LICENSED WORK IS PROVIDED ON
|
||||||
|
AN “AS IS” BASIS. LICENSOR HEREBY DISCLAIMS ALL WARRANTIES AND CONDITIONS,
|
||||||
|
EXPRESS OR IMPLIED, INCLUDING (WITHOUT LIMITATION) WARRANTIES OF
|
||||||
|
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE, NON-INFRINGEMENT, AND
|
||||||
|
TITLE.
|
||||||
|
|
||||||
|
MariaDB hereby grants you permission to use this License’s text to license
|
||||||
|
your works, and to refer to it using the trademark “Business Source License”,
|
||||||
|
as long as you comply with the Covenants of Licensor below.
|
||||||
|
|
||||||
|
Covenants of Licensor
|
||||||
|
|
||||||
|
In consideration of the right to use this License’s text and the “Business
|
||||||
|
Source License” name and trademark, Licensor covenants to MariaDB, and to all
|
||||||
|
other recipients of the licensed work to be provided by Licensor:
|
||||||
|
|
||||||
|
1. To specify as the Change License the GPL Version 2.0 or any later version,
|
||||||
|
or a license that is compatible with GPL Version 2.0 or a later version,
|
||||||
|
where “compatible” means that software provided under the Change License can
|
||||||
|
be included in a program with software provided under GPL Version 2.0 or a
|
||||||
|
later version. Licensor may specify additional Change Licenses without
|
||||||
|
limitation.
|
||||||
|
|
||||||
|
2. To either: (a) specify an additional grant of rights to use that does not
|
||||||
|
impose any additional restriction on the right granted in this License, as
|
||||||
|
the Additional Use Grant; or (b) insert the text “None”.
|
||||||
|
|
||||||
|
3. To specify a Change Date.
|
||||||
|
|
||||||
|
4. Not to modify this License in any other way.
|
||||||
17
backend/build.rs
Normal file
17
backend/build.rs
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
use std::fs::File;
|
||||||
|
use std::io::Write;
|
||||||
|
|
||||||
|
use deno_core::{JsRuntime, RuntimeOptions};
|
||||||
|
|
||||||
|
fn main() {
|
||||||
|
println!("cargo:rerun-if-changed=build.rs");
|
||||||
|
let options = RuntimeOptions {
|
||||||
|
will_snapshot: true,
|
||||||
|
..Default::default()
|
||||||
|
};
|
||||||
|
let mut runtime = JsRuntime::new(options);
|
||||||
|
|
||||||
|
let mut snap = File::create("v8.snap").expect("can create snap file");
|
||||||
|
snap.write_all(&runtime.snapshot())
|
||||||
|
.expect("can write content to snap");
|
||||||
|
}
|
||||||
0
backend/migrations/.gitkeep
Normal file
0
backend/migrations/.gitkeep
Normal file
0
backend/migrations/20220123221903_first.down.sql
Normal file
0
backend/migrations/20220123221903_first.down.sql
Normal file
575
backend/migrations/20220123221903_first.up.sql
Normal file
575
backend/migrations/20220123221903_first.up.sql
Normal file
@@ -0,0 +1,575 @@
|
|||||||
|
-- Add migration script here
|
||||||
|
create SCHEMA IF NOT exists extensions;
|
||||||
|
create extension if not exists "uuid-ossp" with schema extensions;
|
||||||
|
|
||||||
|
CREATE TABLE workspace (
|
||||||
|
id VARCHAR(50) PRIMARY KEY,
|
||||||
|
name VARCHAR(50) NOT NULL,
|
||||||
|
owner VARCHAR(50) NOT NULL,
|
||||||
|
domain VARCHAR(30),
|
||||||
|
deleted BOOLEAN NOT NULL DEFAULT false,
|
||||||
|
CONSTRAINT proper_id CHECK (id ~ '^\w+(-\w+)*$')
|
||||||
|
);
|
||||||
|
|
||||||
|
INSERT INTO workspace(id, name, owner) VALUES
|
||||||
|
('starter', 'Starter', 'admin@windmill.dev'),
|
||||||
|
('demo', 'Demo', 'admin@windmill.dev');
|
||||||
|
|
||||||
|
CREATE TABLE script (
|
||||||
|
workspace_id VARCHAR(50) NOT NULL REFERENCES workspace(id),
|
||||||
|
hash BIGINT NOT NULL,
|
||||||
|
path varchar(255) NOT NULL,
|
||||||
|
parent_hashes BIGINT[],
|
||||||
|
summary TEXT NOT NULL,
|
||||||
|
description TEXT NOT NULL,
|
||||||
|
content TEXT NOT NULL,
|
||||||
|
created_by VARCHAR(50) NOT NULL,
|
||||||
|
created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now(),
|
||||||
|
archived BOOLEAN NOT NULL DEFAULT false,
|
||||||
|
schema JSONB,
|
||||||
|
deleted BOOLEAN NOT NULL DEFAULT false,
|
||||||
|
is_template boolean DEFAULT false,
|
||||||
|
PRIMARY KEY (workspace_id, hash),
|
||||||
|
CONSTRAINT proper_id CHECK (path ~ '^[ug](\/[\w-]+){2,}$')
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE flow (
|
||||||
|
workspace_id VARCHAR(50) NOT NULL REFERENCES workspace(id),
|
||||||
|
path varchar(255) NOT NULL,
|
||||||
|
summary TEXT NOT NULL,
|
||||||
|
description TEXT NOT NULL,
|
||||||
|
value JSONB NOT NULL,
|
||||||
|
edited_by VARCHAR(50) NOT NULL,
|
||||||
|
edited_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now(),
|
||||||
|
archived BOOLEAN NOT NULL DEFAULT false,
|
||||||
|
schema JSONB,
|
||||||
|
PRIMARY KEY (workspace_id, path),
|
||||||
|
CONSTRAINT proper_id CHECK (path ~ '^[ug](\/[\w-]+){2,}$')
|
||||||
|
);
|
||||||
|
|
||||||
|
INSERT INTO script(workspace_id, created_by, content, schema, summary, description, path, hash) VALUES (
|
||||||
|
'starter',
|
||||||
|
'system', 'import wmill
|
||||||
|
import psycopg2
|
||||||
|
|
||||||
|
client = wmill.Client()
|
||||||
|
|
||||||
|
def main():
|
||||||
|
# query that returns rows will return them as a list
|
||||||
|
res1 = query_pg("SELECT * from demo", "g/all/demodb")
|
||||||
|
|
||||||
|
# query that does not return rows will return None
|
||||||
|
res2 = query_pg("UPDATE demo SET value = ''value''", "g/all/demodb")
|
||||||
|
|
||||||
|
# one can use RETURNING to still fetch the updated rows
|
||||||
|
res3 = query_pg("UPDATE demo SET value = ''value'' RETURNING *", "g/all/demodb")
|
||||||
|
|
||||||
|
# output expects a dict
|
||||||
|
return {"res1": res1, "res2": res2, "res3": res3}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
def query_pg(query: str, resource: str):
|
||||||
|
pg_con = client.get_resource(resource)
|
||||||
|
conn = psycopg2.connect(**pg_con)
|
||||||
|
cur = conn.cursor()
|
||||||
|
cur.execute(f"{query};")
|
||||||
|
if cur.description:
|
||||||
|
return cur.fetchall()
|
||||||
|
else:
|
||||||
|
return None',
|
||||||
|
'{
|
||||||
|
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||||
|
"properties": {},
|
||||||
|
"required": [],
|
||||||
|
"type": "object"
|
||||||
|
}',
|
||||||
|
'Query the demodb resource','
|
||||||
|
An example of how to use resources from scripts. In this example, we will query the demo database demodb that is set up by default on Windmill.',
|
||||||
|
'u/bot/postgres_example', 43),
|
||||||
|
(
|
||||||
|
'starter',
|
||||||
|
'system',
|
||||||
|
'import os
|
||||||
|
|
||||||
|
def main(name: str = "Nicolas Bourbaki"):
|
||||||
|
print(f"Hello World and a warm welcome especially to {name}")
|
||||||
|
print("The env variable at `g/all/pretty_secret`: ", os.environ.get("G_ALL_PRETTY_SECRET"))
|
||||||
|
return {"len": len(name), "splitted": name.split() }',
|
||||||
|
'{
|
||||||
|
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||||
|
"properties": {
|
||||||
|
"name": {
|
||||||
|
"description": "",
|
||||||
|
"type": "string",
|
||||||
|
"default": "Nicolas Bourbaki"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"required": [],
|
||||||
|
"type": "object"
|
||||||
|
}',
|
||||||
|
'Hello World', '', 'u/bot/hello_world', 44);
|
||||||
|
|
||||||
|
|
||||||
|
CREATE INDEX index_script_on_path_created_at ON script (path, created_at);
|
||||||
|
|
||||||
|
CREATE TYPE JOB_KIND AS ENUM ('script', 'preview', 'flow', 'dependencies');
|
||||||
|
|
||||||
|
CREATE TABLE queue (
|
||||||
|
id UUID PRIMARY KEY,
|
||||||
|
workspace_id VARCHAR(50) NOT NULL REFERENCES workspace(id),
|
||||||
|
parent_job UUID,
|
||||||
|
created_by VARCHAR(50) NOT NULL,
|
||||||
|
created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now(),
|
||||||
|
started_at TIMESTAMP WITH TIME ZONE,
|
||||||
|
scheduled_for TIMESTAMP WITH TIME ZONE NOT NULL,
|
||||||
|
running BOOLEAN NOT NULL DEFAULT FALSE,
|
||||||
|
script_hash BIGINT,
|
||||||
|
script_path VARCHAR(255),
|
||||||
|
args JSONB,
|
||||||
|
logs TEXT,
|
||||||
|
raw_code TEXT,
|
||||||
|
canceled boolean NOT NULL DEFAULT false,
|
||||||
|
canceled_by VARCHAR(50),
|
||||||
|
canceled_reason TEXT,
|
||||||
|
last_ping TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now(),
|
||||||
|
job_kind JOB_KIND NOT NULL DEFAULT 'script',
|
||||||
|
env_id INTEGER,
|
||||||
|
schedule_path VARCHAR(255),
|
||||||
|
permissioned_as VARCHAR(55) NOT NULL DEFAULT 'g/all',
|
||||||
|
flow_status JSONB
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX index_queue_on_workspace_id ON queue (workspace_id);
|
||||||
|
CREATE INDEX index_queue_on_scheduled_for ON queue (scheduled_for);
|
||||||
|
CREATE INDEX index_queue_on_running ON queue (running);
|
||||||
|
CREATE INDEX index_queue_on_created ON queue (created_at);
|
||||||
|
CREATE INDEX index_queue_on_script_path ON queue (script_path);
|
||||||
|
CREATE INDEX index_queue_on_script_hash ON queue (script_hash);
|
||||||
|
|
||||||
|
CREATE TABLE completed_job (
|
||||||
|
id UUID PRIMARY KEY,
|
||||||
|
workspace_id VARCHAR(50) NOT NULL REFERENCES workspace(id),
|
||||||
|
parent_job UUID,
|
||||||
|
created_by VARCHAR(50) NOT NULL,
|
||||||
|
created_at TIMESTAMP WITH TIME ZONE NOT NULL,
|
||||||
|
duration INT NOT NULL,
|
||||||
|
success BOOLEAN NOT NULL,
|
||||||
|
script_hash BIGINT,
|
||||||
|
script_path VARCHAR(255),
|
||||||
|
args JSONB,
|
||||||
|
result JSONB,
|
||||||
|
logs TEXT,
|
||||||
|
deleted BOOLEAN NOT NULL DEFAULT false,
|
||||||
|
raw_code TEXT,
|
||||||
|
canceled boolean NOT NULL DEFAULT false,
|
||||||
|
canceled_by VARCHAR(50),
|
||||||
|
canceled_reason TEXT,
|
||||||
|
job_kind JOB_KIND NOT NULL DEFAULT 'script',
|
||||||
|
env_id INTEGER NOT NULL DEFAULT 0,
|
||||||
|
schedule_path varchar(255),
|
||||||
|
permissioned_as VARCHAR(55) NOT NULL DEFAULT 'g/all',
|
||||||
|
flow_status JSONB
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX index_completed_on_workspace_id ON completed_job (workspace_id);
|
||||||
|
CREATE INDEX index_completed_on_created ON completed_job (created_at);
|
||||||
|
CREATE INDEX index_completed_on_script_path ON completed_job (script_path);
|
||||||
|
CREATE INDEX index_completed_on_script_hash ON completed_job (script_hash);
|
||||||
|
|
||||||
|
CREATE TABLE usr (
|
||||||
|
workspace_id VARCHAR(50) NOT NULL REFERENCES workspace(id),
|
||||||
|
username VARCHAR(50) NOT NULL,
|
||||||
|
email VARCHAR(50) NOT NULL,
|
||||||
|
is_admin BOOLEAN NOT NULL DEFAULT false,
|
||||||
|
created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW(),
|
||||||
|
operator BOOLEAN NOT NULL DEFAULT false,
|
||||||
|
disabled BOOLEAN NOT NULL DEFAULT false,
|
||||||
|
role VARCHAR(50),
|
||||||
|
PRIMARY KEY (workspace_id, username),
|
||||||
|
CONSTRAINT proper_username CHECK (username ~ '^[\w-]+$'),
|
||||||
|
CONSTRAINT proper_email CHECK (email ~ '^(?:[a-z0-9!#$%&''*+/=?^_`{|}~-]+(?:\.[a-z0-9!#$%&''*+/=?^_`{|}~-]+)*|"(?:[\x01-\x08\x0b\x0c\x0e-\x1f\x21\x23-\x5b\x5d-\x7f]|\\[\x01-\x09\x0b\x0c\x0e-\x7f])*")@(?:(?:[a-z0-9](?:[a-z0-9-]*[a-z0-9])?\.)+[a-z0-9](?:[a-z0-9-]*[a-z0-9])?|\[(?:(?:(2(5[0-5]|[0-4][0-9])|1[0-9][0-9]|[1-9]?[0-9]))\.){3}(?:(2(5[0-5]|[0-4][0-9])|1[0-9][0-9]|[1-9]?[0-9])|[a-z0-9-]*[a-z0-9]:(?:[\x01-\x08\x0b\x0c\x0e-\x1f\x21-\x5a\x53-\x7f]|\\[\x01-\x09\x0b\x0c\x0e-\x7f])+)\])$')
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX index_usr_email ON usr (email);
|
||||||
|
|
||||||
|
CREATE TYPE LOGIN_TYPE AS ENUM ('password', 'github');
|
||||||
|
|
||||||
|
CREATE TABLE password (
|
||||||
|
email VARCHAR(50) PRIMARY KEY,
|
||||||
|
password_hash VARCHAR(100),
|
||||||
|
login_type LOGIN_TYPE NOT NULL,
|
||||||
|
super_admin BOOLEAN NOT NULL DEFAULT FALSE,
|
||||||
|
verified BOOLEAN NOT NULL DEFAULT FALSE,
|
||||||
|
name VARCHAR(30),
|
||||||
|
company VARCHAR(30)
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CREATE TABLE invite_code (
|
||||||
|
-- code VARCHAR(20) PRIMARY KEY,
|
||||||
|
-- seats_left INTEGER NOT NULL DEFAULT 0,
|
||||||
|
-- seats_given INTEGER NOT NULL DEFAULT 1
|
||||||
|
-- );
|
||||||
|
|
||||||
|
|
||||||
|
CREATE TABLE workspace_settings (
|
||||||
|
workspace_id VARCHAR(50) PRIMARY KEY REFERENCES workspace(id),
|
||||||
|
slack_team_id VARCHAR(50) UNIQUE,
|
||||||
|
slack_name VARCHAR(50),
|
||||||
|
slack_command_script VARCHAR(255)
|
||||||
|
);
|
||||||
|
|
||||||
|
|
||||||
|
INSERT INTO workspace_settings (workspace_id) VALUES
|
||||||
|
('starter'),
|
||||||
|
('demo');
|
||||||
|
|
||||||
|
|
||||||
|
CREATE TABLE workspace_invite (
|
||||||
|
workspace_id VARCHAR(50) NOT NULL REFERENCES workspace(id),
|
||||||
|
email VARCHAR(50),
|
||||||
|
is_admin bool NOT NULL DEFAULT false,
|
||||||
|
CONSTRAINT proper_email CHECK (email ~ '^(?:[a-z0-9!#$%&''*+/=?^_`{|}~-]+(?:\.[a-z0-9!#$%&''*+/=?^_`{|}~-]+)*|"(?:[\x01-\x08\x0b\x0c\x0e-\x1f\x21\x23-\x5b\x5d-\x7f]|\\[\x01-\x09\x0b\x0c\x0e-\x7f])*")@(?:(?:[a-z0-9](?:[a-z0-9-]*[a-z0-9])?\.)+[a-z0-9](?:[a-z0-9-]*[a-z0-9])?|\[(?:(?:(2(5[0-5]|[0-4][0-9])|1[0-9][0-9]|[1-9]?[0-9]))\.){3}(?:(2(5[0-5]|[0-4][0-9])|1[0-9][0-9]|[1-9]?[0-9])|[a-z0-9-]*[a-z0-9]:(?:[\x01-\x08\x0b\x0c\x0e-\x1f\x21-\x5a\x53-\x7f]|\\[\x01-\x09\x0b\x0c\x0e-\x7f])+)\])$'),
|
||||||
|
PRIMARY KEY (workspace_id, email)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE magic_link (
|
||||||
|
email VARCHAR(50) NOT NULL,
|
||||||
|
token VARCHAR(100) NOT NULL,
|
||||||
|
expiration TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT (NOW() + interval '1 day'),
|
||||||
|
PRIMARY KEY (email, token)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE token (
|
||||||
|
token VARCHAR(50) PRIMARY KEY,
|
||||||
|
label VARCHAR(50),
|
||||||
|
expiration TIMESTAMP WITH TIME ZONE,
|
||||||
|
workspace_id VARCHAR(50) REFERENCES workspace(id),
|
||||||
|
owner VARCHAR(55),
|
||||||
|
email VARCHAR(50),
|
||||||
|
super_admin BOOLEAN NOT NULL DEFAULT FALSE,
|
||||||
|
created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW(),
|
||||||
|
last_used_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW()
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX index_magic_link_exp ON magic_link (expiration);
|
||||||
|
CREATE INDEX index_token_exp ON token (expiration);
|
||||||
|
|
||||||
|
INSERT INTO usr(workspace_id, email, username, is_admin, role) VALUES
|
||||||
|
('starter', 'admin@windmill.dev', 'admin', true, 'Admin'),
|
||||||
|
('demo', 'admin@windmill.dev', 'admin', true, 'Ruben');
|
||||||
|
|
||||||
|
INSERT INTO password(email, verified, password_hash, login_type, super_admin, name, company) VALUES
|
||||||
|
('admin@windmill.dev', true, '$argon2id$v=19$m=4096,t=3,p=1$z0Kg3qyaS14e+YHeihkJLQ$N69flI6yQ/U98pjAHtbNxbdz2f4PrJEi9Tx1VoYk1as', 'password', true, 'Admin', 'Windmill'),
|
||||||
|
('ruben@windmill.dev', true, '$argon2id$v=19$m=4096,t=3,p=1$z0Kg3qyaS14e+YHeihkJLQ$N69flI6yQ/U98pjAHtbNxbdz2f4PrJEi9Tx1VoYk1as', 'password', true, 'Ruben', 'Windmill'),
|
||||||
|
('user@windmill.dev', true, '$argon2id$v=19$m=4096,t=3,p=1$z0Kg3qyaS14e+YHeihkJLQ$N69flI6yQ/U98pjAHtbNxbdz2f4PrJEi9Tx1VoYk1as', 'password', false, 'User', 'Windmill');
|
||||||
|
|
||||||
|
INSERT INTO workspace_invite(workspace_id, email, is_admin) VALUES
|
||||||
|
('demo', 'ruben@windmill.dev', true);
|
||||||
|
|
||||||
|
CREATE TABLE variable (
|
||||||
|
workspace_id VARCHAR(50) NOT NULL REFERENCES workspace(id),
|
||||||
|
path VARCHAR(255),
|
||||||
|
value VARCHAR(4012) NOT NULL,
|
||||||
|
is_secret BOOLEAN NOT NULL DEFAULT FALSE,
|
||||||
|
description VARCHAR(255) NOT NULL DEFAULT '',
|
||||||
|
PRIMARY KEY (workspace_id, path),
|
||||||
|
CONSTRAINT proper_id CHECK (path ~ '^[ug](\/[\w-]+){2,}$')
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CREATE TABLE oauth(
|
||||||
|
-- id VARCHAR(150) NOT NULL PRIMARY KEY,
|
||||||
|
-- owner VARCHAR(50),
|
||||||
|
-- workspace_id VARCHAR(50) NOT NULL REFERENCES workspace(id),
|
||||||
|
-- type VARCHAR(50) NOT NULL,
|
||||||
|
-- refresh_token VARCHAR(255),
|
||||||
|
-- access_token VARCHAR(255) NOT NULL
|
||||||
|
-- );
|
||||||
|
|
||||||
|
-- CREATE INDEX index_oauth ON oauth (workspace_id, type, owner);
|
||||||
|
|
||||||
|
CREATE TYPE ACTION_KIND AS ENUM ('create', 'update', 'delete', 'execute');
|
||||||
|
|
||||||
|
CREATE TABLE audit (
|
||||||
|
workspace_id VARCHAR(50) NOT NULL,
|
||||||
|
id SERIAL,
|
||||||
|
timestamp TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now(),
|
||||||
|
username VARCHAR(50) NOT NULL,
|
||||||
|
operation VARCHAR(50) NOT NULL,
|
||||||
|
action_kind ACTION_KIND NOT NULL,
|
||||||
|
resource VARCHAR(255),
|
||||||
|
parameters JSONB,
|
||||||
|
PRIMARY KEY (workspace_id, id)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE resource_type (
|
||||||
|
workspace_id VARCHAR(50) NOT NULL REFERENCES workspace(id),
|
||||||
|
name VARCHAR(50),
|
||||||
|
schema JSONB,
|
||||||
|
description TEXT,
|
||||||
|
PRIMARY KEY (workspace_id, name),
|
||||||
|
CONSTRAINT proper_name CHECK (name ~ '^[\w-]+$')
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE resource (
|
||||||
|
workspace_id VARCHAR(50) NOT NULL REFERENCES workspace(id),
|
||||||
|
path VARCHAR(255),
|
||||||
|
value JSONB,
|
||||||
|
description TEXT,
|
||||||
|
resource_type VARCHAR(50) NOT NULL,
|
||||||
|
PRIMARY KEY (workspace_id, path),
|
||||||
|
CONSTRAINT proper_id CHECK (path ~ '^[ug](\/[\w-]+){2,}$')
|
||||||
|
);
|
||||||
|
|
||||||
|
INSERT INTO resource_type(workspace_id, name, schema, description) VALUES
|
||||||
|
('starter', 'postgres', '{
|
||||||
|
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"dbname": {
|
||||||
|
"description": "The database name",
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"user": {
|
||||||
|
"description": "The postgres username",
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"password": {
|
||||||
|
"description": "The postgres users password",
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"sslmode": {
|
||||||
|
"description": "The sslmode",
|
||||||
|
"type": "string",
|
||||||
|
"enum": ["disable", "allow", "prefer", "require", "verify-ca", "verify-full"]
|
||||||
|
},
|
||||||
|
"host": {
|
||||||
|
"description": "The instance host",
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"port": {
|
||||||
|
"description": "The instance port",
|
||||||
|
"type": "integer"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"required": ["dbname", "user", "password"]
|
||||||
|
}', 'A postgres database connection resource')
|
||||||
|
;
|
||||||
|
|
||||||
|
INSERT INTO resource(workspace_id, path, value, description, resource_type) VALUES
|
||||||
|
('starter', 'g/all/demodb', '{"host": "demodb.service.consul", "dbname": "demodb",
|
||||||
|
"user": "postgres", "password": "demodb", "sslmode": "disable", "port":"6543"}', 'demodb', 'postgres')
|
||||||
|
;
|
||||||
|
|
||||||
|
|
||||||
|
CREATE TABLE pipenv (
|
||||||
|
id SERIAL PRIMARY KEY,
|
||||||
|
timestamp TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now(),
|
||||||
|
created_by VARCHAR(50) NOT NULL,
|
||||||
|
python_version VARCHAR(20),
|
||||||
|
dependencies VARCHAR(255)[] NOT NULL DEFAULT array[]::varchar[],
|
||||||
|
pipfile_lock TEXT,
|
||||||
|
job_id UUID
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE schedule(
|
||||||
|
workspace_id VARCHAR(50) NOT NULL REFERENCES workspace(id),
|
||||||
|
path varchar(255),
|
||||||
|
edited_by varchar(255) NOT NULL,
|
||||||
|
edited_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now(),
|
||||||
|
schedule VARCHAR(255) NOT NULL,
|
||||||
|
offset_ INTEGER NOT NULL,
|
||||||
|
enabled BOOLEAN NOT NULL DEFAULT true,
|
||||||
|
script_path varchar(255),
|
||||||
|
script_hash BIGINT,
|
||||||
|
args JSONB,
|
||||||
|
PRIMARY KEY (workspace_id, path),
|
||||||
|
CONSTRAINT proper_id CHECK (path ~ '^[ug](\/[\w-]+){2,}$')
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE group_ (
|
||||||
|
workspace_id VARCHAR(50) NOT NULL REFERENCES workspace(id),
|
||||||
|
name VARCHAR(50),
|
||||||
|
summary TEXT,
|
||||||
|
PRIMARY KEY (workspace_id, name),
|
||||||
|
CONSTRAINT proper_name CHECK (name ~ '^[\w-]+$')
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE usr_to_group(
|
||||||
|
workspace_id VARCHAR(50) NOT NULL REFERENCES workspace(id),
|
||||||
|
group_ VARCHAR(50) NOT NULL,
|
||||||
|
usr VARCHAR(50) NOT NULL DEFAULT 'ruben',
|
||||||
|
CONSTRAINT fk_group FOREIGN KEY(workspace_id, group_) REFERENCES group_(workspace_id, name),
|
||||||
|
PRIMARY KEY (workspace_id, usr, group_)
|
||||||
|
);
|
||||||
|
|
||||||
|
INSERT INTO group_ SELECT id, 'all', 'The group that always contains all users of this workspace' FROM workspace;
|
||||||
|
|
||||||
|
CREATE TABLE worker_ping(
|
||||||
|
worker VARCHAR(50) PRIMARY KEY,
|
||||||
|
worker_instance VARCHAR(50) NOT NULL,
|
||||||
|
ping_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now(),
|
||||||
|
started_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now(),
|
||||||
|
env_id INTEGER NOT NULL DEFAULT -1,
|
||||||
|
ip VARCHAR(50) NOT NULL DEFAULT 'NO IP',
|
||||||
|
jobs_executed INTEGER NOT NULL DEFAULT 0
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX worker_ping_on_ping_at ON worker_ping (ping_at);
|
||||||
|
|
||||||
|
ALTER TABLE audit ENABLE ROW LEVEL SECURITY;
|
||||||
|
CREATE POLICY audit_log_see_own ON audit FOR SELECT
|
||||||
|
USING(audit.username = current_setting('session.user') or current_setting('session.is_admin')::boolean);
|
||||||
|
-- USING(current_setting('session.is_admin')::boolean);
|
||||||
|
|
||||||
|
|
||||||
|
DO
|
||||||
|
$do$
|
||||||
|
BEGIN
|
||||||
|
IF NOT EXISTS (
|
||||||
|
SELECT FROM pg_catalog.pg_roles
|
||||||
|
WHERE rolname = 'app') THEN
|
||||||
|
|
||||||
|
CREATE ROLE app LOGIN PASSWORD 'changeme';
|
||||||
|
END IF;
|
||||||
|
END
|
||||||
|
$do$;
|
||||||
|
|
||||||
|
GRANT SELECT ON audit TO app;
|
||||||
|
|
||||||
|
REVOKE ALL
|
||||||
|
ON ALL TABLES IN SCHEMA public
|
||||||
|
FROM PUBLIC;
|
||||||
|
|
||||||
|
GRANT ALL
|
||||||
|
ON ALL TABLES IN SCHEMA public
|
||||||
|
TO admin;
|
||||||
|
|
||||||
|
ALTER DEFAULT PRIVILEGES
|
||||||
|
FOR ROLE admin
|
||||||
|
IN SCHEMA public
|
||||||
|
GRANT ALL ON TABLES TO admin;
|
||||||
|
|
||||||
|
|
||||||
|
INSERT INTO usr_to_group
|
||||||
|
SELECT workspace_id, 'all', username FROM (SELECT workspace_id, username from usr) as usernames
|
||||||
|
;
|
||||||
|
|
||||||
|
DROP POLICY audit_log_see_own on audit;
|
||||||
|
GRANT ALL ON audit TO app;
|
||||||
|
CREATE POLICY see_own ON audit FOR ALL
|
||||||
|
USING (audit.username = current_setting('session.user'));
|
||||||
|
|
||||||
|
|
||||||
|
GRANT ALL ON queue TO app;
|
||||||
|
ALTER TABLE queue ENABLE ROW LEVEL SECURITY;
|
||||||
|
|
||||||
|
CREATE POLICY see_own ON queue FOR ALL
|
||||||
|
USING (SPLIT_PART(queue.permissioned_as, '/', 1) = 'u' AND SPLIT_PART(queue.permissioned_as, '/', 2) = current_setting('session.user'));
|
||||||
|
|
||||||
|
CREATE POLICY see_member ON queue FOR ALL
|
||||||
|
USING (SPLIT_PART(queue.permissioned_as, '/', 1) = 'g' AND SPLIT_PART(queue.permissioned_as, '/', 2) = any(regexp_split_to_array(current_setting('session.groups'), ',')::text[]));
|
||||||
|
|
||||||
|
GRANT ALL ON completed_job TO app;
|
||||||
|
ALTER TABLE completed_job ENABLE ROW LEVEL SECURITY;
|
||||||
|
|
||||||
|
|
||||||
|
CREATE POLICY see_starter ON completed_job FOR SELECT
|
||||||
|
USING (completed_job.workspace_id = 'starter');
|
||||||
|
|
||||||
|
CREATE POLICY see_own ON completed_job FOR ALL
|
||||||
|
USING (SPLIT_PART(completed_job.permissioned_as, '/', 1) = 'u' AND SPLIT_PART(completed_job.permissioned_as, '/', 2) = current_setting('session.user'));
|
||||||
|
|
||||||
|
CREATE POLICY see_member ON completed_job FOR ALL
|
||||||
|
USING (SPLIT_PART(completed_job.permissioned_as, '/', 1) = 'g' AND SPLIT_PART(completed_job.permissioned_as, '/', 2) = any(regexp_split_to_array(current_setting('session.groups'), ',')::text[]));
|
||||||
|
|
||||||
|
GRANT SELECT ON pipenv to app;
|
||||||
|
GRANT SELECT (email, username, is_admin, workspace_id) ON usr to app;
|
||||||
|
|
||||||
|
GRANT USAGE, SELECT ON ALL SEQUENCES IN SCHEMA public to app;
|
||||||
|
GRANT USAGE, SELECT ON ALL SEQUENCES IN SCHEMA public to admin;
|
||||||
|
GRANT SELECT, INSERT ON resource_type to app;
|
||||||
|
|
||||||
|
GRANT SELECT ON worker_ping to app;
|
||||||
|
GRANT SELECT ON worker_ping to admin;
|
||||||
|
|
||||||
|
CREATE POLICY schedule ON audit FOR INSERT
|
||||||
|
WITH CHECK (audit.username LIKE 'schedule-%');
|
||||||
|
|
||||||
|
|
||||||
|
DO
|
||||||
|
$do$
|
||||||
|
DECLARE
|
||||||
|
i text;
|
||||||
|
arr text[] := array['resource', 'script', 'variable', 'schedule', 'flow'];
|
||||||
|
BEGIN
|
||||||
|
FOREACH i IN ARRAY arr
|
||||||
|
LOOP
|
||||||
|
EXECUTE FORMAT(
|
||||||
|
$$
|
||||||
|
|
||||||
|
GRANT ALL ON %1$I TO app;
|
||||||
|
ALTER TABLE %1$I ENABLE ROW LEVEL SECURITY;
|
||||||
|
|
||||||
|
CREATE POLICY see_starter ON %1$I FOR SELECT
|
||||||
|
USING (%1$I.workspace_id = 'starter');
|
||||||
|
|
||||||
|
CREATE POLICY see_own ON %1$I FOR ALL
|
||||||
|
USING (SPLIT_PART(%1$I.path, '/', 1) = 'u' AND SPLIT_PART(%1$I.path, '/', 2) = current_setting('session.user'));
|
||||||
|
|
||||||
|
CREATE POLICY see_member ON %1$I FOR ALL
|
||||||
|
USING (SPLIT_PART(%1$I.path, '/', 1) = 'g' AND SPLIT_PART(%1$I.path, '/', 2) = any(regexp_split_to_array(current_setting('session.groups'), ',')::text[]));
|
||||||
|
|
||||||
|
ALTER TABLE %1$I
|
||||||
|
ADD COLUMN extra_perms JSONB NOT NULL DEFAULT '{}';
|
||||||
|
|
||||||
|
CREATE INDEX %1$I_extra_perms ON %1$I USING GIN (extra_perms);
|
||||||
|
|
||||||
|
CREATE POLICY see_extra_perms_user ON %1$I FOR ALL
|
||||||
|
USING (extra_perms ? CONCAT('u/', current_setting('session.user')))
|
||||||
|
WITH CHECK ((extra_perms ->> CONCAT('u/', current_setting('session.user')))::boolean);
|
||||||
|
|
||||||
|
CREATE POLICY see_extra_perms_groups ON %1$I FOR ALL
|
||||||
|
USING (extra_perms ?| regexp_split_to_array(current_setting('session.pgroups'), ',')::text[])
|
||||||
|
WITH CHECK (exists(
|
||||||
|
SELECT key, value FROM jsonb_each_text(extra_perms)
|
||||||
|
WHERE SPLIT_PART(key, '/', 1) = 'g' AND key = ANY(regexp_split_to_array(current_setting('session.pgroups'), ',')::text[])
|
||||||
|
AND value::boolean));
|
||||||
|
$$,
|
||||||
|
i
|
||||||
|
);
|
||||||
|
END LOOP;
|
||||||
|
END
|
||||||
|
$do$;
|
||||||
|
|
||||||
|
GRANT ALL ON group_ TO app;
|
||||||
|
ALTER TABLE group_
|
||||||
|
ADD COLUMN extra_perms JSONB NOT NULL DEFAULT '{}';
|
||||||
|
|
||||||
|
CREATE INDEX group_extra_perms ON group_ USING GIN (extra_perms);
|
||||||
|
|
||||||
|
GRANT ALL ON usr_to_group TO app;
|
||||||
|
ALTER TABLE usr_to_group ENABLE ROW LEVEL SECURITY;
|
||||||
|
|
||||||
|
CREATE POLICY see_extra_perms_user ON usr_to_group FOR ALL
|
||||||
|
USING (true)
|
||||||
|
WITH CHECK (EXISTS(SELECT 1 FROM group_ WHERE usr_to_group.group_ = group_.name AND usr_to_group.workspace_id = group_.workspace_id AND (group_.extra_perms ->> CONCAT('u/', current_setting('session.user')))::boolean));
|
||||||
|
|
||||||
|
|
||||||
|
CREATE POLICY see_extra_perms_groups ON usr_to_group FOR ALL
|
||||||
|
USING (true)
|
||||||
|
WITH CHECK (exists(
|
||||||
|
SELECT f.* FROM group_ g, jsonb_each_text(g.extra_perms) f
|
||||||
|
WHERE usr_to_group.group_ = g.name AND usr_to_group.workspace_id = g.workspace_id AND SPLIT_PART(key, '/', 1) = 'g' AND key = ANY(regexp_split_to_array(current_setting('session.pgroups'), ',')::text[])
|
||||||
|
AND value::boolean));
|
||||||
|
|
||||||
|
DO
|
||||||
|
$do$
|
||||||
|
BEGIN
|
||||||
|
IF NOT EXISTS (
|
||||||
|
SELECT FROM pg_catalog.pg_roles -- SELECT list can be empty for this
|
||||||
|
WHERE rolname = 'admin') THEN
|
||||||
|
CREATE ROLE admin WITH BYPASSRLS LOGIN PASSWORD 'changeme';
|
||||||
|
END IF;
|
||||||
|
END
|
||||||
|
$do$;
|
||||||
6
backend/migrations/20220316135622_raw_flow.down.sql
Normal file
6
backend/migrations/20220316135622_raw_flow.down.sql
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
-- Add down migration script here
|
||||||
|
ALTER TABLE queue DROP COLUMN raw_flow;
|
||||||
|
ALTER TABLE completed_job DROP COLUMN raw_flow;
|
||||||
|
|
||||||
|
ALTER TABLE queue DROP COLUMN is_flow_step NOT NULL DEFAULT false;
|
||||||
|
ALTER TABLE completed_job DROP COLUMN is_flow_step NOT NULL DEFAULT false;
|
||||||
6
backend/migrations/20220316135622_raw_flow.up.sql
Normal file
6
backend/migrations/20220316135622_raw_flow.up.sql
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
-- Add up migration script here
|
||||||
|
ALTER TABLE queue ADD COLUMN raw_flow JSONB;
|
||||||
|
ALTER TABLE completed_job ADD COLUMN raw_flow JSONB;
|
||||||
|
|
||||||
|
ALTER TABLE queue ADD COLUMN is_flow_step boolean DEFAULT false;
|
||||||
|
ALTER TABLE completed_job ADD COLUMN is_flow_step boolean DEFAULT false;
|
||||||
4
backend/migrations/20220320122733_schedule_flow.down.sql
Normal file
4
backend/migrations/20220320122733_schedule_flow.down.sql
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
-- Add down migration script here
|
||||||
|
ALTER TABLE schedule ADD COLUMN script_hash BIGINT;
|
||||||
|
ALTER TABLE schedule DROP COLUMN is_flow;
|
||||||
|
|
||||||
4
backend/migrations/20220320122733_schedule_flow.up.sql
Normal file
4
backend/migrations/20220320122733_schedule_flow.up.sql
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
-- Add up migration script here
|
||||||
|
ALTER TABLE schedule DROP COLUMN script_hash;
|
||||||
|
ALTER TABLE schedule ADD COLUMN is_flow boolean NOT NULL DEFAULT false;
|
||||||
|
ALTER TABLE schedule ALTER COLUMN script_path SET NOT NULL;;
|
||||||
1
backend/migrations/20220321004844_flow_preview.down.sql
Normal file
1
backend/migrations/20220321004844_flow_preview.down.sql
Normal file
@@ -0,0 +1 @@
|
|||||||
|
-- Add down migration script here
|
||||||
2
backend/migrations/20220321004844_flow_preview.up.sql
Normal file
2
backend/migrations/20220321004844_flow_preview.up.sql
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
-- Add up migration script here
|
||||||
|
ALTER TYPE JOB_KIND ADD VALUE 'flowpreview';
|
||||||
20
backend/migrations/20220406141754_custom_env.down.sql
Normal file
20
backend/migrations/20220406141754_custom_env.down.sql
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
-- Add down migration script here
|
||||||
|
CREATE TABLE pipenv (
|
||||||
|
id SERIAL PRIMARY KEY,
|
||||||
|
timestamp TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now(),
|
||||||
|
created_by VARCHAR(50) NOT NULL,
|
||||||
|
python_version VARCHAR(20),
|
||||||
|
dependencies VARCHAR(255)[] NOT NULL DEFAULT array[]::varchar[],
|
||||||
|
pipfile_lock TEXT,
|
||||||
|
job_id UUID
|
||||||
|
);
|
||||||
|
|
||||||
|
ALTER TABLE script
|
||||||
|
DROP COLUMN lock;
|
||||||
|
|
||||||
|
ALTER TABLE script
|
||||||
|
DROP COLUMN lock_error_logs;
|
||||||
|
|
||||||
|
ALTER TABLE worker_ping
|
||||||
|
ADD COLUMN env_id INTEGER NOT NULL DEFAULT -1;
|
||||||
|
|
||||||
11
backend/migrations/20220406141754_custom_env.up.sql
Normal file
11
backend/migrations/20220406141754_custom_env.up.sql
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
-- Add up migration script here
|
||||||
|
DROP TABLE pipenv;
|
||||||
|
|
||||||
|
ALTER TABLE script
|
||||||
|
ADD COLUMN lock TEXT;
|
||||||
|
|
||||||
|
ALTER TABLE script
|
||||||
|
ADD COLUMN lock_error_logs TEXT;
|
||||||
|
|
||||||
|
ALTER TABLE worker_ping
|
||||||
|
DROP COLUMN env_id;
|
||||||
6
backend/migrations/20220421061414_jsonb.down.sql
Normal file
6
backend/migrations/20220421061414_jsonb.down.sql
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
-- Add down migration script here
|
||||||
|
ALTER TABLE script
|
||||||
|
ALTER COLUMN schema TYPE jsonb;
|
||||||
|
|
||||||
|
ALTER TABLE flow
|
||||||
|
ALTER COLUMN schema TYPE jsonb;
|
||||||
6
backend/migrations/20220421061414_jsonb.up.sql
Normal file
6
backend/migrations/20220421061414_jsonb.up.sql
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
-- Add up migration script here
|
||||||
|
ALTER TABLE script
|
||||||
|
ALTER COLUMN schema TYPE json;
|
||||||
|
|
||||||
|
ALTER TABLE flow
|
||||||
|
ALTER COLUMN schema TYPE json;
|
||||||
3
backend/migrations/20220428085013_private_key.down.sql
Normal file
3
backend/migrations/20220428085013_private_key.down.sql
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
-- Add down migration script here
|
||||||
|
DROP TABLE workspace_key;
|
||||||
|
DROP TYPE WORKSPACE_KEY_KIND;
|
||||||
16
backend/migrations/20220428085013_private_key.up.sql
Normal file
16
backend/migrations/20220428085013_private_key.up.sql
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
-- Add up migration script here
|
||||||
|
|
||||||
|
CREATE TYPE WORKSPACE_KEY_KIND AS ENUM ('cloud');
|
||||||
|
|
||||||
|
CREATE TABLE workspace_key (
|
||||||
|
workspace_id VARCHAR(50) NOT NULL REFERENCES workspace(id),
|
||||||
|
kind WORKSPACE_KEY_KIND NOT NULL,
|
||||||
|
key VARCHAR(255) NOT NULL DEFAULT 'changeme',
|
||||||
|
PRIMARY KEY (workspace_id, kind)
|
||||||
|
);
|
||||||
|
|
||||||
|
GRANT SELECT ON workspace_key TO app;
|
||||||
|
GRANT SELECT ON workspace_key TO admin;
|
||||||
|
|
||||||
|
INSERT INTO workspace_key SELECT id as workspace_id, 'cloud' as kind, 'changeme' as key FROM workspace;
|
||||||
|
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
-- Add down migration script here
|
||||||
|
DELETE FROM resource_type WHERE name = 'slack' AND workspace_id = 'starter';
|
||||||
14
backend/migrations/20220503085923_slack_resource.up.sql
Normal file
14
backend/migrations/20220503085923_slack_resource.up.sql
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
-- Add up migration script here
|
||||||
|
INSERT INTO resource_type(workspace_id, name, schema, description) VALUES
|
||||||
|
('starter', 'slack', '{
|
||||||
|
"type": "object",
|
||||||
|
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||||
|
"required": [],
|
||||||
|
"properties": {
|
||||||
|
"token": {
|
||||||
|
"type": "string",
|
||||||
|
"description": "The slack token"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}', 'A slack token to interact with a specific workspace. Can be obtained from the OAuth integration in the workspace settings.')
|
||||||
|
;
|
||||||
3406
backend/openapi.yaml
Normal file
3406
backend/openapi.yaml
Normal file
File diff suppressed because it is too large
Load Diff
7
backend/rustfmt.toml
Normal file
7
backend/rustfmt.toml
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
imports_granularity = "Crate"
|
||||||
|
max_width = 100
|
||||||
|
use_small_heuristics = "Default"
|
||||||
|
indent_style = "Block"
|
||||||
|
fn_single_line = false
|
||||||
|
force_multiline_blocks = true
|
||||||
|
format_strings = true
|
||||||
2972
backend/sqlx-data.json
Normal file
2972
backend/sqlx-data.json
Normal file
File diff suppressed because it is too large
Load Diff
159
backend/src/audit.rs
Normal file
159
backend/src/audit.rs
Normal file
@@ -0,0 +1,159 @@
|
|||||||
|
/*
|
||||||
|
* Author & Copyright: Ruben Fiszel 2021
|
||||||
|
* This file and its contents are licensed under the AGPL License.
|
||||||
|
* Please see the included NOTICE for copyright information and
|
||||||
|
* LICENSE-AGPL for a copy of the license.
|
||||||
|
*/
|
||||||
|
|
||||||
|
use sql_builder::prelude::*;
|
||||||
|
|
||||||
|
use std::collections::HashMap;
|
||||||
|
|
||||||
|
use crate::{
|
||||||
|
db::UserDB,
|
||||||
|
error::{Error, JsonResult, Result},
|
||||||
|
users::Authed,
|
||||||
|
utils::Pagination,
|
||||||
|
};
|
||||||
|
use axum::{
|
||||||
|
extract::{Extension, Path, Query},
|
||||||
|
routing::get,
|
||||||
|
Json, Router,
|
||||||
|
};
|
||||||
|
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
use sql_builder::SqlBuilder;
|
||||||
|
use sqlx::{FromRow, Postgres, Transaction};
|
||||||
|
|
||||||
|
pub fn workspaced_service() -> Router {
|
||||||
|
Router::new()
|
||||||
|
.route("/list", get(list_audit))
|
||||||
|
.route("/get/:id", get(get_audit))
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(sqlx::Type, Serialize, Deserialize, Debug)]
|
||||||
|
#[sqlx(type_name = "ACTION_KIND", rename_all = "lowercase")]
|
||||||
|
pub enum ActionKind {
|
||||||
|
Create,
|
||||||
|
Update,
|
||||||
|
Delete,
|
||||||
|
Execute,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(FromRow, Serialize, Deserialize)]
|
||||||
|
pub struct AuditLog {
|
||||||
|
pub workspace_id: String,
|
||||||
|
pub id: i32,
|
||||||
|
pub timestamp: chrono::DateTime<chrono::Utc>,
|
||||||
|
pub username: String,
|
||||||
|
pub operation: String,
|
||||||
|
pub action_kind: ActionKind,
|
||||||
|
pub resource: Option<String>,
|
||||||
|
pub parameters: Option<serde_json::Value>,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn audit_log<'c>(
|
||||||
|
db: &mut Transaction<'c, Postgres>,
|
||||||
|
username: &str,
|
||||||
|
operation: &str,
|
||||||
|
action_kind: ActionKind,
|
||||||
|
w_id: &str,
|
||||||
|
resource: Option<&str>,
|
||||||
|
parameters: Option<HashMap<&str, &str>>,
|
||||||
|
) -> Result<()> {
|
||||||
|
let p_json: serde_json::Value = serde_json::to_value(¶meters).unwrap();
|
||||||
|
|
||||||
|
tracing::info!(
|
||||||
|
username = username,
|
||||||
|
kind = "audit",
|
||||||
|
operation = operation,
|
||||||
|
workspace = w_id,
|
||||||
|
action_kind = ?action_kind,
|
||||||
|
resource = resource,
|
||||||
|
parameters = %p_json
|
||||||
|
);
|
||||||
|
sqlx::query(
|
||||||
|
"INSERT INTO audit
|
||||||
|
(workspace_id, username, operation, action_kind, resource, parameters)
|
||||||
|
VALUES ($1, $2, $3, $4, $5, $6)",
|
||||||
|
)
|
||||||
|
.bind(w_id)
|
||||||
|
.bind(username)
|
||||||
|
.bind(operation)
|
||||||
|
.bind(action_kind)
|
||||||
|
.bind(resource)
|
||||||
|
.bind(p_json)
|
||||||
|
.execute(db)
|
||||||
|
.await?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
pub struct ListAuditLogQuery {
|
||||||
|
pub username: Option<String>,
|
||||||
|
pub operation: Option<String>,
|
||||||
|
pub action_kind: Option<String>,
|
||||||
|
pub resource: Option<String>,
|
||||||
|
pub before: Option<chrono::DateTime<chrono::Utc>>,
|
||||||
|
pub after: Option<chrono::DateTime<chrono::Utc>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn list_audit(
|
||||||
|
authed: Authed,
|
||||||
|
Extension(user_db): Extension<UserDB>,
|
||||||
|
Path(w_id): Path<String>,
|
||||||
|
Query(pagination): Query<Pagination>,
|
||||||
|
Query(lq): Query<ListAuditLogQuery>,
|
||||||
|
) -> JsonResult<Vec<AuditLog>> {
|
||||||
|
let (per_page, offset) = crate::utils::paginate(pagination);
|
||||||
|
|
||||||
|
let mut sqlb = SqlBuilder::select_from("audit")
|
||||||
|
.field("*")
|
||||||
|
.order_by("id", true)
|
||||||
|
.and_where_eq("workspace_id", "?".bind(&w_id))
|
||||||
|
.offset(offset)
|
||||||
|
.limit(per_page)
|
||||||
|
.clone();
|
||||||
|
|
||||||
|
if let Some(u) = &lq.username {
|
||||||
|
sqlb.and_where_eq("username", "?".bind(u));
|
||||||
|
}
|
||||||
|
if let Some(o) = &lq.operation {
|
||||||
|
sqlb.and_where_eq("operation", "?".bind(o));
|
||||||
|
}
|
||||||
|
if let Some(ak) = &lq.action_kind {
|
||||||
|
sqlb.and_where_eq("action_kind", "?".bind(ak));
|
||||||
|
}
|
||||||
|
if let Some(r) = &lq.resource {
|
||||||
|
sqlb.and_where_eq("resource", "?".bind(r));
|
||||||
|
}
|
||||||
|
if let Some(b) = &lq.before {
|
||||||
|
sqlb.and_where_le("timestamp", format!("to_timestamp({})", b.timestamp()));
|
||||||
|
}
|
||||||
|
if let Some(a) = &lq.after {
|
||||||
|
sqlb.and_where_gt("timestamp", format!("to_timestamp({})", a.timestamp()));
|
||||||
|
}
|
||||||
|
|
||||||
|
let sql = sqlb.sql().map_err(|e| Error::InternalErr(e.to_string()))?;
|
||||||
|
let mut tx = user_db.begin(&authed).await?;
|
||||||
|
let rows = sqlx::query_as::<_, AuditLog>(&sql)
|
||||||
|
.fetch_all(&mut tx)
|
||||||
|
.await?;
|
||||||
|
tx.commit().await?;
|
||||||
|
Ok(Json(rows))
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn get_audit(
|
||||||
|
authed: Authed,
|
||||||
|
Extension(user_db): Extension<UserDB>,
|
||||||
|
Path(id): Path<i32>,
|
||||||
|
) -> JsonResult<AuditLog> {
|
||||||
|
let mut tx = user_db.begin(&authed).await?;
|
||||||
|
let audit_o = sqlx::query_as::<_, AuditLog>("SELECT * FROM audit WHERE id = $1")
|
||||||
|
.bind(id)
|
||||||
|
.fetch_optional(&mut tx)
|
||||||
|
.await?;
|
||||||
|
tx.commit().await?;
|
||||||
|
let audit = crate::utils::not_found_if_none(audit_o, "AuditLog", &id.to_string())?;
|
||||||
|
Ok(Json(audit))
|
||||||
|
}
|
||||||
44
backend/src/client.rs
Normal file
44
backend/src/client.rs
Normal file
@@ -0,0 +1,44 @@
|
|||||||
|
use crate::{error::Error, variables::ListableVariable};
|
||||||
|
|
||||||
|
pub async fn get_variable(
|
||||||
|
workspace: &str,
|
||||||
|
path: &str,
|
||||||
|
token: &str,
|
||||||
|
base_url: &str,
|
||||||
|
) -> Result<String, anyhow::Error> {
|
||||||
|
let client = reqwest::Client::new();
|
||||||
|
let res = client
|
||||||
|
.get(format!("{base_url}/api/w/{workspace}/variables/get/{path}"))
|
||||||
|
.bearer_auth(token)
|
||||||
|
.send()
|
||||||
|
.await?;
|
||||||
|
if res.status().is_success() {
|
||||||
|
let value = res
|
||||||
|
.json::<ListableVariable>()
|
||||||
|
.await?
|
||||||
|
.value
|
||||||
|
.unwrap_or_else(|| "".to_string());
|
||||||
|
Ok(value)
|
||||||
|
} else {
|
||||||
|
Err(Error::NotFound(format!("Variable not found at {path}")))?
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn get_resource(
|
||||||
|
workspace: &str,
|
||||||
|
path: &str,
|
||||||
|
token: &str,
|
||||||
|
base_url: &str,
|
||||||
|
) -> Result<Option<serde_json::Value>, anyhow::Error> {
|
||||||
|
let client = reqwest::Client::new();
|
||||||
|
let result = client
|
||||||
|
.get(format!(
|
||||||
|
"{base_url}/api/w/{workspace}/resources/get_value/{path}"
|
||||||
|
))
|
||||||
|
.bearer_auth(token)
|
||||||
|
.send()
|
||||||
|
.await?
|
||||||
|
.json::<Option<serde_json::Value>>()
|
||||||
|
.await?;
|
||||||
|
Ok(result)
|
||||||
|
}
|
||||||
93
backend/src/db.rs
Normal file
93
backend/src/db.rs
Normal file
@@ -0,0 +1,93 @@
|
|||||||
|
/*
|
||||||
|
* Author & Copyright: Ruben Fiszel 2021
|
||||||
|
* This file and its contents are licensed under the AGPL License.
|
||||||
|
* Please see the included NOTICE for copyright information and
|
||||||
|
* LICENSE-AGPL for a copy of the license.
|
||||||
|
*/
|
||||||
|
|
||||||
|
use crate::{error::Error, users::Authed};
|
||||||
|
use sqlx::{postgres::PgPoolOptions, Pool, Postgres, Transaction};
|
||||||
|
use std::time::Duration;
|
||||||
|
|
||||||
|
pub type DB = Pool<Postgres>;
|
||||||
|
|
||||||
|
pub async fn connect(database_url: &str) -> Result<DB, Error> {
|
||||||
|
PgPoolOptions::new()
|
||||||
|
.max_connections(100)
|
||||||
|
.max_lifetime(Duration::from_secs(30 * 60)) // 30 mins
|
||||||
|
.connect(database_url)
|
||||||
|
.await
|
||||||
|
.map_err(|err| Error::ConnectingToDatabase(err.to_string()))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn migrate(db: &DB) -> Result<(), Error> {
|
||||||
|
match sqlx::migrate!("./migrations").run(db).await {
|
||||||
|
Ok(_) => Ok(()),
|
||||||
|
Err(err) => Err(err),
|
||||||
|
}?;
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn setup_app_user(db: &DB, password: &str) -> Result<(), Error> {
|
||||||
|
let mut tx = db.begin().await?;
|
||||||
|
|
||||||
|
sqlx::query(&format!("ALTER USER app WITH PASSWORD '{}'", password))
|
||||||
|
.execute(&mut tx)
|
||||||
|
.await?;
|
||||||
|
sqlx::query(&format!("ALTER USER admin WITH PASSWORD '{}'", password))
|
||||||
|
.execute(&mut tx)
|
||||||
|
.await?;
|
||||||
|
tx.commit().await?;
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
#[derive(Clone)]
|
||||||
|
pub struct UserDB {
|
||||||
|
db: DB,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl UserDB {
|
||||||
|
pub fn new(db: DB) -> Self {
|
||||||
|
Self { db }
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn begin(
|
||||||
|
self,
|
||||||
|
authed: &Authed,
|
||||||
|
) -> Result<Transaction<'static, Postgres>, sqlx::Error> {
|
||||||
|
let mut tx = self.db.begin().await?;
|
||||||
|
let user = if authed.is_admin { "admin" } else { "app" };
|
||||||
|
|
||||||
|
sqlx::query(&format!("SET LOCAL SESSION AUTHORIZATION {}", user))
|
||||||
|
.execute(&mut tx)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
sqlx::query!(
|
||||||
|
"SELECT set_config('session.user', $1, true)",
|
||||||
|
authed.username
|
||||||
|
)
|
||||||
|
.fetch_optional(&mut tx)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
sqlx::query!(
|
||||||
|
"SELECT set_config('session.groups', $1, true)",
|
||||||
|
&authed.groups.join(",")
|
||||||
|
)
|
||||||
|
.fetch_optional(&mut tx)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
sqlx::query!(
|
||||||
|
"SELECT set_config('session.pgroups', $1, true)",
|
||||||
|
&authed
|
||||||
|
.groups
|
||||||
|
.iter()
|
||||||
|
.map(|x| format!("g/{}", x))
|
||||||
|
.collect::<Vec<_>>()
|
||||||
|
.join(",")
|
||||||
|
)
|
||||||
|
.fetch_optional(&mut tx)
|
||||||
|
.await?;
|
||||||
|
Ok(tx)
|
||||||
|
}
|
||||||
|
}
|
||||||
31
backend/src/email.rs
Normal file
31
backend/src/email.rs
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
use lettre::{
|
||||||
|
transport::smtp::authentication::Credentials, AsyncSmtpTransport, AsyncTransport, Message,
|
||||||
|
Tokio1Executor,
|
||||||
|
};
|
||||||
|
|
||||||
|
use crate::error::{Error, Result};
|
||||||
|
|
||||||
|
pub struct EmailSender {
|
||||||
|
pub from: String,
|
||||||
|
pub server: String,
|
||||||
|
pub password: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl EmailSender {
|
||||||
|
pub async fn send_email(&self, email: Message) -> Result<()> {
|
||||||
|
let creds = Credentials::new(self.from.to_string(), self.password.to_string());
|
||||||
|
|
||||||
|
// Open a remote connection to gmail
|
||||||
|
let mailer: AsyncSmtpTransport<Tokio1Executor> =
|
||||||
|
AsyncSmtpTransport::<Tokio1Executor>::relay(&self.server)
|
||||||
|
.unwrap()
|
||||||
|
.credentials(creds)
|
||||||
|
.build();
|
||||||
|
|
||||||
|
mailer
|
||||||
|
.send(email)
|
||||||
|
.await
|
||||||
|
.map_err(|x| Error::InternalErr(format!("Impossible to send email {x}")))?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
}
|
||||||
67
backend/src/error.rs
Normal file
67
backend/src/error.rs
Normal file
@@ -0,0 +1,67 @@
|
|||||||
|
/*
|
||||||
|
* Author & Copyright: Ruben Fiszel 2021
|
||||||
|
* This file and its contents are licensed under the AGPL License.
|
||||||
|
* Please see the included NOTICE for copyright information and
|
||||||
|
* LICENSE-AGPL for a copy of the license.
|
||||||
|
*/
|
||||||
|
|
||||||
|
use axum::{
|
||||||
|
body::{self, BoxBody},
|
||||||
|
response::IntoResponse,
|
||||||
|
Json,
|
||||||
|
};
|
||||||
|
use hyper::{Response, StatusCode};
|
||||||
|
use sqlx::migrate::MigrateError;
|
||||||
|
use thiserror::Error;
|
||||||
|
use tokio::io;
|
||||||
|
|
||||||
|
pub type Result<T> = std::result::Result<T, Error>;
|
||||||
|
pub type JsonResult<T> = std::result::Result<Json<T>, Error>;
|
||||||
|
|
||||||
|
#[derive(Debug, Error)]
|
||||||
|
pub enum Error {
|
||||||
|
#[error("Uuid Error {0}")]
|
||||||
|
UuidErr(#[from] uuid::Error),
|
||||||
|
#[error("Bad config: {0}")]
|
||||||
|
BadConfig(String),
|
||||||
|
#[error("Connecting to database: {0}")]
|
||||||
|
ConnectingToDatabase(String),
|
||||||
|
#[error("Not found: {0}")]
|
||||||
|
NotFound(String),
|
||||||
|
#[error("Not authorized: {0}")]
|
||||||
|
NotAuthorized(String),
|
||||||
|
#[error("{0}")]
|
||||||
|
ExecutionErr(String),
|
||||||
|
#[error("IO error: {0}")]
|
||||||
|
IoErr(#[from] io::Error),
|
||||||
|
#[error("Sql error: {0}")]
|
||||||
|
SqlErr(#[from] sqlx::Error),
|
||||||
|
#[error("Bad request: {0}")]
|
||||||
|
BadRequest(String),
|
||||||
|
#[error("Internal: {0}")]
|
||||||
|
InternalErr(String),
|
||||||
|
#[error("Hexadecimal decoding error: {0}")]
|
||||||
|
HexErr(#[from] hex::FromHexError),
|
||||||
|
#[error("Migrating database: {0}")]
|
||||||
|
DatabaseMigration(#[from] MigrateError),
|
||||||
|
#[error("{0}")]
|
||||||
|
Anyhow(#[from] anyhow::Error),
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn to_anyhow<T: 'static + std::error::Error + Send + Sync>(e: T) -> anyhow::Error {
|
||||||
|
From::from(e)
|
||||||
|
}
|
||||||
|
|
||||||
|
impl IntoResponse for Error {
|
||||||
|
fn into_response(self) -> Response<BoxBody> {
|
||||||
|
let e = &self;
|
||||||
|
let body = body::boxed(body::Full::from(e.to_string()));
|
||||||
|
let status = match self {
|
||||||
|
Self::NotFound(_) => StatusCode::NOT_FOUND,
|
||||||
|
Self::NotAuthorized(_) => StatusCode::UNAUTHORIZED,
|
||||||
|
Self::SqlErr(_) | Self::BadRequest(_) => StatusCode::BAD_REQUEST,
|
||||||
|
_ => StatusCode::INTERNAL_SERVER_ERROR,
|
||||||
|
};
|
||||||
|
Response::builder().status(status).body(body).unwrap()
|
||||||
|
}
|
||||||
|
}
|
||||||
326
backend/src/flow.rs
Normal file
326
backend/src/flow.rs
Normal file
@@ -0,0 +1,326 @@
|
|||||||
|
/*
|
||||||
|
* Author & Copyright: Ruben Fiszel 2021
|
||||||
|
* This file and its contents are licensed under the AGPL License.
|
||||||
|
* Please see the included NOTICE for copyright information and
|
||||||
|
* LICENSE-AGPL for a copy of the license.
|
||||||
|
*/
|
||||||
|
|
||||||
|
use std::collections::HashMap;
|
||||||
|
|
||||||
|
use sql_builder::prelude::*;
|
||||||
|
|
||||||
|
use axum::{
|
||||||
|
extract::{Extension, Path, Query},
|
||||||
|
routing::{get, post},
|
||||||
|
Json, Router,
|
||||||
|
};
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
use sql_builder::SqlBuilder;
|
||||||
|
use sqlx::FromRow;
|
||||||
|
|
||||||
|
use crate::{
|
||||||
|
audit::{audit_log, ActionKind},
|
||||||
|
db::UserDB,
|
||||||
|
error::{Error, JsonResult, Result},
|
||||||
|
scripts::Schema,
|
||||||
|
users::Authed,
|
||||||
|
utils::{Pagination, StripPath},
|
||||||
|
};
|
||||||
|
|
||||||
|
pub fn workspaced_service() -> Router {
|
||||||
|
Router::new()
|
||||||
|
.route("/list", get(list_flows))
|
||||||
|
.route("/create", post(create_flow))
|
||||||
|
.route("/update/*path", post(update_flow))
|
||||||
|
.route("/archive/*path", post(archive_flow_by_path))
|
||||||
|
.route("/get/*path", get(get_flow_by_path))
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(FromRow, Serialize)]
|
||||||
|
pub struct Flow {
|
||||||
|
pub workspace_id: String,
|
||||||
|
pub path: String,
|
||||||
|
pub summary: String,
|
||||||
|
pub description: String,
|
||||||
|
pub value: serde_json::Value,
|
||||||
|
pub edited_by: String,
|
||||||
|
pub edited_at: chrono::DateTime<chrono::Utc>,
|
||||||
|
pub archived: bool,
|
||||||
|
pub schema: Option<Schema>,
|
||||||
|
pub extra_perms: serde_json::Value,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(FromRow, Deserialize)]
|
||||||
|
pub struct NewFlow {
|
||||||
|
pub path: String,
|
||||||
|
pub summary: String,
|
||||||
|
pub description: String,
|
||||||
|
pub value: serde_json::Value,
|
||||||
|
pub schema: Option<Schema>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Deserialize, Serialize)]
|
||||||
|
pub struct FlowValue {
|
||||||
|
pub modules: Vec<FlowModule>,
|
||||||
|
pub failure_module: Option<FlowModule>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Deserialize, Serialize)]
|
||||||
|
pub struct FlowModule {
|
||||||
|
pub input_transform: HashMap<String, InputTransform>,
|
||||||
|
pub value: FlowModuleValue,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Deserialize, Serialize)]
|
||||||
|
#[serde(
|
||||||
|
tag = "type",
|
||||||
|
rename_all(serialize = "lowercase", deserialize = "lowercase")
|
||||||
|
)]
|
||||||
|
pub enum InputTransform {
|
||||||
|
Static { value: serde_json::Value },
|
||||||
|
Javascript { expr: String },
|
||||||
|
Resource { path: String },
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Serialize, Deserialize, Debug)]
|
||||||
|
#[serde(
|
||||||
|
tag = "type",
|
||||||
|
rename_all(serialize = "lowercase", deserialize = "lowercase")
|
||||||
|
)]
|
||||||
|
pub enum FlowModuleValue {
|
||||||
|
Script { path: String },
|
||||||
|
Flow { path: String },
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
pub struct ListFlowQuery {
|
||||||
|
pub path_start: Option<String>,
|
||||||
|
pub path_exact: Option<String>,
|
||||||
|
pub edited_by: Option<String>,
|
||||||
|
pub show_archived: Option<bool>,
|
||||||
|
pub order_by: Option<String>,
|
||||||
|
pub order_desc: Option<bool>,
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn list_flows(
|
||||||
|
authed: Authed,
|
||||||
|
Extension(user_db): Extension<UserDB>,
|
||||||
|
Path(w_id): Path<String>,
|
||||||
|
Query(pagination): Query<Pagination>,
|
||||||
|
Query(lq): Query<ListFlowQuery>,
|
||||||
|
) -> JsonResult<Vec<Flow>> {
|
||||||
|
let (per_page, offset) = crate::utils::paginate(pagination);
|
||||||
|
|
||||||
|
let mut sqlb = SqlBuilder::select_from("flow as o")
|
||||||
|
.fields(&[
|
||||||
|
"workspace_id",
|
||||||
|
"path",
|
||||||
|
"summary",
|
||||||
|
"description",
|
||||||
|
"'{}'::jsonb as value",
|
||||||
|
"edited_by",
|
||||||
|
"edited_at",
|
||||||
|
"archived",
|
||||||
|
"schema",
|
||||||
|
"extra_perms",
|
||||||
|
])
|
||||||
|
.order_by("edited_at", lq.order_desc.unwrap_or(true))
|
||||||
|
.and_where("workspace_id = ? OR workspace_id = 'starter'".bind(&w_id))
|
||||||
|
.offset(offset)
|
||||||
|
.limit(per_page)
|
||||||
|
.clone();
|
||||||
|
|
||||||
|
if !lq.show_archived.unwrap_or(false) {
|
||||||
|
sqlb.and_where_eq("archived", false);
|
||||||
|
}
|
||||||
|
if let Some(ps) = &lq.path_start {
|
||||||
|
sqlb.and_where_like_left("path", "?".bind(ps));
|
||||||
|
}
|
||||||
|
if let Some(p) = &lq.path_exact {
|
||||||
|
sqlb.and_where_eq("path", "?".bind(p));
|
||||||
|
}
|
||||||
|
if let Some(cb) = &lq.edited_by {
|
||||||
|
sqlb.and_where_eq("edited_by", "?".bind(cb));
|
||||||
|
}
|
||||||
|
|
||||||
|
let sql = sqlb.sql().map_err(|e| Error::InternalErr(e.to_string()))?;
|
||||||
|
let mut tx = user_db.begin(&authed).await?;
|
||||||
|
let rows = sqlx::query_as::<_, Flow>(&sql).fetch_all(&mut tx).await?;
|
||||||
|
tx.commit().await?;
|
||||||
|
Ok(Json(rows))
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn create_flow(
|
||||||
|
authed: Authed,
|
||||||
|
Extension(user_db): Extension<UserDB>,
|
||||||
|
Path(w_id): Path<String>,
|
||||||
|
Json(nf): Json<NewFlow>,
|
||||||
|
) -> Result<String> {
|
||||||
|
// cron::Schedule::from_str(&ns.schedule).map_err(|e| error::Error::BadRequest(e.to_string()))?;
|
||||||
|
let mut tx = user_db.begin(&authed).await?;
|
||||||
|
|
||||||
|
sqlx::query!(
|
||||||
|
"INSERT INTO flow (workspace_id, path, summary, description, value, edited_by, edited_at, schema) VALUES ($1, $2, $3, $4, $5, $6, $7, $8::text::json)",
|
||||||
|
w_id,
|
||||||
|
nf.path,
|
||||||
|
nf.summary,
|
||||||
|
nf.description,
|
||||||
|
nf.value,
|
||||||
|
&authed.username,
|
||||||
|
&chrono::Utc::now(),
|
||||||
|
nf.schema.and_then(|x| serde_json::to_string(&x.0).ok()),
|
||||||
|
)
|
||||||
|
.execute(&mut tx)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
audit_log(
|
||||||
|
&mut tx,
|
||||||
|
&authed.username,
|
||||||
|
"flows.create",
|
||||||
|
ActionKind::Create,
|
||||||
|
&w_id,
|
||||||
|
Some(&nf.path.to_string()),
|
||||||
|
Some(
|
||||||
|
[Some(("flow", nf.path.as_str()))]
|
||||||
|
.into_iter()
|
||||||
|
.flatten()
|
||||||
|
.collect(),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
tx.commit().await?;
|
||||||
|
Ok(nf.path.to_string())
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn update_flow(
|
||||||
|
authed: Authed,
|
||||||
|
Extension(user_db): Extension<UserDB>,
|
||||||
|
Path((w_id, flow_path)): Path<(String, StripPath)>,
|
||||||
|
Json(nf): Json<NewFlow>,
|
||||||
|
) -> Result<String> {
|
||||||
|
let mut tx = user_db.begin(&authed).await?;
|
||||||
|
|
||||||
|
let flow_path = flow_path.to_path();
|
||||||
|
let schema = nf.schema.map(|x| x.0);
|
||||||
|
let flow = sqlx::query_scalar!(
|
||||||
|
"UPDATE flow SET path = $1, summary = $2, description = $3, value = $4, edited_by = $5, edited_at = $6, schema = $7 WHERE path = $8 AND workspace_id = $9 RETURNING path",
|
||||||
|
nf.path,
|
||||||
|
nf.summary,
|
||||||
|
nf.description,
|
||||||
|
nf.value,
|
||||||
|
&authed.username,
|
||||||
|
&chrono::Utc::now(),
|
||||||
|
schema,
|
||||||
|
flow_path,
|
||||||
|
w_id,
|
||||||
|
)
|
||||||
|
.fetch_optional(&mut tx)
|
||||||
|
.await?;
|
||||||
|
crate::utils::not_found_if_none(flow, "Flow", flow_path)?;
|
||||||
|
|
||||||
|
audit_log(
|
||||||
|
&mut tx,
|
||||||
|
&authed.username,
|
||||||
|
"flows.update",
|
||||||
|
ActionKind::Create,
|
||||||
|
&w_id,
|
||||||
|
Some(&nf.path.to_string()),
|
||||||
|
Some(
|
||||||
|
[Some(("flow", nf.path.as_str()))]
|
||||||
|
.into_iter()
|
||||||
|
.flatten()
|
||||||
|
.collect(),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
tx.commit().await?;
|
||||||
|
Ok(nf.path.to_string())
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn get_flow_by_path(
|
||||||
|
authed: Authed,
|
||||||
|
Extension(user_db): Extension<UserDB>,
|
||||||
|
Path((w_id, path)): Path<(String, StripPath)>,
|
||||||
|
) -> JsonResult<Flow> {
|
||||||
|
let path = path.to_path();
|
||||||
|
let mut tx = user_db.begin(&authed).await?;
|
||||||
|
|
||||||
|
let flow_o = sqlx::query_as::<_, Flow>(
|
||||||
|
"SELECT * FROM flow WHERE path = $1 AND (workspace_id = $2 OR workspace_id = 'starter')",
|
||||||
|
)
|
||||||
|
.bind(path)
|
||||||
|
.bind(w_id)
|
||||||
|
.fetch_optional(&mut tx)
|
||||||
|
.await?;
|
||||||
|
tx.commit().await?;
|
||||||
|
|
||||||
|
let flow = crate::utils::not_found_if_none(flow_o, "Flow", path)?;
|
||||||
|
Ok(Json(flow))
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn archive_flow_by_path(
|
||||||
|
authed: Authed,
|
||||||
|
Extension(user_db): Extension<UserDB>,
|
||||||
|
Path((w_id, path)): Path<(String, StripPath)>,
|
||||||
|
) -> Result<String> {
|
||||||
|
let path = path.to_path();
|
||||||
|
let mut tx = user_db.begin(&authed).await?;
|
||||||
|
|
||||||
|
sqlx::query!(
|
||||||
|
"UPDATE flow SET archived = true WHERE path = $1 AND workspace_id = $2",
|
||||||
|
path,
|
||||||
|
&w_id
|
||||||
|
)
|
||||||
|
.execute(&mut tx)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
audit_log(
|
||||||
|
&mut tx,
|
||||||
|
&authed.username,
|
||||||
|
"flows.archive",
|
||||||
|
ActionKind::Delete,
|
||||||
|
&w_id,
|
||||||
|
Some(path),
|
||||||
|
Some([("workspace", w_id.as_str())].into()),
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
tx.commit().await?;
|
||||||
|
|
||||||
|
Ok(format!("Flow {path} archived"))
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
|
||||||
|
// Note this useful idiom: importing names from outer (for mod tests) scope.
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_serialize() -> anyhow::Result<()> {
|
||||||
|
let mut hm = HashMap::new();
|
||||||
|
hm.insert(
|
||||||
|
"test".to_owned(),
|
||||||
|
InputTransform::Static {
|
||||||
|
value: serde_json::json!("test2"),
|
||||||
|
},
|
||||||
|
);
|
||||||
|
let fv = FlowValue {
|
||||||
|
modules: vec![FlowModule {
|
||||||
|
input_transform: hm,
|
||||||
|
value: FlowModuleValue::Script {
|
||||||
|
path: "test".to_string(),
|
||||||
|
},
|
||||||
|
}],
|
||||||
|
failure_module: Some(FlowModule {
|
||||||
|
input_transform: HashMap::new(),
|
||||||
|
value: FlowModuleValue::Flow {
|
||||||
|
path: "test".to_string(),
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
println!("{}", serde_json::json!(fv).to_string());
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
}
|
||||||
116
backend/src/granular_acls.rs
Normal file
116
backend/src/granular_acls.rs
Normal file
@@ -0,0 +1,116 @@
|
|||||||
|
/*
|
||||||
|
* Author & Copyright: Ruben Fiszel 2021
|
||||||
|
* This file and its contents are licensed under the AGPL License.
|
||||||
|
* Please see the included NOTICE for copyright information and
|
||||||
|
* LICENSE-AGPL for a copy of the license.
|
||||||
|
*/
|
||||||
|
|
||||||
|
use crate::{
|
||||||
|
db::UserDB,
|
||||||
|
error::{Error, JsonResult, Result},
|
||||||
|
users::Authed,
|
||||||
|
utils::StripPath,
|
||||||
|
};
|
||||||
|
use axum::{
|
||||||
|
extract::{Extension, Path},
|
||||||
|
routing::{get, post},
|
||||||
|
Json, Router,
|
||||||
|
};
|
||||||
|
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
|
pub fn workspaced_service() -> Router {
|
||||||
|
Router::new()
|
||||||
|
.route("/get/*path", get(get_granular_acls))
|
||||||
|
.route("/add/*path", post(add_granular_acl))
|
||||||
|
.route("/remove/*path", post(remove_granular_acl))
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Serialize, Deserialize)]
|
||||||
|
pub struct GranularAcl {
|
||||||
|
pub owner: String,
|
||||||
|
pub write: Option<bool>,
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn add_granular_acl(
|
||||||
|
authed: Authed,
|
||||||
|
Extension(user_db): Extension<UserDB>,
|
||||||
|
Path((w_id, path)): Path<(String, StripPath)>,
|
||||||
|
Json(GranularAcl { owner, write }): Json<GranularAcl>,
|
||||||
|
) -> Result<String> {
|
||||||
|
let path = path.to_path();
|
||||||
|
let (kind, path) = path
|
||||||
|
.split_once('/')
|
||||||
|
.ok_or_else(|| Error::BadRequest("Invalid path or kind".to_string()))?;
|
||||||
|
let mut tx = user_db.begin(&authed).await?;
|
||||||
|
|
||||||
|
let identifier = if kind == "group_" { "name" } else { "path" };
|
||||||
|
let obj_o = sqlx::query_scalar::<_, serde_json::Value>(&format!(
|
||||||
|
"UPDATE {kind} SET extra_perms = jsonb_set(extra_perms, '{{\"{owner}\"}}', to_jsonb($1), true) WHERE {identifier} = $2 AND workspace_id = $3 RETURNING extra_perms"
|
||||||
|
))
|
||||||
|
.bind(write.unwrap_or(false))
|
||||||
|
.bind(path)
|
||||||
|
.bind(&w_id)
|
||||||
|
.fetch_optional(&mut tx)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
let _ = crate::utils::not_found_if_none(obj_o, &kind, &path)?;
|
||||||
|
tx.commit().await?;
|
||||||
|
|
||||||
|
Ok("Successfully modified granular acl".to_string())
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn remove_granular_acl(
|
||||||
|
authed: Authed,
|
||||||
|
Extension(user_db): Extension<UserDB>,
|
||||||
|
Path((w_id, path)): Path<(String, StripPath)>,
|
||||||
|
Json(GranularAcl { owner, write: _ }): Json<GranularAcl>,
|
||||||
|
) -> Result<String> {
|
||||||
|
let path = path.to_path();
|
||||||
|
let (kind, path) = path
|
||||||
|
.split_once('/')
|
||||||
|
.ok_or_else(|| Error::BadRequest("Invalid path or kind".to_string()))?;
|
||||||
|
let mut tx = user_db.begin(&authed).await?;
|
||||||
|
|
||||||
|
let identifier = if kind == "group_" { "name" } else { "path" };
|
||||||
|
let obj_o = sqlx::query_scalar::<_, serde_json::Value>(&format!(
|
||||||
|
"UPDATE {kind} SET extra_perms = extra_perms - $1 WHERE {identifier} = $2 AND workspace_id = $3 RETURNING extra_perms"
|
||||||
|
))
|
||||||
|
.bind(owner)
|
||||||
|
.bind(path)
|
||||||
|
.bind(w_id)
|
||||||
|
.fetch_optional(&mut tx)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
let _ = crate::utils::not_found_if_none(obj_o, &kind, &path)?;
|
||||||
|
tx.commit().await?;
|
||||||
|
|
||||||
|
Ok("Successfully removed granular acl".to_string())
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn get_granular_acls(
|
||||||
|
authed: Authed,
|
||||||
|
Extension(user_db): Extension<UserDB>,
|
||||||
|
Path((w_id, path)): Path<(String, StripPath)>,
|
||||||
|
) -> JsonResult<serde_json::Value> {
|
||||||
|
let path = path.to_path();
|
||||||
|
let (kind, path) = path
|
||||||
|
.split_once('/')
|
||||||
|
.ok_or_else(|| Error::BadRequest("Invalid path or kind".to_string()))?;
|
||||||
|
|
||||||
|
let mut tx = user_db.begin(&authed).await?;
|
||||||
|
|
||||||
|
let identifier = if kind == "group_" { "name" } else { "path" };
|
||||||
|
let obj_o = sqlx::query_scalar::<_, serde_json::Value>(&format!(
|
||||||
|
"SELECT extra_perms from {kind} WHERE {identifier} = $1 AND workspace_id = $2"
|
||||||
|
))
|
||||||
|
.bind(path)
|
||||||
|
.bind(w_id)
|
||||||
|
.fetch_optional(&mut tx)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
let obj = crate::utils::not_found_if_none(obj_o, &kind, &path)?;
|
||||||
|
tx.commit().await?;
|
||||||
|
|
||||||
|
Ok(Json(obj))
|
||||||
|
}
|
||||||
330
backend/src/groups.rs
Normal file
330
backend/src/groups.rs
Normal file
@@ -0,0 +1,330 @@
|
|||||||
|
/*
|
||||||
|
* Author & Copyright: Ruben Fiszel 2021
|
||||||
|
* This file and its contents are licensed under the AGPL License.
|
||||||
|
* Please see the included NOTICE for copyright information and
|
||||||
|
* LICENSE-AGPL for a copy of the license.
|
||||||
|
*/
|
||||||
|
|
||||||
|
use crate::{
|
||||||
|
audit::{audit_log, ActionKind},
|
||||||
|
db::{UserDB, DB},
|
||||||
|
error::{Error, JsonResult, Result},
|
||||||
|
users::{owner_to_token_owner, Authed},
|
||||||
|
utils::Pagination,
|
||||||
|
};
|
||||||
|
use axum::{
|
||||||
|
extract::{Extension, Path, Query},
|
||||||
|
routing::{delete, get, post},
|
||||||
|
Json, Router,
|
||||||
|
};
|
||||||
|
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
use sqlx::{FromRow, Postgres, Transaction};
|
||||||
|
|
||||||
|
pub fn workspaced_service() -> Router {
|
||||||
|
Router::new()
|
||||||
|
.route("/list", get(list_groups))
|
||||||
|
.route("/listnames", get(list_group_names))
|
||||||
|
.route("/create", post(create_group))
|
||||||
|
.route("/get/:name", get(get_group))
|
||||||
|
.route("/update/:name", post(update_group))
|
||||||
|
.route("/delete/:name", delete(delete_group))
|
||||||
|
.route("/adduser/:name", post(add_user))
|
||||||
|
.route("/removeuser/:name", post(remove_user))
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(FromRow, Serialize, Deserialize)]
|
||||||
|
pub struct Group {
|
||||||
|
pub workspace_id: String,
|
||||||
|
pub name: String,
|
||||||
|
pub summary: Option<String>,
|
||||||
|
pub extra_perms: serde_json::Value,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
pub struct NewGroup {
|
||||||
|
pub name: String,
|
||||||
|
pub summary: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Serialize)]
|
||||||
|
pub struct GroupInfo {
|
||||||
|
pub workspace_id: String,
|
||||||
|
pub name: String,
|
||||||
|
pub summary: Option<String>,
|
||||||
|
pub members: Vec<String>,
|
||||||
|
pub extra_perms: serde_json::Value,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
pub struct EditGroup {
|
||||||
|
pub summary: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
pub struct Username {
|
||||||
|
pub username: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn list_groups(
|
||||||
|
Extension(db): Extension<DB>,
|
||||||
|
Path(w_id): Path<String>,
|
||||||
|
Query(pagination): Query<Pagination>,
|
||||||
|
) -> JsonResult<Vec<Group>> {
|
||||||
|
let (per_page, offset) = crate::utils::paginate(pagination);
|
||||||
|
|
||||||
|
let rows = sqlx::query_as!(
|
||||||
|
Group,
|
||||||
|
"SELECT * FROM group_ WHERE workspace_id = $1 ORDER BY name desc LIMIT $2 OFFSET $3",
|
||||||
|
w_id,
|
||||||
|
per_page as i64,
|
||||||
|
offset as i64
|
||||||
|
)
|
||||||
|
.fetch_all(&db)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
Ok(Json(rows))
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn list_group_names(
|
||||||
|
Extension(db): Extension<DB>,
|
||||||
|
Path(w_id): Path<String>,
|
||||||
|
) -> JsonResult<Vec<String>> {
|
||||||
|
let rows = sqlx::query_scalar!(
|
||||||
|
"SELECT name FROM group_ WHERE workspace_id = $1 ORDER BY name desc",
|
||||||
|
w_id
|
||||||
|
)
|
||||||
|
.fetch_all(&db)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
Ok(Json(rows))
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn create_group(
|
||||||
|
authed: Authed,
|
||||||
|
Extension(user_db): Extension<UserDB>,
|
||||||
|
Path(w_id): Path<String>,
|
||||||
|
Json(ng): Json<NewGroup>,
|
||||||
|
) -> Result<String> {
|
||||||
|
let mut tx = user_db.begin(&authed).await?;
|
||||||
|
|
||||||
|
sqlx::query_as!(
|
||||||
|
Group,
|
||||||
|
"INSERT INTO group_ (workspace_id, name, summary, extra_perms) VALUES ($1, $2, $3, $4)",
|
||||||
|
w_id,
|
||||||
|
ng.name,
|
||||||
|
ng.summary,
|
||||||
|
serde_json::json!({owner_to_token_owner(&authed.username, false): true})
|
||||||
|
)
|
||||||
|
.execute(&mut tx)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
audit_log(
|
||||||
|
&mut tx,
|
||||||
|
&authed.username,
|
||||||
|
"group.create",
|
||||||
|
ActionKind::Create,
|
||||||
|
&w_id,
|
||||||
|
Some(&ng.name.to_string()),
|
||||||
|
None,
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
tx.commit().await?;
|
||||||
|
Ok(format!("Created group {}", ng.name))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn get_group_opt<'c>(
|
||||||
|
db: &mut Transaction<'c, Postgres>,
|
||||||
|
w_id: &str,
|
||||||
|
name: &str,
|
||||||
|
) -> Result<Option<Group>> {
|
||||||
|
let group_opt = sqlx::query_as!(
|
||||||
|
Group,
|
||||||
|
"SELECT * FROM group_ WHERE name = $1 AND workspace_id = $2",
|
||||||
|
name,
|
||||||
|
w_id
|
||||||
|
)
|
||||||
|
.fetch_optional(db)
|
||||||
|
.await?;
|
||||||
|
Ok(group_opt)
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn get_group(
|
||||||
|
authed: Authed,
|
||||||
|
Extension(user_db): Extension<UserDB>,
|
||||||
|
Path((w_id, name)): Path<(String, String)>,
|
||||||
|
) -> JsonResult<GroupInfo> {
|
||||||
|
let mut tx = user_db.begin(&authed).await?;
|
||||||
|
|
||||||
|
let group = crate::utils::not_found_if_none(
|
||||||
|
get_group_opt(&mut tx, &w_id, &name).await?,
|
||||||
|
"Group",
|
||||||
|
&name,
|
||||||
|
)?;
|
||||||
|
|
||||||
|
let members = sqlx::query_scalar!(
|
||||||
|
"SELECT usr.username
|
||||||
|
FROM usr_to_group LEFT JOIN usr ON usr_to_group.usr = usr.username
|
||||||
|
WHERE group_ = $1 AND usr.workspace_id = $2 AND usr_to_group.workspace_id = $2",
|
||||||
|
name,
|
||||||
|
w_id
|
||||||
|
)
|
||||||
|
.fetch_all(&mut tx)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
tx.commit().await?;
|
||||||
|
Ok(Json(GroupInfo {
|
||||||
|
workspace_id: group.workspace_id,
|
||||||
|
name: group.name,
|
||||||
|
summary: group.summary,
|
||||||
|
members,
|
||||||
|
extra_perms: group.extra_perms,
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn delete_group(
|
||||||
|
authed: Authed,
|
||||||
|
Extension(user_db): Extension<UserDB>,
|
||||||
|
Path((w_id, name)): Path<(String, String)>,
|
||||||
|
) -> Result<String> {
|
||||||
|
let mut tx = user_db.begin(&authed).await?;
|
||||||
|
|
||||||
|
crate::utils::not_found_if_none(get_group_opt(&mut tx, &w_id, &name).await?, "Group", &name)?;
|
||||||
|
|
||||||
|
sqlx::query!(
|
||||||
|
"DELETE FROM usr_to_group WHERE group_ = $1 AND workspace_id = $2",
|
||||||
|
name,
|
||||||
|
w_id
|
||||||
|
)
|
||||||
|
.execute(&mut tx)
|
||||||
|
.await?;
|
||||||
|
sqlx::query!(
|
||||||
|
"DELETE FROM group_ WHERE name = $1 AND workspace_id = $2",
|
||||||
|
name,
|
||||||
|
w_id
|
||||||
|
)
|
||||||
|
.execute(&mut tx)
|
||||||
|
.await?;
|
||||||
|
audit_log(
|
||||||
|
&mut tx,
|
||||||
|
&authed.username,
|
||||||
|
"group.delete",
|
||||||
|
ActionKind::Delete,
|
||||||
|
&w_id,
|
||||||
|
Some(&name.to_string()),
|
||||||
|
None,
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
tx.commit().await?;
|
||||||
|
Ok(format!("delete group at name {}", name))
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn update_group(
|
||||||
|
authed: Authed,
|
||||||
|
Extension(user_db): Extension<UserDB>,
|
||||||
|
Path((w_id, name)): Path<(String, String)>,
|
||||||
|
Json(eg): Json<EditGroup>,
|
||||||
|
) -> Result<String> {
|
||||||
|
let mut tx = user_db.begin(&authed).await?;
|
||||||
|
|
||||||
|
crate::utils::not_found_if_none(get_group_opt(&mut tx, &w_id, &name).await?, "Group", &name)?;
|
||||||
|
|
||||||
|
sqlx::query_as!(
|
||||||
|
Group,
|
||||||
|
"UPDATE group_ SET summary = $1 WHERE name = $2 AND workspace_id = $3",
|
||||||
|
eg.summary,
|
||||||
|
&name,
|
||||||
|
&w_id
|
||||||
|
)
|
||||||
|
.execute(&mut tx)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
audit_log(
|
||||||
|
&mut tx,
|
||||||
|
&authed.username,
|
||||||
|
"group.edit",
|
||||||
|
ActionKind::Update,
|
||||||
|
&w_id,
|
||||||
|
Some(&name.to_string()),
|
||||||
|
None,
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
tx.commit().await?;
|
||||||
|
Ok(format!("Edited group {}", name))
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn add_user(
|
||||||
|
authed: Authed,
|
||||||
|
Extension(user_db): Extension<UserDB>,
|
||||||
|
Path((w_id, name)): Path<(String, String)>,
|
||||||
|
Json(Username {
|
||||||
|
username: user_username,
|
||||||
|
}): Json<Username>,
|
||||||
|
) -> Result<String> {
|
||||||
|
let mut tx = user_db.begin(&authed).await?;
|
||||||
|
|
||||||
|
crate::utils::not_found_if_none(get_group_opt(&mut tx, &w_id, &name).await?, "Group", &name)?;
|
||||||
|
|
||||||
|
sqlx::query_as!(
|
||||||
|
Group,
|
||||||
|
"INSERT INTO usr_to_group (workspace_id, usr, group_) VALUES ($1, $2, $3)",
|
||||||
|
&w_id,
|
||||||
|
user_username,
|
||||||
|
name,
|
||||||
|
)
|
||||||
|
.execute(&mut tx)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
audit_log(
|
||||||
|
&mut tx,
|
||||||
|
&authed.username,
|
||||||
|
"group.adduser",
|
||||||
|
ActionKind::Update,
|
||||||
|
&w_id,
|
||||||
|
Some(&name.to_string()),
|
||||||
|
Some([("user", user_username.as_str())].into()),
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
tx.commit().await?;
|
||||||
|
Ok(format!("Added {} to group {}", user_username, name))
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn remove_user(
|
||||||
|
authed: Authed,
|
||||||
|
Extension(user_db): Extension<UserDB>,
|
||||||
|
Path((w_id, name)): Path<(String, String)>,
|
||||||
|
Json(Username {
|
||||||
|
username: user_username,
|
||||||
|
}): Json<Username>,
|
||||||
|
) -> Result<String> {
|
||||||
|
let mut tx = user_db.begin(&authed).await?;
|
||||||
|
|
||||||
|
crate::utils::not_found_if_none(get_group_opt(&mut tx, &w_id, &name).await?, "Group", &name)?;
|
||||||
|
if &name == "all" {
|
||||||
|
return Err(Error::BadRequest(format!("Cannot delete users from all")));
|
||||||
|
}
|
||||||
|
sqlx::query_as!(
|
||||||
|
Group,
|
||||||
|
"DELETE FROM usr_to_group WHERE usr = $1 AND group_ = $2 AND workspace_id = $3",
|
||||||
|
user_username,
|
||||||
|
name,
|
||||||
|
&w_id,
|
||||||
|
)
|
||||||
|
.execute(&mut tx)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
audit_log(
|
||||||
|
&mut tx,
|
||||||
|
&authed.username,
|
||||||
|
"group.removeuser",
|
||||||
|
ActionKind::Update,
|
||||||
|
&w_id,
|
||||||
|
Some(&name.to_string()),
|
||||||
|
Some([("user", user_username.as_str())].into()),
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
tx.commit().await?;
|
||||||
|
Ok(format!("Removed {} to group {}", user_username, name))
|
||||||
|
}
|
||||||
1525
backend/src/jobs.rs
Normal file
1525
backend/src/jobs.rs
Normal file
File diff suppressed because it is too large
Load Diff
300
backend/src/js_eval.rs
Normal file
300
backend/src/js_eval.rs
Normal file
@@ -0,0 +1,300 @@
|
|||||||
|
/*
|
||||||
|
* Author & Copyright: Ruben Fiszel 2021
|
||||||
|
* This file and its contents are licensed under the AGPL License.
|
||||||
|
* Please see the included NOTICE for copyright information and
|
||||||
|
* LICENSE-AGPL for a copy of the license.
|
||||||
|
*/
|
||||||
|
|
||||||
|
use std::cell::RefCell;
|
||||||
|
use std::rc::Rc;
|
||||||
|
|
||||||
|
use deno_core::serde_v8;
|
||||||
|
use deno_core::v8;
|
||||||
|
use deno_core::v8::IsolateHandle;
|
||||||
|
use deno_core::JsRuntime;
|
||||||
|
use deno_core::OpState;
|
||||||
|
use deno_core::RuntimeOptions;
|
||||||
|
use deno_core::Snapshot;
|
||||||
|
use deno_core::ZeroCopyBuf;
|
||||||
|
use itertools::Itertools;
|
||||||
|
use regex::Regex;
|
||||||
|
use serde_json::Value;
|
||||||
|
use tokio::sync::oneshot;
|
||||||
|
use tokio::time::timeout;
|
||||||
|
|
||||||
|
use crate::client;
|
||||||
|
use crate::error::Error;
|
||||||
|
|
||||||
|
pub async fn eval_timeout(
|
||||||
|
expr: String,
|
||||||
|
env: Vec<(String, serde_json::Value)>,
|
||||||
|
workspace: &str,
|
||||||
|
token: &str,
|
||||||
|
steps: Vec<String>,
|
||||||
|
) -> anyhow::Result<serde_json::Value> {
|
||||||
|
let expr2 = expr.clone();
|
||||||
|
let (sender, mut receiver) = oneshot::channel::<IsolateHandle>();
|
||||||
|
let (workspace, token) = (workspace.to_string().clone(), token.to_string().clone());
|
||||||
|
timeout(
|
||||||
|
std::time::Duration::from_millis(2000),
|
||||||
|
tokio::task::spawn_blocking(move || {
|
||||||
|
let buffer = include_bytes!("../v8.snap");
|
||||||
|
|
||||||
|
// Use our snapshot to provision our new runtime
|
||||||
|
let options = RuntimeOptions {
|
||||||
|
startup_snapshot: Some(Snapshot::Static(buffer)),
|
||||||
|
..Default::default()
|
||||||
|
};
|
||||||
|
let mut js_runtime = JsRuntime::new(options);
|
||||||
|
js_runtime.register_op("variable", deno_core::op_async(op_variable));
|
||||||
|
js_runtime.register_op("resource", deno_core::op_async(op_resource));
|
||||||
|
if !steps.is_empty() {
|
||||||
|
js_runtime.register_op("result", deno_core::op_async(op_get_result));
|
||||||
|
}
|
||||||
|
js_runtime.sync_ops_cache();
|
||||||
|
|
||||||
|
sender
|
||||||
|
.send(js_runtime.v8_isolate().thread_safe_handle())
|
||||||
|
.map_err(|_| Error::ExecutionErr("impossible to send v8 isolate".to_string()))?;
|
||||||
|
|
||||||
|
let runtime = tokio::runtime::Builder::new_current_thread()
|
||||||
|
.enable_all()
|
||||||
|
.build()?;
|
||||||
|
|
||||||
|
let re = Regex::new(r"import (.*)\n").unwrap();
|
||||||
|
let expr = re.replace_all(&expr, "").to_string();
|
||||||
|
// pretty frail but this it to make the expr more user friendly and not require the user to write await
|
||||||
|
let expr = ["variable", "step"]
|
||||||
|
.into_iter()
|
||||||
|
.fold(expr, replace_with_await);
|
||||||
|
|
||||||
|
let r =
|
||||||
|
runtime.block_on(eval(&mut js_runtime, &expr, env, &workspace, &token, steps))?;
|
||||||
|
|
||||||
|
Ok(r) as anyhow::Result<Value>
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.map_err(|_| {
|
||||||
|
if let Ok(isolate) = receiver.try_recv() {
|
||||||
|
isolate.terminate_execution();
|
||||||
|
};
|
||||||
|
Error::ExecutionErr(format!(
|
||||||
|
"The expression of evaluation `{expr2}` took too long to execute (>2000ms)"
|
||||||
|
))
|
||||||
|
})??
|
||||||
|
}
|
||||||
|
|
||||||
|
fn replace_with_await(expr: String, fn_name: &str) -> String {
|
||||||
|
let sep = format!("{}(", fn_name);
|
||||||
|
let mut split = expr.split(&sep);
|
||||||
|
let mut s = split.next().unwrap_or_else(|| "").to_string();
|
||||||
|
for x in split {
|
||||||
|
s.push_str(&format!("(await {}({}", fn_name, add_closing_bracket(x)))
|
||||||
|
}
|
||||||
|
s
|
||||||
|
}
|
||||||
|
|
||||||
|
fn add_closing_bracket(s: &str) -> String {
|
||||||
|
let mut s = s.to_string();
|
||||||
|
let mut level = 1;
|
||||||
|
let mut idx = 0;
|
||||||
|
for c in s.chars() {
|
||||||
|
match c {
|
||||||
|
'(' => level += 1,
|
||||||
|
')' => level -= 1,
|
||||||
|
_ => (),
|
||||||
|
};
|
||||||
|
if level == 0 {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
idx += 1;
|
||||||
|
}
|
||||||
|
s.insert_str(idx, ")");
|
||||||
|
s
|
||||||
|
}
|
||||||
|
|
||||||
|
const SPLIT_PAT: &str = ";\n";
|
||||||
|
async fn eval(
|
||||||
|
context: &mut JsRuntime,
|
||||||
|
expr: &str,
|
||||||
|
env: Vec<(String, serde_json::Value)>,
|
||||||
|
workspace: &str,
|
||||||
|
token: &str,
|
||||||
|
steps: Vec<String>,
|
||||||
|
) -> anyhow::Result<serde_json::Value> {
|
||||||
|
let expr = expr.trim();
|
||||||
|
let expr = format!(
|
||||||
|
"{}\nreturn {};",
|
||||||
|
expr.split(SPLIT_PAT)
|
||||||
|
.take(expr.split(SPLIT_PAT).count() - 1)
|
||||||
|
.join("\n"),
|
||||||
|
expr.split(SPLIT_PAT).last().unwrap_or_else(|| "")
|
||||||
|
);
|
||||||
|
let steps_code = if !steps.is_empty() {
|
||||||
|
format!(
|
||||||
|
r#"
|
||||||
|
let steps = [{}];
|
||||||
|
async function step(n) {{
|
||||||
|
if (n == 0) {{
|
||||||
|
return flow_input;
|
||||||
|
}}
|
||||||
|
if (n == -1) {{
|
||||||
|
return previous_result;
|
||||||
|
}}
|
||||||
|
let token = "{token}";
|
||||||
|
if (n < 0) {{
|
||||||
|
let steps_length = steps.length;
|
||||||
|
n = n % steps.length + steps.length;
|
||||||
|
}}
|
||||||
|
let id = steps[n];
|
||||||
|
return await Deno.core.opAsync("result", [workspace, id, token, base_url]);
|
||||||
|
}}"#,
|
||||||
|
steps.into_iter().map(|x| format!("\"{x}\"")).join(",")
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
"".to_string()
|
||||||
|
};
|
||||||
|
|
||||||
|
let code = format!(
|
||||||
|
r#"
|
||||||
|
let workspace = "{workspace}";
|
||||||
|
let base_url = "{}";
|
||||||
|
async function variable(path) {{
|
||||||
|
let token = "{token}";
|
||||||
|
return await Deno.core.opAsync("variable", [workspace, path, token, base_url]);
|
||||||
|
}}
|
||||||
|
async function resource(path) {{
|
||||||
|
let token = "{token}";
|
||||||
|
return await Deno.core.opAsync("resource", [workspace, path, token, base_url]);
|
||||||
|
}}
|
||||||
|
{}
|
||||||
|
{steps_code}
|
||||||
|
(async () => {{
|
||||||
|
{expr}
|
||||||
|
}})()
|
||||||
|
"#,
|
||||||
|
std::env::var("BASE_INTERNAL_URL")
|
||||||
|
.unwrap_or_else(|_| "http://missing-base-url".to_string()),
|
||||||
|
env.into_iter()
|
||||||
|
.map(|(a, b)| format!(
|
||||||
|
"let {a} = {};\n",
|
||||||
|
serde_json::to_string(&b)
|
||||||
|
.unwrap_or_else(|_| "\"error serializing value\"".to_string())
|
||||||
|
))
|
||||||
|
.join(""),
|
||||||
|
);
|
||||||
|
let global = context.execute_script("<anon>", &code)?;
|
||||||
|
let global = context.resolve_value(global).await?;
|
||||||
|
|
||||||
|
let scope = &mut context.handle_scope();
|
||||||
|
let local = v8::Local::new(scope, global);
|
||||||
|
// Deserialize a `v8` object into a Rust type using `serde_v8`,
|
||||||
|
// in this case deserialize to a JSON `Value`.
|
||||||
|
Ok(serde_v8::from_v8::<serde_json::Value>(scope, local)?)
|
||||||
|
}
|
||||||
|
|
||||||
|
// #[warn(dead_code)]
|
||||||
|
// async fn op_test(
|
||||||
|
// _state: Rc<RefCell<OpState>>,
|
||||||
|
// path: String,
|
||||||
|
// _buf: Option<ZeroCopyBuf>,
|
||||||
|
// ) -> Result<String, anyhow::Error> {
|
||||||
|
// tokio::time::sleep(std::time::Duration::from_secs(1)).await;
|
||||||
|
// Ok(path)
|
||||||
|
// }
|
||||||
|
|
||||||
|
async fn op_variable(
|
||||||
|
_state: Rc<RefCell<OpState>>,
|
||||||
|
args: Vec<String>,
|
||||||
|
_buf: Option<ZeroCopyBuf>,
|
||||||
|
) -> Result<String, anyhow::Error> {
|
||||||
|
let workspace = &args[0];
|
||||||
|
let path = &args[1];
|
||||||
|
let token = &args[2];
|
||||||
|
let base_url = &args[3];
|
||||||
|
client::get_variable(workspace, path, token, &base_url).await
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn op_get_result(
|
||||||
|
_state: Rc<RefCell<OpState>>,
|
||||||
|
args: Vec<String>,
|
||||||
|
_buf: Option<ZeroCopyBuf>,
|
||||||
|
) -> Result<Option<serde_json::Value>, anyhow::Error> {
|
||||||
|
let workspace = &args[0];
|
||||||
|
let id = &args[1];
|
||||||
|
let token = &args[2];
|
||||||
|
let base_url = &args[3];
|
||||||
|
let client = reqwest::Client::new();
|
||||||
|
let result = client
|
||||||
|
.get(format!(
|
||||||
|
"{base_url}/api/w/{workspace}/jobs/completed/get_result/{id}"
|
||||||
|
))
|
||||||
|
.bearer_auth(token)
|
||||||
|
.send()
|
||||||
|
.await?
|
||||||
|
.json::<Option<serde_json::Value>>()
|
||||||
|
.await?;
|
||||||
|
Ok(result)
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn op_resource(
|
||||||
|
_state: Rc<RefCell<OpState>>,
|
||||||
|
args: Vec<String>,
|
||||||
|
_buf: Option<ZeroCopyBuf>,
|
||||||
|
) -> Result<Option<serde_json::Value>, anyhow::Error> {
|
||||||
|
let workspace = &args[0];
|
||||||
|
let path = &args[1];
|
||||||
|
let token = &args[2];
|
||||||
|
let base_url = &args[3];
|
||||||
|
client::get_resource(workspace, path, token, &base_url).await
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
|
||||||
|
use serde_json::json;
|
||||||
|
|
||||||
|
// Note this useful idiom: importing names from outer (for mod tests) scope.
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn test_eval() -> anyhow::Result<()> {
|
||||||
|
let env = vec![
|
||||||
|
("params".to_string(), json!({"test": 2})),
|
||||||
|
("value".to_string(), json!({"test": 2})),
|
||||||
|
];
|
||||||
|
let code = "value.test + params.test";
|
||||||
|
|
||||||
|
let mut runtime = JsRuntime::new(RuntimeOptions::default());
|
||||||
|
let res = eval(&mut runtime, code, env, "workspace", "token", vec![]).await?;
|
||||||
|
assert_eq!(res, json!(4));
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn test_eval_multiline() -> anyhow::Result<()> {
|
||||||
|
let env = vec![];
|
||||||
|
let code = "let x = 5;
|
||||||
|
`my ${x}
|
||||||
|
multiline template`";
|
||||||
|
|
||||||
|
let mut runtime = JsRuntime::new(RuntimeOptions::default());
|
||||||
|
let res = eval(&mut runtime, code, env, "workspace", "token", vec![]).await?;
|
||||||
|
assert_eq!(res, json!("my 5\nmultiline template"));
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn test_eval_timeout() -> anyhow::Result<()> {
|
||||||
|
let env = vec![
|
||||||
|
("params".to_string(), json!({"test": 2})),
|
||||||
|
("value".to_string(), json!({"test": 2})),
|
||||||
|
];
|
||||||
|
let code = r#"variable("test")"#;
|
||||||
|
|
||||||
|
let res = eval_timeout(code.to_string(), env, "workspace", "token", vec![]).await?;
|
||||||
|
assert_eq!(res, json!("test"));
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
}
|
||||||
342
backend/src/lib.rs
Normal file
342
backend/src/lib.rs
Normal file
@@ -0,0 +1,342 @@
|
|||||||
|
/*
|
||||||
|
* Author & Copyright: Ruben Fiszel 2021
|
||||||
|
* This file and its contents are licensed under the AGPL License.
|
||||||
|
* Please see the included NOTICE for copyright information and
|
||||||
|
* LICENSE-AGPL for a copy of the license.
|
||||||
|
*/
|
||||||
|
|
||||||
|
use ::oauth2::basic::BasicClient;
|
||||||
|
use argon2::Argon2;
|
||||||
|
use axum::{extract::extractor_middleware, handler::Handler, routing::get, Extension, Router};
|
||||||
|
use db::DB;
|
||||||
|
use git_version::git_version;
|
||||||
|
use hyper::Response;
|
||||||
|
use slack_http_verifier::SlackVerifier;
|
||||||
|
use std::{collections::HashMap, net::SocketAddr, sync::Arc};
|
||||||
|
use tokio::sync::Mutex;
|
||||||
|
use tower::ServiceBuilder;
|
||||||
|
use tower_cookies::CookieManagerLayer;
|
||||||
|
use tower_http::trace::{MakeSpan, OnResponse, TraceLayer};
|
||||||
|
use tracing::{field, Span};
|
||||||
|
use tracing_subscriber::{filter::filter_fn, prelude::*, EnvFilter};
|
||||||
|
extern crate magic_crypt;
|
||||||
|
|
||||||
|
extern crate dotenv;
|
||||||
|
|
||||||
|
mod audit;
|
||||||
|
mod client;
|
||||||
|
mod db;
|
||||||
|
mod email;
|
||||||
|
mod error;
|
||||||
|
mod flow;
|
||||||
|
mod granular_acls;
|
||||||
|
mod groups;
|
||||||
|
mod jobs;
|
||||||
|
mod js_eval;
|
||||||
|
mod oauth2;
|
||||||
|
mod parser;
|
||||||
|
mod resources;
|
||||||
|
mod schedule;
|
||||||
|
mod scripts;
|
||||||
|
mod static_assets;
|
||||||
|
mod users;
|
||||||
|
mod utils;
|
||||||
|
mod variables;
|
||||||
|
mod worker;
|
||||||
|
mod worker_ping;
|
||||||
|
mod workspaces;
|
||||||
|
|
||||||
|
use error::Error;
|
||||||
|
|
||||||
|
pub use crate::email::EmailSender;
|
||||||
|
use crate::{db::UserDB, utils::rd_string};
|
||||||
|
|
||||||
|
const GIT_VERSION: &str = git_version!(args = ["--tag", "--always"], fallback = "unknown-version");
|
||||||
|
pub const DEFAULT_NUM_WORKERS: usize = 3;
|
||||||
|
pub const DEFAULT_TIMEOUT: i32 = 300;
|
||||||
|
pub const DEFAULT_SLEEP_QUEUE: u64 = 50;
|
||||||
|
|
||||||
|
#[derive(Clone)]
|
||||||
|
struct MyOnResponse {}
|
||||||
|
|
||||||
|
impl<B> OnResponse<B> for MyOnResponse {
|
||||||
|
fn on_response(
|
||||||
|
self,
|
||||||
|
response: &Response<B>,
|
||||||
|
latency: std::time::Duration,
|
||||||
|
_span: &tracing::Span,
|
||||||
|
) {
|
||||||
|
tracing::info!(
|
||||||
|
latency = %latency.as_millis(),
|
||||||
|
status = ?response.status(),
|
||||||
|
"finished processed request")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone)]
|
||||||
|
struct MyMakeSpan {}
|
||||||
|
|
||||||
|
impl<B> MakeSpan<B> for MyMakeSpan {
|
||||||
|
fn make_span(&mut self, request: &hyper::Request<B>) -> Span {
|
||||||
|
tracing::info_span!(
|
||||||
|
"request",
|
||||||
|
method = %request.method(),
|
||||||
|
uri = %request.uri(),
|
||||||
|
version = ?request.version(),
|
||||||
|
username = field::Empty,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn initialize_tracing() -> anyhow::Result<()> {
|
||||||
|
//let log_level = if std::env::var("RUST_LOG").map(|x| &x == "debug")
|
||||||
|
let ts_base = tracing_subscriber::registry()
|
||||||
|
.with(
|
||||||
|
EnvFilter::from_default_env()
|
||||||
|
//.add_directive("windmill".parse()?)
|
||||||
|
.add_directive("runtime=trace".parse()?)
|
||||||
|
.add_directive("tokio=trace".parse()?),
|
||||||
|
)
|
||||||
|
.with(
|
||||||
|
tracing_subscriber::fmt::layer()
|
||||||
|
.json()
|
||||||
|
.flatten_event(true)
|
||||||
|
.with_span_list(false)
|
||||||
|
.with_current_span(true)
|
||||||
|
.with_filter(filter_fn(|meta| meta.target().starts_with("windmill"))),
|
||||||
|
);
|
||||||
|
|
||||||
|
if std::env::var("TOKIO_CONSOLE")
|
||||||
|
.map(|x| x == "true")
|
||||||
|
.unwrap_or(false)
|
||||||
|
{
|
||||||
|
let console_layer = console_subscriber::spawn();
|
||||||
|
ts_base.with(console_layer).init();
|
||||||
|
} else {
|
||||||
|
ts_base.init();
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn migrate_db(db: &DB) -> anyhow::Result<()> {
|
||||||
|
let app_password = std::env::var("APP_USER_PASSWORD").unwrap_or_else(|_| "changeme".to_owned());
|
||||||
|
|
||||||
|
db::migrate(db).await?;
|
||||||
|
db::setup_app_user(db, &app_password).await?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn connect_db() -> anyhow::Result<DB> {
|
||||||
|
let database_url = std::env::var("DATABASE_URL")
|
||||||
|
.map_err(|_| Error::BadConfig("DATABASE_URL env var is missing".to_string()))?;
|
||||||
|
Ok(db::connect(&database_url).await?)
|
||||||
|
}
|
||||||
|
|
||||||
|
type BasicClientsMap = HashMap<String, BasicClient>;
|
||||||
|
|
||||||
|
pub fn build_oauth_clients(base_url: &str) -> BasicClientsMap {
|
||||||
|
[(
|
||||||
|
"github".to_string(),
|
||||||
|
oauth2::build_gh_client(
|
||||||
|
&std::env::var("GITHUB_OAUTH_CLIENT_ID").unwrap_or_else(|_| "".to_string()),
|
||||||
|
&std::env::var("GITHUB_OAUTH_CLIENT_SECRET").unwrap_or_else(|_| "".to_string()),
|
||||||
|
base_url,
|
||||||
|
),
|
||||||
|
)]
|
||||||
|
.into()
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone)]
|
||||||
|
struct BaseUrl(String);
|
||||||
|
|
||||||
|
pub async fn run_server(
|
||||||
|
db: DB,
|
||||||
|
addr: SocketAddr,
|
||||||
|
base_url: &str,
|
||||||
|
es: EmailSender,
|
||||||
|
mut rx: tokio::sync::broadcast::Receiver<()>,
|
||||||
|
) -> anyhow::Result<()> {
|
||||||
|
let user_db = UserDB::new(db.clone());
|
||||||
|
|
||||||
|
let auth_cache = Arc::new(users::AuthCache::new(db.clone()));
|
||||||
|
let argon2 = Arc::new(Argon2::default());
|
||||||
|
let email_sender = Arc::new(es);
|
||||||
|
let basic_clients = Arc::new(build_oauth_clients(base_url));
|
||||||
|
let slack_verifier = Arc::new(
|
||||||
|
std::env::var("SLACK_SIGNING_SECRET")
|
||||||
|
.ok()
|
||||||
|
.map(|x| SlackVerifier::new(x).unwrap()),
|
||||||
|
);
|
||||||
|
|
||||||
|
let middleware_stack = ServiceBuilder::new()
|
||||||
|
.layer(
|
||||||
|
TraceLayer::new_for_http()
|
||||||
|
.on_response(MyOnResponse {})
|
||||||
|
.make_span_with(MyMakeSpan {})
|
||||||
|
.on_request(()),
|
||||||
|
)
|
||||||
|
.layer(Extension(db.clone()))
|
||||||
|
.layer(Extension(user_db))
|
||||||
|
.layer(Extension(auth_cache.clone()))
|
||||||
|
.layer(Extension(basic_clients))
|
||||||
|
.layer(Extension(BaseUrl(base_url.to_string())))
|
||||||
|
.layer(CookieManagerLayer::new());
|
||||||
|
// build our application with a route
|
||||||
|
let app = Router::new()
|
||||||
|
.nest(
|
||||||
|
"/api",
|
||||||
|
Router::new()
|
||||||
|
.nest(
|
||||||
|
"/w/:workspace_id",
|
||||||
|
Router::new()
|
||||||
|
.nest("/scripts", scripts::workspaced_service())
|
||||||
|
.nest("/jobs", jobs::workspaced_service())
|
||||||
|
.nest(
|
||||||
|
"/users",
|
||||||
|
users::workspaced_service()
|
||||||
|
.layer(Extension(argon2.clone()))
|
||||||
|
.layer(Extension(email_sender)),
|
||||||
|
)
|
||||||
|
.nest("/variables", variables::workspaced_service())
|
||||||
|
.nest("/oauth", oauth2::workspaced_service())
|
||||||
|
.nest("/resources", resources::workspaced_service())
|
||||||
|
.nest("/schedules", schedule::workspaced_service())
|
||||||
|
.nest("/groups", groups::workspaced_service())
|
||||||
|
.nest("/audit", audit::workspaced_service())
|
||||||
|
.nest("/acls", granular_acls::workspaced_service())
|
||||||
|
.nest("/workspaces", workspaces::workspaced_service())
|
||||||
|
.nest("/flows", flow::workspaced_service()),
|
||||||
|
)
|
||||||
|
.nest("/workspaces", workspaces::global_service())
|
||||||
|
.nest(
|
||||||
|
"/users",
|
||||||
|
users::global_service().layer(Extension(argon2.clone())),
|
||||||
|
)
|
||||||
|
.nest("/workers", worker_ping::global_service())
|
||||||
|
.nest("/scripts", scripts::global_service())
|
||||||
|
.nest("/schedules", schedule::global_service())
|
||||||
|
.route_layer(extractor_middleware::<users::Authed>())
|
||||||
|
.route_layer(extractor_middleware::<users::Tokened>())
|
||||||
|
.nest(
|
||||||
|
"/auth",
|
||||||
|
users::make_unauthed_service().layer(Extension(argon2)),
|
||||||
|
)
|
||||||
|
.nest(
|
||||||
|
"/oauth",
|
||||||
|
oauth2::global_service().layer(Extension(slack_verifier)),
|
||||||
|
)
|
||||||
|
.route("/version", get(git_v))
|
||||||
|
.route("/openapi.yaml", get(openapi)),
|
||||||
|
)
|
||||||
|
.fallback(static_assets::static_handler.into_service())
|
||||||
|
.layer(middleware_stack);
|
||||||
|
|
||||||
|
let instance_name = rd_string(5);
|
||||||
|
|
||||||
|
tracing::info!(addr = %addr.to_string(), instance = %instance_name, "server started listening");
|
||||||
|
let server = axum::Server::bind(&addr)
|
||||||
|
.serve(app.into_make_service())
|
||||||
|
.with_graceful_shutdown(async {
|
||||||
|
rx.recv().await.ok();
|
||||||
|
println!("Graceful shutdown of server");
|
||||||
|
});
|
||||||
|
|
||||||
|
tokio::spawn(async move { auth_cache.monitor().await });
|
||||||
|
|
||||||
|
server.await?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn monitor_db(db: &DB, timeout: i32, tx: tokio::sync::broadcast::Sender<()>) {
|
||||||
|
let db1 = db.clone();
|
||||||
|
let db2 = db.clone();
|
||||||
|
|
||||||
|
let rx1 = tx.subscribe();
|
||||||
|
let rx2 = tx.subscribe();
|
||||||
|
|
||||||
|
tokio::spawn(async move { worker::restart_zombie_jobs_periodically(&db1, timeout, rx1).await });
|
||||||
|
tokio::spawn(async move { users::delete_expired_items_perdiodically(&db2, rx2).await });
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn run_workers(
|
||||||
|
db: DB,
|
||||||
|
addr: SocketAddr,
|
||||||
|
timeout: i32,
|
||||||
|
num_workers: i32,
|
||||||
|
sleep_queue: u64,
|
||||||
|
base_url: String,
|
||||||
|
tx: tokio::sync::broadcast::Sender<()>,
|
||||||
|
) -> anyhow::Result<()> {
|
||||||
|
let instance_name = rd_string(5);
|
||||||
|
|
||||||
|
let mutex = Arc::new(Mutex::new(0));
|
||||||
|
|
||||||
|
let sources: external_ip::Sources = external_ip::get_http_sources();
|
||||||
|
let consensus = external_ip::ConsensusBuilder::new()
|
||||||
|
.add_sources(sources)
|
||||||
|
.build();
|
||||||
|
|
||||||
|
let ip = consensus
|
||||||
|
.get_consensus()
|
||||||
|
.await
|
||||||
|
.map(|x| x.to_string())
|
||||||
|
.unwrap_or_else(|| "Unretrievable ip".to_string());
|
||||||
|
|
||||||
|
let mut handles = Vec::new();
|
||||||
|
for i in 1..(num_workers + 1) {
|
||||||
|
let db1 = db.clone();
|
||||||
|
let instance_name = instance_name.clone();
|
||||||
|
let worker_name = format!("dt-worker-{}-{}", &instance_name, rd_string(5));
|
||||||
|
let m1 = mutex.clone();
|
||||||
|
let ip = ip.clone();
|
||||||
|
let tx = tx.clone();
|
||||||
|
let base_url = base_url.clone();
|
||||||
|
handles.push(tokio::spawn(async move {
|
||||||
|
tracing::info!(addr = %addr.to_string(), worker = %worker_name, "starting worker");
|
||||||
|
worker::run_worker(
|
||||||
|
&db1,
|
||||||
|
timeout,
|
||||||
|
&instance_name,
|
||||||
|
worker_name,
|
||||||
|
i as u64,
|
||||||
|
num_workers as u64,
|
||||||
|
m1,
|
||||||
|
&ip,
|
||||||
|
sleep_queue,
|
||||||
|
&base_url,
|
||||||
|
tx,
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
futures::future::try_join_all(handles).await?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn git_v() -> &'static str {
|
||||||
|
GIT_VERSION
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn openapi() -> &'static str {
|
||||||
|
include_str!("../openapi.yaml")
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn shutdown_signal(tx: tokio::sync::broadcast::Sender<()>) -> anyhow::Result<()> {
|
||||||
|
use std::io;
|
||||||
|
use tokio::signal::unix::SignalKind;
|
||||||
|
|
||||||
|
async fn terminate() -> io::Result<()> {
|
||||||
|
tokio::signal::unix::signal(SignalKind::terminate())?
|
||||||
|
.recv()
|
||||||
|
.await;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
tokio::select! {
|
||||||
|
_ = terminate() => {},
|
||||||
|
_ = tokio::signal::ctrl_c() => {},
|
||||||
|
}
|
||||||
|
println!("signal received, starting graceful shutdown");
|
||||||
|
let _ = tx.send(());
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
95
backend/src/main.rs
Normal file
95
backend/src/main.rs
Normal file
@@ -0,0 +1,95 @@
|
|||||||
|
/*
|
||||||
|
* Author & Copyright: Ruben Fiszel 2021
|
||||||
|
* This file and its contents are licensed under the AGPL License.
|
||||||
|
* Please see the included NOTICE for copyright information and
|
||||||
|
* LICENSE-AGPL for a copy of the license.
|
||||||
|
*/
|
||||||
|
|
||||||
|
use std::net::SocketAddr;
|
||||||
|
|
||||||
|
use dotenv::dotenv;
|
||||||
|
|
||||||
|
#[tokio::main]
|
||||||
|
async fn main() -> anyhow::Result<()> {
|
||||||
|
dotenv().ok();
|
||||||
|
|
||||||
|
windmill::initialize_tracing().await?;
|
||||||
|
|
||||||
|
let db = windmill::connect_db().await?;
|
||||||
|
|
||||||
|
let num_workers = std::env::var("NUM_WORKERS")
|
||||||
|
.ok()
|
||||||
|
.and_then(|x| x.parse::<i32>().ok())
|
||||||
|
.unwrap_or(windmill::DEFAULT_NUM_WORKERS as i32);
|
||||||
|
|
||||||
|
let (server_mode, monitor_mode, migrate_db) = (true, true, true);
|
||||||
|
|
||||||
|
if migrate_db {
|
||||||
|
windmill::migrate_db(&db).await?;
|
||||||
|
}
|
||||||
|
|
||||||
|
let (tx, rx) = tokio::sync::broadcast::channel::<()>(3);
|
||||||
|
let shutdown_signal = windmill::shutdown_signal(tx.clone());
|
||||||
|
|
||||||
|
if server_mode || monitor_mode || num_workers > 0 {
|
||||||
|
let addr = SocketAddr::from(([0, 0, 0, 0], 8000));
|
||||||
|
|
||||||
|
let timeout = std::env::var("TIMEOUT")
|
||||||
|
.ok()
|
||||||
|
.and_then(|x| x.parse::<i32>().ok())
|
||||||
|
.unwrap_or(windmill::DEFAULT_TIMEOUT);
|
||||||
|
|
||||||
|
let server_f = async {
|
||||||
|
if server_mode {
|
||||||
|
windmill::run_server(
|
||||||
|
db.clone(),
|
||||||
|
addr,
|
||||||
|
&std::env::var("BASE_URL").unwrap_or("http://localhost".to_string()),
|
||||||
|
windmill::EmailSender {
|
||||||
|
from: "bot@windmill.dev".to_string(),
|
||||||
|
server: "smtp.gmail.com".to_string(),
|
||||||
|
password: std::env::var("SMTP_PASSWORD").unwrap_or("NOPASS".to_string()),
|
||||||
|
},
|
||||||
|
rx,
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
}
|
||||||
|
Ok(()) as anyhow::Result<()>
|
||||||
|
};
|
||||||
|
|
||||||
|
let base_url = std::env::var("BASE_INTERNAL_URL")
|
||||||
|
.unwrap_or_else(|_| "http://missing-base-url".to_string());
|
||||||
|
|
||||||
|
let workers_f = async {
|
||||||
|
if num_workers > 0 {
|
||||||
|
let sleep_queue = std::env::var("SLEEP_QUEUE")
|
||||||
|
.ok()
|
||||||
|
.and_then(|x| x.parse::<u64>().ok())
|
||||||
|
.unwrap_or(windmill::DEFAULT_SLEEP_QUEUE);
|
||||||
|
|
||||||
|
windmill::run_workers(
|
||||||
|
db.clone(),
|
||||||
|
addr,
|
||||||
|
timeout,
|
||||||
|
num_workers,
|
||||||
|
sleep_queue,
|
||||||
|
base_url,
|
||||||
|
tx.clone(),
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
}
|
||||||
|
Ok(()) as anyhow::Result<()>
|
||||||
|
};
|
||||||
|
|
||||||
|
let monitor_f = async {
|
||||||
|
if monitor_mode {
|
||||||
|
windmill::monitor_db(&db, timeout, tx.clone());
|
||||||
|
}
|
||||||
|
Ok(()) as anyhow::Result<()>
|
||||||
|
};
|
||||||
|
|
||||||
|
futures::try_join!(shutdown_signal, server_f, workers_f, monitor_f)?;
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
712
backend/src/oauth2.rs
Normal file
712
backend/src/oauth2.rs
Normal file
@@ -0,0 +1,712 @@
|
|||||||
|
use std::fmt::Debug;
|
||||||
|
|
||||||
|
use std::sync::Arc;
|
||||||
|
use std::time::Duration;
|
||||||
|
|
||||||
|
use axum::body::Bytes;
|
||||||
|
use axum::extract::{Extension, FromRequest, Path, Query, RequestParts};
|
||||||
|
use axum::response::Redirect;
|
||||||
|
use axum::routing::{get, post};
|
||||||
|
use axum::{async_trait, Router};
|
||||||
|
use futures::TryFutureExt;
|
||||||
|
use hyper::StatusCode;
|
||||||
|
use oauth2::basic::{
|
||||||
|
BasicClient, BasicErrorResponse, BasicRevocationErrorResponse, BasicTokenIntrospectionResponse,
|
||||||
|
BasicTokenType,
|
||||||
|
};
|
||||||
|
use oauth2::reqwest::async_http_client;
|
||||||
|
use oauth2::{helpers, TokenType};
|
||||||
|
use oauth2::{AccessToken, Client as OClient, RefreshToken, StandardRevocableToken};
|
||||||
|
// Alternatively, this can be `oauth2::curl::http_client` or a custom client.
|
||||||
|
use oauth2::{
|
||||||
|
AuthUrl, AuthorizationCode, ClientId, ClientSecret, CsrfToken, RedirectUrl, Scope,
|
||||||
|
TokenResponse, TokenUrl,
|
||||||
|
};
|
||||||
|
use reqwest::Client;
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
use slack_http_verifier::SlackVerifier;
|
||||||
|
use tower_cookies::{Cookie, Cookies};
|
||||||
|
|
||||||
|
use crate::audit::{audit_log, ActionKind};
|
||||||
|
use crate::db::{UserDB, DB};
|
||||||
|
use crate::error::{self, to_anyhow, Error, Result};
|
||||||
|
use crate::jobs::{get_latest_hash_for_path, JobPayload};
|
||||||
|
use crate::users::{Authed, LoginType};
|
||||||
|
use crate::variables::build_crypt;
|
||||||
|
use crate::workspaces::WorkspaceSettings;
|
||||||
|
use crate::{jobs, BasicClientsMap};
|
||||||
|
use crate::{variables, BaseUrl};
|
||||||
|
|
||||||
|
pub fn global_service() -> Router {
|
||||||
|
Router::new()
|
||||||
|
.route("/login/:client", get(login))
|
||||||
|
.route("/login_callback/:client", get(login_callback))
|
||||||
|
.route(
|
||||||
|
"/slack_command",
|
||||||
|
post(slack_command).route_layer(axum::extract::extractor_middleware::<SlackSig>()),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn workspaced_service() -> Router {
|
||||||
|
Router::new()
|
||||||
|
.route("/connect/:client", get(connect))
|
||||||
|
.route("/disconnect/:client", post(disconnect))
|
||||||
|
.route("/connect_callback/:client", get(connect_callback))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn build_gh_client(client_id: &str, client_secret: &str, base_uri: &str) -> BasicClient {
|
||||||
|
let auth_url = AuthUrl::new("https://github.com/login/oauth/authorize".to_string())
|
||||||
|
.expect("Invalid authorization endpoint URL");
|
||||||
|
let token_url = TokenUrl::new("https://github.com/login/oauth/access_token".to_string())
|
||||||
|
.expect("Invalid token endpoint URL");
|
||||||
|
|
||||||
|
// Set up the config for the Github OAuth2 process.
|
||||||
|
BasicClient::new(
|
||||||
|
ClientId::new(client_id.to_string()),
|
||||||
|
Some(ClientSecret::new(client_secret.to_string())),
|
||||||
|
auth_url,
|
||||||
|
Some(token_url),
|
||||||
|
)
|
||||||
|
.set_redirect_uri(
|
||||||
|
RedirectUrl::new(format!("{base_uri}/api/oauth/login_callback/github")).unwrap(),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn build_connect_client(w_id: &str, client_name: &str, base_uri: &str) -> Result<BasicClient> {
|
||||||
|
let (auth_str, token_str) = match client_name {
|
||||||
|
"gmail" => ("", ""),
|
||||||
|
"slack" => (
|
||||||
|
"https://slack.com/oauth/authorize",
|
||||||
|
"https://slack.com/api/oauth.access",
|
||||||
|
),
|
||||||
|
_ => Err(Error::BadRequest(format!("unrecognized client!")))?,
|
||||||
|
};
|
||||||
|
|
||||||
|
let auth_url = AuthUrl::new(auth_str.to_string()).expect("Invalid authorization endpoint URL");
|
||||||
|
let token_url = TokenUrl::new(token_str.to_string()).expect("Invalid token endpoint URL");
|
||||||
|
|
||||||
|
// Set up the config for the Github OAuth2 process.
|
||||||
|
Ok(BasicClient::new(
|
||||||
|
ClientId::new(
|
||||||
|
std::env::var(&format!("{}_OAUTH_CLIENT_ID", client_name.to_uppercase()))
|
||||||
|
.ok()
|
||||||
|
.ok_or(Error::BadRequest(format!(
|
||||||
|
"client id for {} not in env",
|
||||||
|
client_name
|
||||||
|
)))?,
|
||||||
|
),
|
||||||
|
Some(ClientSecret::new(
|
||||||
|
std::env::var(&format!(
|
||||||
|
"{}_OAUTH_CLIENT_SECRET",
|
||||||
|
client_name.to_uppercase()
|
||||||
|
))
|
||||||
|
.ok()
|
||||||
|
.ok_or(Error::BadRequest(format!(
|
||||||
|
"client secret for {} not in env",
|
||||||
|
client_name
|
||||||
|
)))?,
|
||||||
|
)),
|
||||||
|
auth_url,
|
||||||
|
Some(token_url),
|
||||||
|
)
|
||||||
|
.set_redirect_uri(
|
||||||
|
RedirectUrl::new(format!(
|
||||||
|
"{base_uri}/api/w/{w_id}/oauth/connect_callback/{client_name}"
|
||||||
|
))
|
||||||
|
.unwrap(),
|
||||||
|
))
|
||||||
|
}
|
||||||
|
|
||||||
|
type SlackClient = OClient<
|
||||||
|
BasicErrorResponse,
|
||||||
|
SlackTokenResponse,
|
||||||
|
BasicTokenType,
|
||||||
|
BasicTokenIntrospectionResponse,
|
||||||
|
StandardRevocableToken,
|
||||||
|
BasicRevocationErrorResponse,
|
||||||
|
>;
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, Deserialize, Serialize)]
|
||||||
|
pub struct SlackTokenResponse {
|
||||||
|
access_token: AccessToken,
|
||||||
|
|
||||||
|
team_id: String,
|
||||||
|
|
||||||
|
team_name: String,
|
||||||
|
|
||||||
|
#[serde(rename = "scope")]
|
||||||
|
#[serde(deserialize_with = "helpers::deserialize_space_delimited_vec")]
|
||||||
|
#[serde(serialize_with = "helpers::serialize_space_delimited_vec")]
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
#[serde(default)]
|
||||||
|
scopes: Option<Vec<Scope>>,
|
||||||
|
bot: SlackBotToken,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, Deserialize, Serialize)]
|
||||||
|
pub struct SlackBotToken {
|
||||||
|
bot_access_token: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl TokenResponse<BasicTokenType> for SlackTokenResponse
|
||||||
|
where
|
||||||
|
BasicTokenType: TokenType,
|
||||||
|
{
|
||||||
|
///
|
||||||
|
/// REQUIRED. The access token issued by the authorization server.
|
||||||
|
///
|
||||||
|
fn access_token(&self) -> &AccessToken {
|
||||||
|
&self.access_token
|
||||||
|
}
|
||||||
|
///
|
||||||
|
/// REQUIRED. The type of the token issued as described in
|
||||||
|
/// [Section 7.1](https://tools.ietf.org/html/rfc6749#section-7.1).
|
||||||
|
/// Value is case insensitive and deserialized to the generic `TokenType` parameter.
|
||||||
|
/// But in this particular case as the service is non compliant, it has a default value
|
||||||
|
///
|
||||||
|
fn token_type(&self) -> &BasicTokenType {
|
||||||
|
&BasicTokenType::Bearer
|
||||||
|
}
|
||||||
|
///
|
||||||
|
/// RECOMMENDED. The lifetime in seconds of the access token. For example, the value 3600
|
||||||
|
/// denotes that the access token will expire in one hour from the time the response was
|
||||||
|
/// generated. If omitted, the authorization server SHOULD provide the expiration time via
|
||||||
|
/// other means or document the default value.
|
||||||
|
///
|
||||||
|
fn expires_in(&self) -> Option<Duration> {
|
||||||
|
None
|
||||||
|
}
|
||||||
|
///
|
||||||
|
/// OPTIONAL. The refresh token, which can be used to obtain new access tokens using the same
|
||||||
|
/// authorization grant as described in
|
||||||
|
/// [Section 6](https://tools.ietf.org/html/rfc6749#section-6).
|
||||||
|
///
|
||||||
|
fn refresh_token(&self) -> Option<&RefreshToken> {
|
||||||
|
None
|
||||||
|
}
|
||||||
|
///
|
||||||
|
/// OPTIONAL, if identical to the scope requested by the client; otherwise, REQUIRED. The
|
||||||
|
/// scipe of the access token as described by
|
||||||
|
/// [Section 3.3](https://tools.ietf.org/html/rfc6749#section-3.3). If included in the response,
|
||||||
|
/// this space-delimited field is parsed into a `Vec` of individual scopes. If omitted from
|
||||||
|
/// the response, this field is `None`.
|
||||||
|
///
|
||||||
|
fn scopes(&self) -> Option<&Vec<Scope>> {
|
||||||
|
self.scopes.as_ref()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn build_slack_client(w_id: &str, client_name: &str, base_uri: &str) -> Result<SlackClient> {
|
||||||
|
let (auth_str, token_str) = (
|
||||||
|
"https://slack.com/oauth/authorize",
|
||||||
|
"https://slack.com/api/oauth.access",
|
||||||
|
);
|
||||||
|
|
||||||
|
let auth_url = AuthUrl::new(auth_str.to_string()).expect("Invalid authorization endpoint URL");
|
||||||
|
let token_url = TokenUrl::new(token_str.to_string()).expect("Invalid token endpoint URL");
|
||||||
|
|
||||||
|
// Set up the config for the Github OAuth2 process.
|
||||||
|
Ok(SlackClient::new(
|
||||||
|
ClientId::new(
|
||||||
|
std::env::var(&format!("{}_OAUTH_CLIENT_ID", client_name.to_uppercase()))
|
||||||
|
.ok()
|
||||||
|
.ok_or(Error::BadRequest(format!(
|
||||||
|
"client id for {} not in env",
|
||||||
|
client_name
|
||||||
|
)))?,
|
||||||
|
),
|
||||||
|
Some(ClientSecret::new(
|
||||||
|
std::env::var(&format!(
|
||||||
|
"{}_OAUTH_CLIENT_SECRET",
|
||||||
|
client_name.to_uppercase()
|
||||||
|
))
|
||||||
|
.ok()
|
||||||
|
.ok_or(Error::BadRequest(format!(
|
||||||
|
"client secret for {} not in env",
|
||||||
|
client_name
|
||||||
|
)))?,
|
||||||
|
)),
|
||||||
|
auth_url,
|
||||||
|
Some(token_url),
|
||||||
|
)
|
||||||
|
.set_redirect_uri(
|
||||||
|
RedirectUrl::new(format!(
|
||||||
|
"{base_uri}/api/w/{w_id}/oauth/connect_callback/{client_name}"
|
||||||
|
))
|
||||||
|
.unwrap(),
|
||||||
|
))
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn connect(
|
||||||
|
Path((w_id, client_name)): Path<(String, String)>,
|
||||||
|
Extension(base_url): Extension<BaseUrl>,
|
||||||
|
cookies: Cookies,
|
||||||
|
) -> error::Result<Redirect> {
|
||||||
|
let client = build_connect_client(&w_id, &client_name, &base_url.0)?;
|
||||||
|
|
||||||
|
let (authorize_url, csrf_state) = client
|
||||||
|
.authorize_url(CsrfToken::new_random)
|
||||||
|
.add_scope(Scope::new("bot".to_string()))
|
||||||
|
.add_scope(Scope::new("commands".to_string()))
|
||||||
|
.url();
|
||||||
|
|
||||||
|
let csrf = csrf_state.secret().to_string();
|
||||||
|
let mut cookie = Cookie::new("csrf", csrf);
|
||||||
|
cookie.set_path("/");
|
||||||
|
cookies.add(cookie);
|
||||||
|
Ok(Redirect::to(authorize_url.as_str()))
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn disconnect(
|
||||||
|
authed: Authed,
|
||||||
|
Path((w_id, client_name)): Path<(String, String)>,
|
||||||
|
Extension(user_db): Extension<UserDB>,
|
||||||
|
) -> error::Result<String> {
|
||||||
|
let mut tx = user_db.begin(&authed).await?;
|
||||||
|
|
||||||
|
match client_name.as_str() {
|
||||||
|
"slack" => {
|
||||||
|
sqlx::query!(
|
||||||
|
"UPDATE workspace_settings
|
||||||
|
SET slack_team_id = null, slack_name = null WHERE workspace_id = $1",
|
||||||
|
&w_id
|
||||||
|
)
|
||||||
|
.execute(&mut tx)
|
||||||
|
.await?;
|
||||||
|
}
|
||||||
|
_ => Err(error::Error::BadRequest(format!(
|
||||||
|
"Not recognized client name {client_name}"
|
||||||
|
)))?,
|
||||||
|
}
|
||||||
|
tx.commit().await?;
|
||||||
|
Ok(format!("{client_name} disconnected"))
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn login(
|
||||||
|
Extension(clients): Extension<Arc<BasicClientsMap>>,
|
||||||
|
Path(client_name): Path<String>,
|
||||||
|
cookies: Cookies,
|
||||||
|
) -> error::Result<Redirect> {
|
||||||
|
let client = clients
|
||||||
|
.get(&client_name)
|
||||||
|
.ok_or(Error::BadRequest(format!("client {} invalid", client_name)))?;
|
||||||
|
let (authorize_url, csrf_state) = client
|
||||||
|
.authorize_url(CsrfToken::new_random)
|
||||||
|
.add_scope(Scope::new("user:email".to_string()))
|
||||||
|
// .add_scope(Scope::new("read:user".to_string()))
|
||||||
|
.url();
|
||||||
|
|
||||||
|
let csrf = csrf_state.secret().to_string();
|
||||||
|
let mut cookie = Cookie::new("csrf", csrf);
|
||||||
|
cookie.set_path("/");
|
||||||
|
cookies.add(cookie);
|
||||||
|
Ok(Redirect::to(authorize_url.as_str()))
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
pub struct CallbackQuery {
|
||||||
|
code: Option<String>,
|
||||||
|
state: Option<String>,
|
||||||
|
error: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn connect_callback(
|
||||||
|
authed: Authed,
|
||||||
|
Path((w_id, client_name)): Path<(String, String)>,
|
||||||
|
Query(query): Query<CallbackQuery>,
|
||||||
|
cookies: Cookies,
|
||||||
|
Extension(user_db): Extension<UserDB>,
|
||||||
|
Extension(base_url): Extension<BaseUrl>,
|
||||||
|
) -> error::Result<Redirect> {
|
||||||
|
if let Some(error) = query.error {
|
||||||
|
return Ok(Redirect::to(&format!(
|
||||||
|
"/connection_added?error={}",
|
||||||
|
urlencoding::encode(&error).into_owned()
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
|
||||||
|
let code = AuthorizationCode::new(query.code.unwrap());
|
||||||
|
let state = CsrfToken::new(query.state.unwrap());
|
||||||
|
|
||||||
|
let csrf_state = cookies
|
||||||
|
.get("csrf")
|
||||||
|
.map(|x| x.value().to_string())
|
||||||
|
.unwrap_or("".to_string());
|
||||||
|
|
||||||
|
if state.secret().to_string() != csrf_state {
|
||||||
|
return Err(error::Error::BadRequest("csrf did not match".to_string()));
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut tx = user_db.begin(&authed).await?;
|
||||||
|
|
||||||
|
let mc = build_crypt(&mut tx, &w_id).await?;
|
||||||
|
|
||||||
|
let token_res = match client_name.as_str() {
|
||||||
|
"slack" => {
|
||||||
|
let t = build_slack_client(&w_id, &client_name, &base_url.0)?
|
||||||
|
.exchange_code(code)
|
||||||
|
.request_async(async_http_client)
|
||||||
|
.await;
|
||||||
|
if let Ok(token) = t {
|
||||||
|
sqlx::query!(
|
||||||
|
"INSERT INTO workspace_settings
|
||||||
|
(workspace_id, slack_team_id, slack_name)
|
||||||
|
VALUES ($1, $2, $3) ON CONFLICT (workspace_id) DO UPDATE SET slack_team_id = $2, slack_name = $3",
|
||||||
|
&w_id,
|
||||||
|
token.team_id,
|
||||||
|
token.team_name
|
||||||
|
)
|
||||||
|
.execute(&mut tx)
|
||||||
|
.await?;
|
||||||
|
sqlx::query!(
|
||||||
|
"INSERT INTO group_
|
||||||
|
(workspace_id, name, summary)
|
||||||
|
VALUES ($1, $2, $3) ON CONFLICT DO NOTHING",
|
||||||
|
&w_id,
|
||||||
|
"slack",
|
||||||
|
"The group that runs the script triggered by the slack /windmill command.
|
||||||
|
Share scripts to this group to make them executable from slack and add
|
||||||
|
members to this group to let them manage the slack related owner space."
|
||||||
|
)
|
||||||
|
.execute(&mut tx)
|
||||||
|
.await?;
|
||||||
|
Ok(token.bot.bot_access_token.to_owned())
|
||||||
|
} else {
|
||||||
|
Err(t.unwrap_err())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
_ => {
|
||||||
|
build_connect_client(&w_id, &client_name, &base_url.0)?
|
||||||
|
.exchange_code(code)
|
||||||
|
.request_async(async_http_client)
|
||||||
|
.map_ok(|t| t.access_token().secret().to_owned())
|
||||||
|
.await
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if let Ok(token) = token_res {
|
||||||
|
tracing::info!("{token}");
|
||||||
|
let variable_path = &format!("g/all/{}_token", &client_name);
|
||||||
|
sqlx::query!(
|
||||||
|
"INSERT INTO variable
|
||||||
|
(workspace_id, path, value, is_secret, description)
|
||||||
|
VALUES ($1, $2, $3, true, $4) ON CONFLICT (workspace_id, path) DO UPDATE SET value = $3",
|
||||||
|
&w_id,
|
||||||
|
variable_path,
|
||||||
|
variables::encrypt(&mc, token.to_string()),
|
||||||
|
format!("OAuth2 token for {client_name}"),
|
||||||
|
)
|
||||||
|
.execute(&mut tx)
|
||||||
|
.await?;
|
||||||
|
sqlx::query!(
|
||||||
|
"INSERT INTO resource
|
||||||
|
(workspace_id, path, value, description, resource_type)
|
||||||
|
VALUES ($1, $2, $3, $4, $5) ON CONFLICT (workspace_id, path) DO UPDATE SET value = $3",
|
||||||
|
&w_id,
|
||||||
|
variable_path,
|
||||||
|
serde_json::json!({ "token": format!("$var:{variable_path}") }),
|
||||||
|
format!("OAuth2 token for {client_name}"),
|
||||||
|
&client_name
|
||||||
|
)
|
||||||
|
.execute(&mut tx)
|
||||||
|
.await?;
|
||||||
|
audit_log(
|
||||||
|
&mut tx,
|
||||||
|
&authed.username,
|
||||||
|
"oauth2.connect",
|
||||||
|
ActionKind::Create,
|
||||||
|
&w_id,
|
||||||
|
Some(&client_name),
|
||||||
|
None,
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
tx.commit().await?;
|
||||||
|
Ok(Redirect::to(
|
||||||
|
format!("/connection_added?client_name={}", &client_name).as_str(),
|
||||||
|
))
|
||||||
|
} else {
|
||||||
|
let error = token_res.unwrap_err().to_string();
|
||||||
|
Ok(Redirect::to(&format!(
|
||||||
|
"/connection_added?error={}",
|
||||||
|
urlencoding::encode(&format!("error fetching token: {error}")).into_owned()
|
||||||
|
)))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Deserialize, Debug)]
|
||||||
|
pub struct SlackCommand {
|
||||||
|
team_id: String,
|
||||||
|
user_name: String,
|
||||||
|
text: String,
|
||||||
|
response_url: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Debug)]
|
||||||
|
pub struct SlackSig {
|
||||||
|
sig: String,
|
||||||
|
ts: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[async_trait]
|
||||||
|
impl<B> FromRequest<B> for SlackSig
|
||||||
|
where
|
||||||
|
B: Send,
|
||||||
|
{
|
||||||
|
type Rejection = (StatusCode, String);
|
||||||
|
|
||||||
|
async fn from_request(req: &mut RequestParts<B>) -> std::result::Result<Self, Self::Rejection> {
|
||||||
|
let hm = req.headers();
|
||||||
|
Ok(Self {
|
||||||
|
sig: hm
|
||||||
|
.get("X-Slack-Signature")
|
||||||
|
.map(|x| x.to_str().unwrap_or(""))
|
||||||
|
.unwrap_or("")
|
||||||
|
.to_string(),
|
||||||
|
ts: hm
|
||||||
|
.get("X-Slack-Request-Timestamp")
|
||||||
|
.map(|x| x.to_str().unwrap_or(""))
|
||||||
|
.unwrap_or("")
|
||||||
|
.to_string(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn slack_command(
|
||||||
|
SlackSig { sig, ts }: SlackSig,
|
||||||
|
Extension(slack_verifier): Extension<Arc<Option<SlackVerifier>>>,
|
||||||
|
Extension(db): Extension<DB>,
|
||||||
|
Extension(base_url): Extension<BaseUrl>,
|
||||||
|
body: Bytes,
|
||||||
|
) -> error::Result<String> {
|
||||||
|
let form: SlackCommand = serde_urlencoded::from_bytes(&body)
|
||||||
|
.map_err(|_| error::Error::BadRequest("invalid payload".to_string()))?;
|
||||||
|
|
||||||
|
let body = String::from_utf8_lossy(&body);
|
||||||
|
if slack_verifier
|
||||||
|
.as_ref()
|
||||||
|
.as_ref()
|
||||||
|
.map(|sv| sv.verify(&ts, &body, &sig).ok())
|
||||||
|
.flatten()
|
||||||
|
.is_none()
|
||||||
|
{
|
||||||
|
return Err(error::Error::BadRequest("verification failed".to_owned()));
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut tx = db.begin().await?;
|
||||||
|
let settings = sqlx::query_as!(
|
||||||
|
WorkspaceSettings,
|
||||||
|
"SELECT * FROM workspace_settings WHERE slack_team_id = $1",
|
||||||
|
form.team_id,
|
||||||
|
)
|
||||||
|
.fetch_optional(&mut tx)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
if let Some(settings) = settings {
|
||||||
|
if let Some(script) = &settings.slack_command_script {
|
||||||
|
let script_hash =
|
||||||
|
get_latest_hash_for_path(&mut tx, &settings.workspace_id, script).await?;
|
||||||
|
let mut map = serde_json::Map::new();
|
||||||
|
map.insert("text".to_string(), serde_json::Value::String(form.text));
|
||||||
|
map.insert(
|
||||||
|
"response_url".to_string(),
|
||||||
|
serde_json::Value::String(form.response_url),
|
||||||
|
);
|
||||||
|
|
||||||
|
let (uuid, tx) = jobs::push(
|
||||||
|
tx,
|
||||||
|
&settings.workspace_id,
|
||||||
|
JobPayload::ScriptHash {
|
||||||
|
hash: script_hash,
|
||||||
|
path: script.to_owned(),
|
||||||
|
},
|
||||||
|
Some(map),
|
||||||
|
&form.user_name,
|
||||||
|
"g/slack".to_string(),
|
||||||
|
None,
|
||||||
|
None,
|
||||||
|
None,
|
||||||
|
false,
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
tx.commit().await?;
|
||||||
|
let url = base_url.0;
|
||||||
|
return Ok(format!("Job launched. See details at {url}/run/{uuid}"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return Ok(format!(
|
||||||
|
"workspace not properly configured (did you set the script to trigger in the settings?)"
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
pub struct UserInfo {
|
||||||
|
name: Option<String>,
|
||||||
|
company: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn login_callback(
|
||||||
|
Path(client_name): Path<String>,
|
||||||
|
Query(query): Query<CallbackQuery>,
|
||||||
|
cookies: Cookies,
|
||||||
|
Extension(clients): Extension<Arc<BasicClientsMap>>,
|
||||||
|
Extension(db): Extension<DB>,
|
||||||
|
) -> error::Result<Redirect> {
|
||||||
|
if let Some(error) = query.error {
|
||||||
|
return Ok(Redirect::to(&format!(
|
||||||
|
"/user/login?error={}",
|
||||||
|
urlencoding::encode(&error).into_owned()
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
|
||||||
|
let code = AuthorizationCode::new(query.code.unwrap());
|
||||||
|
let state = CsrfToken::new(query.state.unwrap());
|
||||||
|
|
||||||
|
let csrf_state = cookies
|
||||||
|
.get("csrf")
|
||||||
|
.map(|x| x.value().to_string())
|
||||||
|
.unwrap_or("".to_string());
|
||||||
|
|
||||||
|
if state.secret().to_string() != csrf_state {
|
||||||
|
return Err(error::Error::BadRequest("csrf did not match".to_string()));
|
||||||
|
}
|
||||||
|
|
||||||
|
let client = clients.get(&client_name).unwrap();
|
||||||
|
|
||||||
|
// Exchange the code with a token.
|
||||||
|
let token_res = client
|
||||||
|
.exchange_code(code)
|
||||||
|
.request_async(async_http_client)
|
||||||
|
.await;
|
||||||
|
|
||||||
|
if let Ok(token) = token_res {
|
||||||
|
let token = token.access_token().secret();
|
||||||
|
let http_client = reqwest::ClientBuilder::new()
|
||||||
|
.user_agent("windmill/beta")
|
||||||
|
.build()
|
||||||
|
.map_err(to_anyhow)?;
|
||||||
|
|
||||||
|
let email = get_email(&http_client, &client_name, token).await?;
|
||||||
|
|
||||||
|
let mut tx = db.begin().await?;
|
||||||
|
|
||||||
|
let login: Option<(String, LoginType, bool)> =
|
||||||
|
sqlx::query_as("SELECT email, login_type, super_admin FROM password WHERE email = $1")
|
||||||
|
.bind(&email)
|
||||||
|
.fetch_optional(&mut tx)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
if let Some((email, login_type, super_admin)) = login {
|
||||||
|
let login_type = serde_json::json!(login_type);
|
||||||
|
if login_type == client_name {
|
||||||
|
crate::users::create_session_token(&email, super_admin, &mut tx, cookies).await?;
|
||||||
|
} else {
|
||||||
|
return Err(error::Error::BadRequest(format!(
|
||||||
|
"an user with the email associated to this login exists but with a different login type {login_type}")
|
||||||
|
));
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
let user = get_user_info(&http_client, &client_name, &token).await?;
|
||||||
|
|
||||||
|
sqlx::query(
|
||||||
|
&format!("INSERT INTO password (email, name, company, login_type, verified) VALUES ($1, $2, $3, '{}', true)", &client_name)
|
||||||
|
)
|
||||||
|
.bind(&email)
|
||||||
|
.bind(&user.name)
|
||||||
|
.bind(user.company)
|
||||||
|
.execute(&mut tx)
|
||||||
|
.await?;
|
||||||
|
crate::users::create_session_token(&email, false, &mut tx, cookies).await?;
|
||||||
|
audit_log(
|
||||||
|
&mut tx,
|
||||||
|
&email,
|
||||||
|
"oauth.signup",
|
||||||
|
ActionKind::Create,
|
||||||
|
"global",
|
||||||
|
Some("github"),
|
||||||
|
None,
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
let demo_exists =
|
||||||
|
sqlx::query_scalar!("SELECT EXISTS(SELECT 1 FROM workspace WHERE id = 'demo')")
|
||||||
|
.fetch_one(&mut tx)
|
||||||
|
.await?
|
||||||
|
.unwrap_or(false);
|
||||||
|
if demo_exists {
|
||||||
|
sqlx::query!(
|
||||||
|
"INSERT INTO workspace_invite
|
||||||
|
(workspace_id, email, is_admin)
|
||||||
|
VALUES ('demo', $1, false)",
|
||||||
|
&email
|
||||||
|
)
|
||||||
|
.execute(&mut tx)
|
||||||
|
.await?;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
tx.commit().await?;
|
||||||
|
Ok(Redirect::to("/user/workspaces"))
|
||||||
|
} else {
|
||||||
|
Ok(Redirect::to(&format!(
|
||||||
|
"/user/login?error={}",
|
||||||
|
urlencoding::encode("invalid token").into_owned()
|
||||||
|
)))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
pub struct GHEmailInfo {
|
||||||
|
email: String,
|
||||||
|
verified: bool,
|
||||||
|
primary: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn get_email(http_client: &Client, client_name: &str, token: &str) -> error::Result<String> {
|
||||||
|
let email = match client_name {
|
||||||
|
"github" => http_client
|
||||||
|
.get("https://api.github.com/user/emails")
|
||||||
|
.bearer_auth(token)
|
||||||
|
.send()
|
||||||
|
.await
|
||||||
|
.map_err(to_anyhow)?
|
||||||
|
.json::<Vec<GHEmailInfo>>()
|
||||||
|
.await
|
||||||
|
.map_err(to_anyhow)?
|
||||||
|
.iter()
|
||||||
|
.find(|x| x.primary && x.verified)
|
||||||
|
.ok_or(error::Error::BadRequest(format!(
|
||||||
|
"user does not have any primary and verified address"
|
||||||
|
)))?
|
||||||
|
.email
|
||||||
|
.to_string(),
|
||||||
|
_ => {
|
||||||
|
return Err(error::Error::BadRequest(
|
||||||
|
"client name not recognized".to_string(),
|
||||||
|
))
|
||||||
|
}
|
||||||
|
};
|
||||||
|
Ok(email)
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn get_user_info(
|
||||||
|
http_client: &Client,
|
||||||
|
client_name: &str,
|
||||||
|
token: &str,
|
||||||
|
) -> error::Result<UserInfo> {
|
||||||
|
let email = match client_name {
|
||||||
|
"github" => http_client
|
||||||
|
.get("https://api.github.com/user")
|
||||||
|
.bearer_auth(token)
|
||||||
|
.send()
|
||||||
|
.await
|
||||||
|
.map_err(to_anyhow)?
|
||||||
|
.json::<UserInfo>()
|
||||||
|
.await
|
||||||
|
.map_err(to_anyhow)?,
|
||||||
|
_ => {
|
||||||
|
return Err(error::Error::BadRequest(
|
||||||
|
"client name not recognized".to_string(),
|
||||||
|
))
|
||||||
|
}
|
||||||
|
};
|
||||||
|
Ok(email)
|
||||||
|
}
|
||||||
592
backend/src/parser.rs
Normal file
592
backend/src/parser.rs
Normal file
@@ -0,0 +1,592 @@
|
|||||||
|
/*
|
||||||
|
* Author & Copyright: Ruben Fiszel 2021
|
||||||
|
* This file and its contents are licensed under the AGPL License.
|
||||||
|
* Please see the included NOTICE for copyright information and
|
||||||
|
* LICENSE-AGPL for a copy of the license.
|
||||||
|
*/
|
||||||
|
|
||||||
|
use std::collections::HashMap;
|
||||||
|
|
||||||
|
use itertools::Itertools;
|
||||||
|
use regex::Regex;
|
||||||
|
use serde::Serialize;
|
||||||
|
use serde_json::json;
|
||||||
|
|
||||||
|
use crate::error;
|
||||||
|
|
||||||
|
use rustpython_parser::{
|
||||||
|
ast::{ExpressionType, Located, Number, StatementType, StringGroup, Varargs},
|
||||||
|
parser,
|
||||||
|
};
|
||||||
|
#[derive(Serialize)]
|
||||||
|
pub struct MainArgSignature {
|
||||||
|
pub star_args: bool,
|
||||||
|
pub star_kwargs: bool,
|
||||||
|
pub args: Vec<Arg>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Serialize)]
|
||||||
|
#[serde(rename_all(serialize = "lowercase"))]
|
||||||
|
pub enum Typ {
|
||||||
|
Str,
|
||||||
|
Int,
|
||||||
|
Float,
|
||||||
|
Bool,
|
||||||
|
Dict,
|
||||||
|
List,
|
||||||
|
Bytes,
|
||||||
|
Datetime,
|
||||||
|
Unknown,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Serialize)]
|
||||||
|
pub struct Arg {
|
||||||
|
pub name: String,
|
||||||
|
pub typ: Typ,
|
||||||
|
pub default: Option<serde_json::Value>,
|
||||||
|
pub has_default: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn parse_signature(code: &str) -> error::Result<MainArgSignature> {
|
||||||
|
let ast = parser::parse_program(code)
|
||||||
|
.map_err(|e| error::Error::ExecutionErr(format!("Error parsing code: {}", e.to_string())))?
|
||||||
|
.statements;
|
||||||
|
let param = ast.into_iter().find_map(|x| match x {
|
||||||
|
Located {
|
||||||
|
location: _,
|
||||||
|
node:
|
||||||
|
StatementType::FunctionDef {
|
||||||
|
is_async: _,
|
||||||
|
name,
|
||||||
|
args,
|
||||||
|
body: _,
|
||||||
|
decorator_list: _,
|
||||||
|
returns: _,
|
||||||
|
},
|
||||||
|
} if &name == "main" => Some(*args),
|
||||||
|
_ => None,
|
||||||
|
});
|
||||||
|
if let Some(params) = param {
|
||||||
|
//println!("{:?}", params);
|
||||||
|
let def_arg_start = params.args.len() - params.defaults.len();
|
||||||
|
Ok(MainArgSignature {
|
||||||
|
star_args: params.vararg != Varargs::None,
|
||||||
|
star_kwargs: params.vararg != Varargs::None,
|
||||||
|
args: params
|
||||||
|
.args
|
||||||
|
.into_iter()
|
||||||
|
.enumerate()
|
||||||
|
.map(|(i, x)| {
|
||||||
|
let default = if i >= def_arg_start {
|
||||||
|
to_value(¶ms.defaults[i - def_arg_start].node)
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
};
|
||||||
|
Arg {
|
||||||
|
name: x.arg,
|
||||||
|
typ: x.annotation.map_or(Typ::Unknown, |e| match *e {
|
||||||
|
Located {
|
||||||
|
location: _,
|
||||||
|
node: ExpressionType::Identifier { name },
|
||||||
|
} => match name.as_ref() {
|
||||||
|
"str" => Typ::Str,
|
||||||
|
"float" => Typ::Float,
|
||||||
|
"int" => Typ::Int,
|
||||||
|
"bool" => Typ::Bool,
|
||||||
|
"dict" => Typ::Dict,
|
||||||
|
"list" => Typ::List,
|
||||||
|
"bytes" => Typ::Bytes,
|
||||||
|
"datetime" => Typ::Datetime,
|
||||||
|
"datetime.datetime" => Typ::Datetime,
|
||||||
|
_ => Typ::Unknown,
|
||||||
|
},
|
||||||
|
_ => Typ::Unknown,
|
||||||
|
}),
|
||||||
|
has_default: default.is_some(),
|
||||||
|
default,
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.collect(),
|
||||||
|
})
|
||||||
|
} else {
|
||||||
|
Err(error::Error::ExecutionErr(
|
||||||
|
"main function was not findable".to_string(),
|
||||||
|
))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const STDIMPORTS: [&str; 301] = [
|
||||||
|
"__future__",
|
||||||
|
"_abc",
|
||||||
|
"_aix_support",
|
||||||
|
"_ast",
|
||||||
|
"_asyncio",
|
||||||
|
"_bisect",
|
||||||
|
"_blake2",
|
||||||
|
"_bootsubprocess",
|
||||||
|
"_bz2",
|
||||||
|
"_codecs",
|
||||||
|
"_codecs_cn",
|
||||||
|
"_codecs_hk",
|
||||||
|
"_codecs_iso2022",
|
||||||
|
"_codecs_jp",
|
||||||
|
"_codecs_kr",
|
||||||
|
"_codecs_tw",
|
||||||
|
"_collections",
|
||||||
|
"_collections_abc",
|
||||||
|
"_compat_pickle",
|
||||||
|
"_compression",
|
||||||
|
"_contextvars",
|
||||||
|
"_crypt",
|
||||||
|
"_csv",
|
||||||
|
"_ctypes",
|
||||||
|
"_curses",
|
||||||
|
"_curses_panel",
|
||||||
|
"_datetime",
|
||||||
|
"_dbm",
|
||||||
|
"_decimal",
|
||||||
|
"_elementtree",
|
||||||
|
"_frozen_importlib",
|
||||||
|
"_frozen_importlib_external",
|
||||||
|
"_functools",
|
||||||
|
"_gdbm",
|
||||||
|
"_hashlib",
|
||||||
|
"_heapq",
|
||||||
|
"_imp",
|
||||||
|
"_io",
|
||||||
|
"_json",
|
||||||
|
"_locale",
|
||||||
|
"_lsprof",
|
||||||
|
"_lzma",
|
||||||
|
"_markupbase",
|
||||||
|
"_md5",
|
||||||
|
"_msi",
|
||||||
|
"_multibytecodec",
|
||||||
|
"_multiprocessing",
|
||||||
|
"_opcode",
|
||||||
|
"_operator",
|
||||||
|
"_osx_support",
|
||||||
|
"_overlapped",
|
||||||
|
"_pickle",
|
||||||
|
"_posixshmem",
|
||||||
|
"_posixsubprocess",
|
||||||
|
"_py_abc",
|
||||||
|
"_pydecimal",
|
||||||
|
"_pyio",
|
||||||
|
"_queue",
|
||||||
|
"_random",
|
||||||
|
"_sha1",
|
||||||
|
"_sha256",
|
||||||
|
"_sha3",
|
||||||
|
"_sha512",
|
||||||
|
"_signal",
|
||||||
|
"_sitebuiltins",
|
||||||
|
"_socket",
|
||||||
|
"_sqlite3",
|
||||||
|
"_sre",
|
||||||
|
"_ssl",
|
||||||
|
"_stat",
|
||||||
|
"_statistics",
|
||||||
|
"_string",
|
||||||
|
"_strptime",
|
||||||
|
"_struct",
|
||||||
|
"_symtable",
|
||||||
|
"_thread",
|
||||||
|
"_threading_local",
|
||||||
|
"_tkinter",
|
||||||
|
"_tracemalloc",
|
||||||
|
"_uuid",
|
||||||
|
"_warnings",
|
||||||
|
"_weakref",
|
||||||
|
"_weakrefset",
|
||||||
|
"_winapi",
|
||||||
|
"_zoneinfo",
|
||||||
|
"abc",
|
||||||
|
"aifc",
|
||||||
|
"antigravity",
|
||||||
|
"argparse",
|
||||||
|
"array",
|
||||||
|
"ast",
|
||||||
|
"asynchat",
|
||||||
|
"asyncio",
|
||||||
|
"asyncore",
|
||||||
|
"atexit",
|
||||||
|
"audioop",
|
||||||
|
"base64",
|
||||||
|
"bdb",
|
||||||
|
"binascii",
|
||||||
|
"binhex",
|
||||||
|
"bisect",
|
||||||
|
"builtins",
|
||||||
|
"bz2",
|
||||||
|
"cProfile",
|
||||||
|
"calendar",
|
||||||
|
"cgi",
|
||||||
|
"cgitb",
|
||||||
|
"chunk",
|
||||||
|
"cmath",
|
||||||
|
"cmd",
|
||||||
|
"code",
|
||||||
|
"codecs",
|
||||||
|
"codeop",
|
||||||
|
"collections",
|
||||||
|
"colorsys",
|
||||||
|
"compileall",
|
||||||
|
"concurrent",
|
||||||
|
"configparser",
|
||||||
|
"contextlib",
|
||||||
|
"contextvars",
|
||||||
|
"copy",
|
||||||
|
"copyreg",
|
||||||
|
"crypt",
|
||||||
|
"csv",
|
||||||
|
"ctypes",
|
||||||
|
"curses",
|
||||||
|
"dataclasses",
|
||||||
|
"datetime",
|
||||||
|
"dbm",
|
||||||
|
"decimal",
|
||||||
|
"difflib",
|
||||||
|
"dis",
|
||||||
|
"distutils",
|
||||||
|
"doctest",
|
||||||
|
"email",
|
||||||
|
"encodings",
|
||||||
|
"ensurepip",
|
||||||
|
"enum",
|
||||||
|
"errno",
|
||||||
|
"faulthandler",
|
||||||
|
"fcntl",
|
||||||
|
"filecmp",
|
||||||
|
"fileinput",
|
||||||
|
"fnmatch",
|
||||||
|
"fractions",
|
||||||
|
"ftplib",
|
||||||
|
"functools",
|
||||||
|
"gc",
|
||||||
|
"genericpath",
|
||||||
|
"getopt",
|
||||||
|
"getpass",
|
||||||
|
"gettext",
|
||||||
|
"glob",
|
||||||
|
"graphlib",
|
||||||
|
"grp",
|
||||||
|
"gzip",
|
||||||
|
"hashlib",
|
||||||
|
"heapq",
|
||||||
|
"hmac",
|
||||||
|
"html",
|
||||||
|
"http",
|
||||||
|
"idlelib",
|
||||||
|
"imaplib",
|
||||||
|
"imghdr",
|
||||||
|
"imp",
|
||||||
|
"importlib",
|
||||||
|
"inspect",
|
||||||
|
"io",
|
||||||
|
"ipaddress",
|
||||||
|
"itertools",
|
||||||
|
"json",
|
||||||
|
"keyword",
|
||||||
|
"lib2to3",
|
||||||
|
"linecache",
|
||||||
|
"locale",
|
||||||
|
"logging",
|
||||||
|
"lzma",
|
||||||
|
"mailbox",
|
||||||
|
"mailcap",
|
||||||
|
"marshal",
|
||||||
|
"math",
|
||||||
|
"mimetypes",
|
||||||
|
"mmap",
|
||||||
|
"modulefinder",
|
||||||
|
"msilib",
|
||||||
|
"msvcrt",
|
||||||
|
"multiprocessing",
|
||||||
|
"netrc",
|
||||||
|
"nis",
|
||||||
|
"nntplib",
|
||||||
|
"nt",
|
||||||
|
"ntpath",
|
||||||
|
"nturl2path",
|
||||||
|
"numbers",
|
||||||
|
"opcode",
|
||||||
|
"operator",
|
||||||
|
"optparse",
|
||||||
|
"os",
|
||||||
|
"ossaudiodev",
|
||||||
|
"pathlib",
|
||||||
|
"pdb",
|
||||||
|
"pickle",
|
||||||
|
"pickletools",
|
||||||
|
"pipes",
|
||||||
|
"pkgutil",
|
||||||
|
"platform",
|
||||||
|
"plistlib",
|
||||||
|
"poplib",
|
||||||
|
"posix",
|
||||||
|
"posixpath",
|
||||||
|
"pprint",
|
||||||
|
"profile",
|
||||||
|
"pstats",
|
||||||
|
"pty",
|
||||||
|
"pwd",
|
||||||
|
"py_compile",
|
||||||
|
"pyclbr",
|
||||||
|
"pydoc",
|
||||||
|
"pydoc_data",
|
||||||
|
"pyexpat",
|
||||||
|
"queue",
|
||||||
|
"quopri",
|
||||||
|
"random",
|
||||||
|
"re",
|
||||||
|
"readline",
|
||||||
|
"reprlib",
|
||||||
|
"resource",
|
||||||
|
"rlcompleter",
|
||||||
|
"runpy",
|
||||||
|
"sched",
|
||||||
|
"secrets",
|
||||||
|
"select",
|
||||||
|
"selectors",
|
||||||
|
"shelve",
|
||||||
|
"shlex",
|
||||||
|
"shutil",
|
||||||
|
"signal",
|
||||||
|
"site",
|
||||||
|
"smtpd",
|
||||||
|
"smtplib",
|
||||||
|
"sndhdr",
|
||||||
|
"socket",
|
||||||
|
"socketserver",
|
||||||
|
"spwd",
|
||||||
|
"sqlite3",
|
||||||
|
"sre_compile",
|
||||||
|
"sre_constants",
|
||||||
|
"sre_parse",
|
||||||
|
"ssl",
|
||||||
|
"stat",
|
||||||
|
"statistics",
|
||||||
|
"string",
|
||||||
|
"stringprep",
|
||||||
|
"struct",
|
||||||
|
"subprocess",
|
||||||
|
"sunau",
|
||||||
|
"symtable",
|
||||||
|
"sys",
|
||||||
|
"sysconfig",
|
||||||
|
"syslog",
|
||||||
|
"tabnanny",
|
||||||
|
"tarfile",
|
||||||
|
"telnetlib",
|
||||||
|
"tempfile",
|
||||||
|
"termios",
|
||||||
|
"textwrap",
|
||||||
|
"this",
|
||||||
|
"threading",
|
||||||
|
"time",
|
||||||
|
"timeit",
|
||||||
|
"tkinter",
|
||||||
|
"token",
|
||||||
|
"tokenize",
|
||||||
|
"trace",
|
||||||
|
"traceback",
|
||||||
|
"tracemalloc",
|
||||||
|
"tty",
|
||||||
|
"turtle",
|
||||||
|
"turtledemo",
|
||||||
|
"types",
|
||||||
|
"typing",
|
||||||
|
"unicodedata",
|
||||||
|
"unittest",
|
||||||
|
"urllib",
|
||||||
|
"uu",
|
||||||
|
"uuid",
|
||||||
|
"venv",
|
||||||
|
"warnings",
|
||||||
|
"wave",
|
||||||
|
"weakref",
|
||||||
|
"webbrowser",
|
||||||
|
"winreg",
|
||||||
|
"winsound",
|
||||||
|
"wsgiref",
|
||||||
|
"xdrlib",
|
||||||
|
"xml",
|
||||||
|
"xmlrpc",
|
||||||
|
"zipapp",
|
||||||
|
"zipfile",
|
||||||
|
"zipimport",
|
||||||
|
"",
|
||||||
|
];
|
||||||
|
|
||||||
|
fn to_value(et: &ExpressionType) -> Option<serde_json::Value> {
|
||||||
|
match et {
|
||||||
|
ExpressionType::String {
|
||||||
|
value: StringGroup::Constant { value },
|
||||||
|
} => Some(json!(value)),
|
||||||
|
ExpressionType::Number { value } => match value {
|
||||||
|
Number::Integer { value } => Some(json!(value.to_string().parse::<i64>().unwrap())),
|
||||||
|
Number::Float { value } => Some(json!(value)),
|
||||||
|
_ => None,
|
||||||
|
},
|
||||||
|
ExpressionType::True => Some(json!(true)),
|
||||||
|
ExpressionType::False => Some(json!(false)),
|
||||||
|
|
||||||
|
ExpressionType::Dict { elements } => {
|
||||||
|
let v = elements
|
||||||
|
.into_iter()
|
||||||
|
.map(|(k, v)| {
|
||||||
|
let key = k
|
||||||
|
.as_ref()
|
||||||
|
.and_then(|x| to_value(&x.node))
|
||||||
|
.and_then(|x| match x {
|
||||||
|
serde_json::Value::String(s) => Some(s),
|
||||||
|
_ => None,
|
||||||
|
})
|
||||||
|
.unwrap_or_else(|| "no_key".to_string());
|
||||||
|
(key, to_value(&v.node))
|
||||||
|
})
|
||||||
|
.collect::<HashMap<String, _>>();
|
||||||
|
Some(json!(v))
|
||||||
|
}
|
||||||
|
ExpressionType::List { elements } => {
|
||||||
|
let v = elements
|
||||||
|
.into_iter()
|
||||||
|
.map(|x| to_value(&x.node))
|
||||||
|
.collect::<Vec<_>>();
|
||||||
|
Some(json!(v))
|
||||||
|
}
|
||||||
|
ExpressionType::None => Some(json!(null)),
|
||||||
|
|
||||||
|
ExpressionType::Call {
|
||||||
|
function: _,
|
||||||
|
args: _,
|
||||||
|
keywords: _,
|
||||||
|
} => Some(json!("<function call>")),
|
||||||
|
|
||||||
|
_ => None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn parse_imports(code: &str) -> error::Result<Vec<String>> {
|
||||||
|
let find_requirements = code
|
||||||
|
.lines()
|
||||||
|
.find_position(|x| x.starts_with("#requirements:"));
|
||||||
|
let re = Regex::new(r"^\#(\S+)$").unwrap();
|
||||||
|
if let Some((pos, _)) = find_requirements {
|
||||||
|
let lines = code
|
||||||
|
.lines()
|
||||||
|
.skip(pos + 1)
|
||||||
|
.map_while(|x| {
|
||||||
|
re.captures(x)
|
||||||
|
.map(|x| x.get(1).unwrap().as_str().to_string())
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
Ok(lines)
|
||||||
|
} else {
|
||||||
|
let ast = parser::parse_program(code)
|
||||||
|
.map_err(|e| {
|
||||||
|
error::Error::ExecutionErr(format!("Error parsing code: {}", e.to_string()))
|
||||||
|
})?
|
||||||
|
.statements;
|
||||||
|
let imports = ast
|
||||||
|
.into_iter()
|
||||||
|
.filter_map(|x| match x {
|
||||||
|
Located { location: _, node } => match node {
|
||||||
|
StatementType::Import { names } => Some(
|
||||||
|
names
|
||||||
|
.into_iter()
|
||||||
|
.map(|x| x.symbol.split('.').next().unwrap_or("").to_string())
|
||||||
|
.collect::<Vec<String>>(),
|
||||||
|
),
|
||||||
|
StatementType::ImportFrom {
|
||||||
|
level: _,
|
||||||
|
module: Some(mod_),
|
||||||
|
names: _,
|
||||||
|
} => Some(vec![mod_
|
||||||
|
.split('.')
|
||||||
|
.next()
|
||||||
|
.unwrap_or("")
|
||||||
|
.to_string()
|
||||||
|
.replace("_", "-")]),
|
||||||
|
_ => None,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
.flatten()
|
||||||
|
.filter(|x| !STDIMPORTS.contains(&x.as_str()))
|
||||||
|
.unique()
|
||||||
|
.collect();
|
||||||
|
Ok(imports)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
|
||||||
|
// Note this useful idiom: importing names from outer (for mod tests) scope.
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_parse_sig() -> anyhow::Result<()> {
|
||||||
|
//let code = "print(2 + 3, fd=sys.stderr)";
|
||||||
|
let code = "
|
||||||
|
|
||||||
|
import os
|
||||||
|
|
||||||
|
def main(test1: str, name: datetime.datetime = datetime.now(), byte: bytes = bytes(1)):
|
||||||
|
|
||||||
|
print(f\"Hello World and a warm welcome especially to {name}\")
|
||||||
|
print(\"The env variable at `all/pretty_secret`: \", os.environ.get(\"ALL_PRETTY_SECRET\"))
|
||||||
|
return {\"len\": len(name), \"splitted\": name.split() }
|
||||||
|
|
||||||
|
";
|
||||||
|
println!("{}", serde_json::to_string(&parse_signature(code)?)?);
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_parse_imports() -> anyhow::Result<()> {
|
||||||
|
//let code = "print(2 + 3, fd=sys.stderr)";
|
||||||
|
let code = "
|
||||||
|
|
||||||
|
import os
|
||||||
|
import wmill
|
||||||
|
from zanzibar.estonie import talin
|
||||||
|
import matplotlib.pyplot as plt
|
||||||
|
|
||||||
|
def main():
|
||||||
|
pass
|
||||||
|
|
||||||
|
";
|
||||||
|
let r = parse_imports(code)?;
|
||||||
|
println!("{}", serde_json::to_string(&r)?);
|
||||||
|
assert_eq!(r, vec!["wmill", "zanzibar", "matplotlib"]);
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_parse_imports2() -> anyhow::Result<()> {
|
||||||
|
//let code = "print(2 + 3, fd=sys.stderr)";
|
||||||
|
let code = "
|
||||||
|
#requirements:
|
||||||
|
#burkina=0.4
|
||||||
|
#nigeria
|
||||||
|
#
|
||||||
|
#congo
|
||||||
|
|
||||||
|
import os
|
||||||
|
import wmill
|
||||||
|
from zanzibar.estonie import talin
|
||||||
|
|
||||||
|
def main():
|
||||||
|
pass
|
||||||
|
|
||||||
|
";
|
||||||
|
let r = parse_imports(code)?;
|
||||||
|
println!("{}", serde_json::to_string(&r)?);
|
||||||
|
assert_eq!(r, vec!["burkina=0.4", "nigeria"]);
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
}
|
||||||
426
backend/src/resources.rs
Normal file
426
backend/src/resources.rs
Normal file
@@ -0,0 +1,426 @@
|
|||||||
|
/*
|
||||||
|
* Author & Copyright: Ruben Fiszel 2021
|
||||||
|
* This file and its contents are licensed under the AGPL License.
|
||||||
|
* Please see the included NOTICE for copyright information and
|
||||||
|
* LICENSE-AGPL for a copy of the license.
|
||||||
|
*/
|
||||||
|
|
||||||
|
use crate::{
|
||||||
|
audit::{audit_log, ActionKind},
|
||||||
|
db::{UserDB, DB},
|
||||||
|
error::{Error, JsonResult, Result},
|
||||||
|
users::Authed,
|
||||||
|
utils::{require_admin, Pagination, StripPath},
|
||||||
|
};
|
||||||
|
use axum::{
|
||||||
|
extract::{Extension, Path, Query},
|
||||||
|
routing::{delete, get, post},
|
||||||
|
Json, Router,
|
||||||
|
};
|
||||||
|
use hyper::StatusCode;
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
use sql_builder::{bind::Bind, SqlBuilder};
|
||||||
|
use sqlx::FromRow;
|
||||||
|
|
||||||
|
pub fn workspaced_service() -> Router {
|
||||||
|
Router::new()
|
||||||
|
.route("/list", get(list_resources))
|
||||||
|
.route("/get/*path", get(get_resource))
|
||||||
|
.route("/get_value/*path", get(get_resource_value))
|
||||||
|
.route("/update/*path", post(update_resource))
|
||||||
|
.route("/delete/*path", delete(delete_resource))
|
||||||
|
.route("/create", post(create_resource))
|
||||||
|
.route("/type/list", get(list_resource_types))
|
||||||
|
.route("/type/listnames", get(list_resource_types_names))
|
||||||
|
.route("/type/get/:name", get(get_resource_type))
|
||||||
|
.route("/type/update/:name", post(update_resource_type))
|
||||||
|
.route("/type/delete/:name", delete(delete_resource_type))
|
||||||
|
.route("/type/create", post(create_resource_type))
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(FromRow, Serialize, Deserialize)]
|
||||||
|
pub struct ResourceType {
|
||||||
|
pub workspace_id: String,
|
||||||
|
pub name: String,
|
||||||
|
pub schema: Option<serde_json::Value>,
|
||||||
|
pub description: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
pub struct CreateResourceType {
|
||||||
|
pub name: String,
|
||||||
|
pub schema: Option<serde_json::Value>,
|
||||||
|
pub description: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
pub struct EditResourceType {
|
||||||
|
pub schema: Option<serde_json::Value>,
|
||||||
|
pub description: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(FromRow, Serialize, Deserialize)]
|
||||||
|
pub struct Resource {
|
||||||
|
pub workspace_id: String,
|
||||||
|
pub path: String,
|
||||||
|
pub value: Option<serde_json::Value>,
|
||||||
|
pub description: Option<String>,
|
||||||
|
pub resource_type: String,
|
||||||
|
pub extra_perms: serde_json::Value,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
pub struct CreateResource {
|
||||||
|
pub path: String,
|
||||||
|
pub value: Option<serde_json::Value>,
|
||||||
|
pub description: Option<String>,
|
||||||
|
pub resource_type: String,
|
||||||
|
}
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
struct EditResource {
|
||||||
|
path: Option<String>,
|
||||||
|
description: Option<String>,
|
||||||
|
value: Option<serde_json::Value>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
pub struct ListResourceQuery {
|
||||||
|
resource_type: Option<String>,
|
||||||
|
}
|
||||||
|
async fn list_resources(
|
||||||
|
authed: Authed,
|
||||||
|
Query(lq): Query<ListResourceQuery>,
|
||||||
|
Query(pagination): Query<Pagination>,
|
||||||
|
Extension(user_db): Extension<UserDB>,
|
||||||
|
Path(w_id): Path<String>,
|
||||||
|
) -> JsonResult<Vec<Resource>> {
|
||||||
|
let (per_page, offset) = crate::utils::paginate(pagination);
|
||||||
|
|
||||||
|
let mut sqlb = SqlBuilder::select_from("resource")
|
||||||
|
.fields(&[
|
||||||
|
"workspace_id",
|
||||||
|
"path",
|
||||||
|
"null::JSONB as value",
|
||||||
|
"description",
|
||||||
|
"resource_type",
|
||||||
|
"extra_perms",
|
||||||
|
])
|
||||||
|
.order_by("path", true)
|
||||||
|
.and_where("workspace_id = ? OR workspace_id = 'starter'".bind(&w_id))
|
||||||
|
.offset(offset)
|
||||||
|
.limit(per_page)
|
||||||
|
.clone();
|
||||||
|
if let Some(rt) = &lq.resource_type {
|
||||||
|
sqlb.and_where_eq("resource_type", "?".bind(rt));
|
||||||
|
}
|
||||||
|
|
||||||
|
let sql = sqlb.sql().map_err(|e| Error::InternalErr(e.to_string()))?;
|
||||||
|
let mut tx = user_db.begin(&authed).await?;
|
||||||
|
let rows = sqlx::query_as::<_, Resource>(&sql)
|
||||||
|
.fetch_all(&mut tx)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
tx.commit().await?;
|
||||||
|
|
||||||
|
Ok(Json(rows))
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn get_resource(
|
||||||
|
authed: Authed,
|
||||||
|
Extension(user_db): Extension<UserDB>,
|
||||||
|
Path((w_id, path)): Path<(String, StripPath)>,
|
||||||
|
) -> JsonResult<Resource> {
|
||||||
|
let path = path.to_path();
|
||||||
|
let mut tx = user_db.begin(&authed).await?;
|
||||||
|
|
||||||
|
let resource_o = sqlx::query_as!(
|
||||||
|
Resource,
|
||||||
|
"SELECT * from resource WHERE path = $1 AND (workspace_id = $2 OR workspace_id = 'starter')",
|
||||||
|
path.to_owned(),
|
||||||
|
&w_id
|
||||||
|
)
|
||||||
|
.fetch_optional(&mut tx)
|
||||||
|
.await?;
|
||||||
|
tx.commit().await?;
|
||||||
|
|
||||||
|
let resource = crate::utils::not_found_if_none(resource_o, "Resource", path)?;
|
||||||
|
Ok(Json(resource))
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn get_resource_value(
|
||||||
|
authed: Authed,
|
||||||
|
Extension(user_db): Extension<UserDB>,
|
||||||
|
Path((w_id, path)): Path<(String, StripPath)>,
|
||||||
|
) -> JsonResult<Option<serde_json::Value>> {
|
||||||
|
let path = path.to_path();
|
||||||
|
let mut tx = user_db.begin(&authed).await?;
|
||||||
|
|
||||||
|
let value_o = sqlx::query_scalar!(
|
||||||
|
"SELECT value from resource WHERE path = $1 AND (workspace_id = $2 OR workspace_id = 'starter')",
|
||||||
|
path.to_owned(),
|
||||||
|
&w_id
|
||||||
|
)
|
||||||
|
.fetch_optional(&mut tx)
|
||||||
|
.await?;
|
||||||
|
tx.commit().await?;
|
||||||
|
|
||||||
|
let value = crate::utils::not_found_if_none(value_o, "Resource", path)?;
|
||||||
|
Ok(Json(value))
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn create_resource(
|
||||||
|
authed: Authed,
|
||||||
|
Extension(user_db): Extension<UserDB>,
|
||||||
|
Path(w_id): Path<String>,
|
||||||
|
Json(resource): Json<CreateResource>,
|
||||||
|
) -> Result<(StatusCode, String)> {
|
||||||
|
let mut tx = user_db.begin(&authed).await?;
|
||||||
|
|
||||||
|
sqlx::query!(
|
||||||
|
"INSERT INTO resource
|
||||||
|
(workspace_id, path, value, description, resource_type)
|
||||||
|
VALUES ($1, $2, $3, $4, $5)",
|
||||||
|
w_id,
|
||||||
|
resource.path,
|
||||||
|
resource.value,
|
||||||
|
resource.description,
|
||||||
|
resource.resource_type,
|
||||||
|
)
|
||||||
|
.execute(&mut tx)
|
||||||
|
.await?;
|
||||||
|
audit_log(
|
||||||
|
&mut tx,
|
||||||
|
&authed.username,
|
||||||
|
"resources.create",
|
||||||
|
ActionKind::Create,
|
||||||
|
&w_id,
|
||||||
|
Some(&resource.path),
|
||||||
|
None,
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
tx.commit().await?;
|
||||||
|
|
||||||
|
Ok((
|
||||||
|
StatusCode::CREATED,
|
||||||
|
format!("resource {} created", resource.path),
|
||||||
|
))
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn delete_resource(
|
||||||
|
authed: Authed,
|
||||||
|
Extension(user_db): Extension<UserDB>,
|
||||||
|
Path((w_id, path)): Path<(String, StripPath)>,
|
||||||
|
) -> Result<String> {
|
||||||
|
let path = path.to_path();
|
||||||
|
let mut tx = user_db.begin(&authed).await?;
|
||||||
|
|
||||||
|
sqlx::query!(
|
||||||
|
"DELETE FROM resource WHERE path = $1 AND workspace_id = $2",
|
||||||
|
path,
|
||||||
|
w_id
|
||||||
|
)
|
||||||
|
.execute(&mut tx)
|
||||||
|
.await?;
|
||||||
|
audit_log(
|
||||||
|
&mut tx,
|
||||||
|
&authed.username,
|
||||||
|
"resources.delete",
|
||||||
|
ActionKind::Delete,
|
||||||
|
&w_id,
|
||||||
|
Some(path),
|
||||||
|
None,
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
tx.commit().await?;
|
||||||
|
|
||||||
|
Ok(format!("resource {} deleted", path))
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn update_resource(
|
||||||
|
authed: Authed,
|
||||||
|
Extension(user_db): Extension<UserDB>,
|
||||||
|
Path((w_id, path)): Path<(String, StripPath)>,
|
||||||
|
Json(ns): Json<EditResource>,
|
||||||
|
) -> Result<String> {
|
||||||
|
use sql_builder::prelude::*;
|
||||||
|
|
||||||
|
let path = path.to_path();
|
||||||
|
|
||||||
|
let mut sqlb = SqlBuilder::update_table("resource");
|
||||||
|
sqlb.and_where_eq("path", "?".bind(&path));
|
||||||
|
sqlb.and_where_eq("workspace_id", "?".bind(&w_id));
|
||||||
|
|
||||||
|
if let Some(npath) = &ns.path {
|
||||||
|
sqlb.set_str("path", npath);
|
||||||
|
}
|
||||||
|
if let Some(nvalue) = ns.value {
|
||||||
|
sqlb.set_str("value", nvalue.to_string());
|
||||||
|
}
|
||||||
|
if let Some(ndesc) = ns.description {
|
||||||
|
sqlb.set_str("description", ndesc);
|
||||||
|
}
|
||||||
|
let mut tx = user_db.begin(&authed).await?;
|
||||||
|
|
||||||
|
let sql = sqlb.sql().map_err(|e| Error::InternalErr(e.to_string()))?;
|
||||||
|
sqlx::query(&sql).execute(&mut tx).await?;
|
||||||
|
audit_log(
|
||||||
|
&mut tx,
|
||||||
|
&authed.username,
|
||||||
|
"resources.update",
|
||||||
|
ActionKind::Update,
|
||||||
|
&w_id,
|
||||||
|
Some(path),
|
||||||
|
None,
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
tx.commit().await?;
|
||||||
|
|
||||||
|
Ok(format!("resource {} updated (npath: {:?})", path, ns.path))
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn list_resource_types(
|
||||||
|
Extension(db): Extension<DB>,
|
||||||
|
Path(w_id): Path<String>,
|
||||||
|
) -> JsonResult<Vec<ResourceType>> {
|
||||||
|
let rows = sqlx::query_as!(ResourceType, "SELECT * from resource_type WHERE (workspace_id = $1 OR workspace_id = 'starter') ORDER BY name", &w_id)
|
||||||
|
.fetch_all(&db)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
Ok(Json(rows))
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn list_resource_types_names(
|
||||||
|
Extension(db): Extension<DB>,
|
||||||
|
Path(w_id): Path<String>,
|
||||||
|
) -> JsonResult<Vec<String>> {
|
||||||
|
let rows = sqlx::query_scalar!("SELECT name from resource_type WHERE (workspace_id = $1 OR workspace_id = 'starter') ORDER BY name", &w_id)
|
||||||
|
.fetch_all(&db)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
Ok(Json(rows))
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn get_resource_type(
|
||||||
|
authed: Authed,
|
||||||
|
Extension(user_db): Extension<UserDB>,
|
||||||
|
Path((w_id, name)): Path<(String, String)>,
|
||||||
|
) -> JsonResult<ResourceType> {
|
||||||
|
let mut tx = user_db.begin(&authed).await?;
|
||||||
|
|
||||||
|
let resource_type_o = sqlx::query_as!(
|
||||||
|
ResourceType,
|
||||||
|
"SELECT * from resource_type WHERE name = $1 AND (workspace_id = $2 OR workspace_id = 'starter')",
|
||||||
|
&name,
|
||||||
|
&w_id
|
||||||
|
)
|
||||||
|
.fetch_optional(&mut tx)
|
||||||
|
.await?;
|
||||||
|
tx.commit().await?;
|
||||||
|
|
||||||
|
let resource_type = crate::utils::not_found_if_none(resource_type_o, "ResourceType", name)?;
|
||||||
|
Ok(Json(resource_type))
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn create_resource_type(
|
||||||
|
authed: Authed,
|
||||||
|
Extension(user_db): Extension<UserDB>,
|
||||||
|
Path(w_id): Path<String>,
|
||||||
|
Json(resource_type): Json<CreateResourceType>,
|
||||||
|
) -> Result<(StatusCode, String)> {
|
||||||
|
let mut tx = user_db.begin(&authed).await?;
|
||||||
|
|
||||||
|
sqlx::query!(
|
||||||
|
"INSERT INTO resource_type
|
||||||
|
(workspace_id, name, schema, description)
|
||||||
|
VALUES ($1, $2, $3, $4)",
|
||||||
|
w_id,
|
||||||
|
resource_type.name,
|
||||||
|
resource_type.schema,
|
||||||
|
resource_type.description,
|
||||||
|
)
|
||||||
|
.execute(&mut tx)
|
||||||
|
.await?;
|
||||||
|
audit_log(
|
||||||
|
&mut tx,
|
||||||
|
&authed.username,
|
||||||
|
"resource_types.create",
|
||||||
|
ActionKind::Create,
|
||||||
|
&w_id,
|
||||||
|
Some(&resource_type.name),
|
||||||
|
None,
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
tx.commit().await?;
|
||||||
|
|
||||||
|
Ok((
|
||||||
|
StatusCode::CREATED,
|
||||||
|
format!("resource_type {} created", resource_type.name),
|
||||||
|
))
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn delete_resource_type(
|
||||||
|
authed: Authed,
|
||||||
|
Extension(user_db): Extension<UserDB>,
|
||||||
|
Path((w_id, name)): Path<(String, String)>,
|
||||||
|
) -> Result<String> {
|
||||||
|
require_admin(authed.is_admin, &authed.username)?;
|
||||||
|
|
||||||
|
let mut tx = user_db.begin(&authed).await?;
|
||||||
|
|
||||||
|
sqlx::query!(
|
||||||
|
"DELETE FROM resource_type WHERE name = $1 AND workspace_id = $2",
|
||||||
|
name,
|
||||||
|
w_id
|
||||||
|
)
|
||||||
|
.execute(&mut tx)
|
||||||
|
.await?;
|
||||||
|
audit_log(
|
||||||
|
&mut tx,
|
||||||
|
&authed.username,
|
||||||
|
"resource_types.delete",
|
||||||
|
ActionKind::Delete,
|
||||||
|
&w_id,
|
||||||
|
Some(&name),
|
||||||
|
None,
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
tx.commit().await?;
|
||||||
|
|
||||||
|
Ok(format!("resource_type {} deleted", name))
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn update_resource_type(
|
||||||
|
authed: Authed,
|
||||||
|
Extension(user_db): Extension<UserDB>,
|
||||||
|
Path((w_id, name)): Path<(String, String)>,
|
||||||
|
Json(ns): Json<EditResourceType>,
|
||||||
|
) -> Result<String> {
|
||||||
|
use sql_builder::prelude::*;
|
||||||
|
|
||||||
|
let mut sqlb = SqlBuilder::update_table("resource_type");
|
||||||
|
sqlb.and_where_eq("name", "?".bind(&name));
|
||||||
|
sqlb.and_where_eq("workspace_id", "?".bind(&w_id));
|
||||||
|
if let Some(nschema) = ns.schema {
|
||||||
|
sqlb.set_str("schema", nschema);
|
||||||
|
}
|
||||||
|
if let Some(ndesc) = ns.description {
|
||||||
|
sqlb.set_str("description", ndesc);
|
||||||
|
}
|
||||||
|
let sql = sqlb.sql().map_err(|e| Error::InternalErr(e.to_string()))?;
|
||||||
|
let mut tx = user_db.begin(&authed).await?;
|
||||||
|
|
||||||
|
sqlx::query(&sql).execute(&mut tx).await?;
|
||||||
|
audit_log(
|
||||||
|
&mut tx,
|
||||||
|
&authed.username,
|
||||||
|
"resource_types.update",
|
||||||
|
ActionKind::Update,
|
||||||
|
&w_id,
|
||||||
|
Some(&name),
|
||||||
|
None,
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
tx.commit().await?;
|
||||||
|
|
||||||
|
Ok(format!("resource_type {} updated", name))
|
||||||
|
}
|
||||||
360
backend/src/schedule.rs
Normal file
360
backend/src/schedule.rs
Normal file
@@ -0,0 +1,360 @@
|
|||||||
|
/*
|
||||||
|
* Author & Copyright: Ruben Fiszel 2021
|
||||||
|
* This file and its contents are licensed under the AGPL License.
|
||||||
|
* Please see the included NOTICE for copyright information and
|
||||||
|
* LICENSE-AGPL for a copy of the license.
|
||||||
|
*/
|
||||||
|
|
||||||
|
use std::str::FromStr;
|
||||||
|
|
||||||
|
use crate::{
|
||||||
|
audit::{audit_log, ActionKind},
|
||||||
|
db::UserDB,
|
||||||
|
error::{self, JsonResult, Result},
|
||||||
|
jobs::{self, push, JobPayload},
|
||||||
|
users::Authed,
|
||||||
|
utils::{get_owner_from_path, Pagination, StripPath},
|
||||||
|
};
|
||||||
|
use axum::{
|
||||||
|
extract::{Extension, Path, Query},
|
||||||
|
routing::{get, post},
|
||||||
|
Json, Router,
|
||||||
|
};
|
||||||
|
|
||||||
|
use chrono::{DateTime, Duration, FixedOffset};
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
use serde_json::{Map, Value};
|
||||||
|
use sqlx::{FromRow, Postgres, Transaction};
|
||||||
|
|
||||||
|
pub fn workspaced_service() -> Router {
|
||||||
|
Router::new()
|
||||||
|
.route("/list", get(list_schedule))
|
||||||
|
.route("/get/*path", get(get_schedule))
|
||||||
|
.route("/create", post(create_schedule))
|
||||||
|
.route("/update/*path", post(edit_schedule))
|
||||||
|
.route("/setenabled/*path", post(set_enabled))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn global_service() -> Router {
|
||||||
|
Router::new().route("/preview", post(preview_schedule))
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(FromRow, Serialize, Deserialize, Debug)]
|
||||||
|
pub struct Schedule {
|
||||||
|
pub workspace_id: String,
|
||||||
|
pub path: String,
|
||||||
|
pub edited_by: String,
|
||||||
|
pub edited_at: DateTime<chrono::Utc>,
|
||||||
|
pub schedule: String,
|
||||||
|
pub offset_: i32,
|
||||||
|
pub enabled: bool,
|
||||||
|
pub script_path: String,
|
||||||
|
pub is_flow: bool,
|
||||||
|
pub args: Option<serde_json::Value>,
|
||||||
|
pub extra_perms: serde_json::Value,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
pub struct NewSchedule {
|
||||||
|
pub path: String,
|
||||||
|
pub schedule: String,
|
||||||
|
pub offset: i32,
|
||||||
|
pub script_path: String,
|
||||||
|
pub is_flow: bool,
|
||||||
|
pub args: Option<serde_json::Value>,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn push_scheduled_job<'c>(
|
||||||
|
mut tx: Transaction<'c, Postgres>,
|
||||||
|
schedule: Schedule,
|
||||||
|
) -> Result<Transaction<'c, Postgres>> {
|
||||||
|
let sched = cron::Schedule::from_str(&schedule.schedule)
|
||||||
|
.map_err(|e| error::Error::BadRequest(e.to_string()))?;
|
||||||
|
|
||||||
|
let offset = Duration::minutes(schedule.offset_.into());
|
||||||
|
let next = sched
|
||||||
|
.after(&(chrono::Utc::now() - offset + Duration::seconds(1)))
|
||||||
|
.next()
|
||||||
|
.expect("a schedule should have a next event")
|
||||||
|
+ offset;
|
||||||
|
|
||||||
|
let mut args: Option<Map<String, Value>> = None;
|
||||||
|
|
||||||
|
if let Some(args_v) = schedule.args {
|
||||||
|
if let Value::Object(args_m) = args_v {
|
||||||
|
args = Some(args_m)
|
||||||
|
} else {
|
||||||
|
return Err(error::Error::ExecutionErr(
|
||||||
|
"args of scripts needs to be dict".to_string(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let payload = if schedule.is_flow {
|
||||||
|
JobPayload::Flow(schedule.script_path)
|
||||||
|
} else {
|
||||||
|
JobPayload::ScriptHash {
|
||||||
|
hash: jobs::get_latest_hash_for_path(
|
||||||
|
&mut tx,
|
||||||
|
&schedule.workspace_id,
|
||||||
|
&schedule.script_path,
|
||||||
|
)
|
||||||
|
.await?,
|
||||||
|
path: schedule.script_path,
|
||||||
|
}
|
||||||
|
};
|
||||||
|
let (_, tx) = push(
|
||||||
|
tx,
|
||||||
|
&schedule.workspace_id,
|
||||||
|
payload,
|
||||||
|
args,
|
||||||
|
&schedule_to_user(&schedule.path),
|
||||||
|
get_owner_from_path(&schedule.path),
|
||||||
|
Some(next),
|
||||||
|
Some(schedule.path),
|
||||||
|
None,
|
||||||
|
false,
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
Ok(tx)
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn create_schedule(
|
||||||
|
authed: Authed,
|
||||||
|
Extension(user_db): Extension<UserDB>,
|
||||||
|
Path(w_id): Path<String>,
|
||||||
|
Json(ns): Json<NewSchedule>,
|
||||||
|
) -> Result<String> {
|
||||||
|
cron::Schedule::from_str(&ns.schedule).map_err(|e| error::Error::BadRequest(e.to_string()))?;
|
||||||
|
let mut tx = user_db.begin(&authed).await?;
|
||||||
|
|
||||||
|
let schedule = sqlx::query_as!(Schedule,
|
||||||
|
"INSERT INTO schedule (workspace_id, path, schedule, offset_, edited_by, script_path, is_flow, args) VALUES ($1, $2, $3, $4, $5, $6, $7, $8) RETURNING *",
|
||||||
|
w_id,
|
||||||
|
ns.path,
|
||||||
|
ns.schedule,
|
||||||
|
ns.offset,
|
||||||
|
&authed.username,
|
||||||
|
ns.script_path,
|
||||||
|
ns.is_flow,
|
||||||
|
ns.args
|
||||||
|
)
|
||||||
|
.fetch_one(&mut tx)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
audit_log(
|
||||||
|
&mut tx,
|
||||||
|
&authed.username,
|
||||||
|
"schedule.create",
|
||||||
|
ActionKind::Create,
|
||||||
|
&w_id,
|
||||||
|
Some(&ns.path.to_string()),
|
||||||
|
Some(
|
||||||
|
[
|
||||||
|
Some(("schedule", ns.schedule.as_str())),
|
||||||
|
Some(("script_path", ns.script_path.as_str())),
|
||||||
|
]
|
||||||
|
.into_iter()
|
||||||
|
.flatten()
|
||||||
|
.collect(),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
let tx = push_scheduled_job(tx, schedule).await?;
|
||||||
|
tx.commit().await?;
|
||||||
|
Ok(ns.path.to_string())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
pub struct EditSchedule {
|
||||||
|
pub schedule: String,
|
||||||
|
pub script_path: String,
|
||||||
|
pub is_flow: bool,
|
||||||
|
pub args: Option<serde_json::Value>,
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn clear_schedule<'c>(db: &mut Transaction<'c, Postgres>, path: &str) -> Result<()> {
|
||||||
|
sqlx::query!("DELETE FROM queue WHERE schedule_path = $1", path)
|
||||||
|
.execute(db)
|
||||||
|
.await?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn edit_schedule(
|
||||||
|
authed: Authed,
|
||||||
|
Extension(user_db): Extension<UserDB>,
|
||||||
|
Path((w_id, path)): Path<(String, StripPath)>,
|
||||||
|
Json(es): Json<EditSchedule>,
|
||||||
|
) -> Result<String> {
|
||||||
|
let path = path.to_path();
|
||||||
|
|
||||||
|
cron::Schedule::from_str(&es.schedule).map_err(|e| error::Error::BadRequest(e.to_string()))?;
|
||||||
|
|
||||||
|
let mut tx = user_db.begin(&authed).await?;
|
||||||
|
|
||||||
|
clear_schedule(&mut tx, path).await?;
|
||||||
|
let schedule = sqlx::query_as!(Schedule,
|
||||||
|
"UPDATE schedule SET schedule = $1, script_path = $2, is_flow = $3, args = $4 WHERE path = $5 AND workspace_id = $6 RETURNING *",
|
||||||
|
es.schedule,
|
||||||
|
es.script_path,
|
||||||
|
es.is_flow,
|
||||||
|
es.args,
|
||||||
|
path,
|
||||||
|
w_id,
|
||||||
|
)
|
||||||
|
.fetch_one(&mut tx)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
if schedule.enabled {
|
||||||
|
tx = push_scheduled_job(tx, schedule).await?;
|
||||||
|
}
|
||||||
|
|
||||||
|
audit_log(
|
||||||
|
&mut tx,
|
||||||
|
&authed.username,
|
||||||
|
"schedule.edit",
|
||||||
|
ActionKind::Update,
|
||||||
|
&w_id,
|
||||||
|
Some(&path.to_string()),
|
||||||
|
Some(
|
||||||
|
[
|
||||||
|
Some(("schedule", es.schedule.as_str())),
|
||||||
|
Some(("script_path", es.script_path.as_str())),
|
||||||
|
]
|
||||||
|
.into_iter()
|
||||||
|
.flatten()
|
||||||
|
.collect(),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
tx.commit().await?;
|
||||||
|
Ok(path.to_string())
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn list_schedule(
|
||||||
|
authed: Authed,
|
||||||
|
Extension(user_db): Extension<UserDB>,
|
||||||
|
Path(w_id): Path<String>,
|
||||||
|
Query(pagination): Query<Pagination>,
|
||||||
|
) -> JsonResult<Vec<Schedule>> {
|
||||||
|
let (per_page, offset) = crate::utils::paginate(pagination);
|
||||||
|
let mut tx = user_db.begin(&authed).await?;
|
||||||
|
|
||||||
|
let rows = sqlx::query_as!(
|
||||||
|
Schedule,
|
||||||
|
"SELECT * FROM schedule WHERE workspace_id = $1 ORDER BY edited_at desc LIMIT $2 OFFSET $3",
|
||||||
|
w_id,
|
||||||
|
per_page as i64,
|
||||||
|
offset as i64
|
||||||
|
)
|
||||||
|
.fetch_all(&mut tx)
|
||||||
|
.await?;
|
||||||
|
tx.commit().await?;
|
||||||
|
Ok(Json(rows))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn get_schedule_opt<'c>(
|
||||||
|
db: &mut Transaction<'c, Postgres>,
|
||||||
|
w_id: &str,
|
||||||
|
path: &str,
|
||||||
|
) -> Result<Option<Schedule>> {
|
||||||
|
let schedule_opt = sqlx::query_as!(
|
||||||
|
Schedule,
|
||||||
|
"SELECT * FROM schedule WHERE path = $1 AND workspace_id = $2",
|
||||||
|
path,
|
||||||
|
w_id
|
||||||
|
)
|
||||||
|
.fetch_optional(db)
|
||||||
|
.await?;
|
||||||
|
Ok(schedule_opt)
|
||||||
|
}
|
||||||
|
async fn get_schedule(
|
||||||
|
authed: Authed,
|
||||||
|
Extension(user_db): Extension<UserDB>,
|
||||||
|
Path((w_id, path)): Path<(String, StripPath)>,
|
||||||
|
) -> JsonResult<Schedule> {
|
||||||
|
let path = path.to_path();
|
||||||
|
let mut tx = user_db.begin(&authed).await?;
|
||||||
|
|
||||||
|
let schedule_o = get_schedule_opt(&mut tx, &w_id, path).await?;
|
||||||
|
let schedule = crate::utils::not_found_if_none(schedule_o, "Schedule", path)?;
|
||||||
|
tx.commit().await?;
|
||||||
|
Ok(Json(schedule))
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
pub struct PreviewPayload {
|
||||||
|
pub schedule: String,
|
||||||
|
pub offset: Option<i32>,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn preview_schedule(
|
||||||
|
Json(PreviewPayload { schedule, offset }): Json<PreviewPayload>,
|
||||||
|
) -> JsonResult<Vec<DateTime<chrono::Utc>>> {
|
||||||
|
let schedule =
|
||||||
|
cron::Schedule::from_str(&schedule).map_err(|e| error::Error::BadRequest(e.to_string()))?;
|
||||||
|
let upcoming: Vec<DateTime<chrono::Utc>> = schedule
|
||||||
|
.upcoming(get_offset(offset))
|
||||||
|
.take(10)
|
||||||
|
.map(|x| x.into())
|
||||||
|
.collect();
|
||||||
|
Ok(Json(upcoming))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn get_offset(offset: Option<i32>) -> FixedOffset {
|
||||||
|
FixedOffset::west(offset.unwrap_or(0) * 60)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
pub struct SetEnabled {
|
||||||
|
pub enabled: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn set_enabled(
|
||||||
|
authed: Authed,
|
||||||
|
Extension(user_db): Extension<UserDB>,
|
||||||
|
Path((w_id, path)): Path<(String, StripPath)>,
|
||||||
|
Json(SetEnabled { enabled }): Json<SetEnabled>,
|
||||||
|
) -> Result<String> {
|
||||||
|
let path = path.to_path();
|
||||||
|
let mut tx = user_db.begin(&authed).await?;
|
||||||
|
|
||||||
|
let schedule_o = sqlx::query_as!(
|
||||||
|
Schedule,
|
||||||
|
"UPDATE schedule SET enabled = $1 WHERE path = $2 AND workspace_id = $3 RETURNING *",
|
||||||
|
enabled,
|
||||||
|
path,
|
||||||
|
w_id
|
||||||
|
)
|
||||||
|
.fetch_optional(&mut tx)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
let schedule = crate::utils::not_found_if_none(schedule_o, "Schedule", path)?;
|
||||||
|
|
||||||
|
clear_schedule(&mut tx, path).await?;
|
||||||
|
|
||||||
|
if enabled {
|
||||||
|
tx = push_scheduled_job(tx, schedule).await?;
|
||||||
|
}
|
||||||
|
audit_log(
|
||||||
|
&mut tx,
|
||||||
|
&authed.username,
|
||||||
|
"schedule.setenabled",
|
||||||
|
ActionKind::Update,
|
||||||
|
&w_id,
|
||||||
|
Some(path),
|
||||||
|
Some([("enabled", enabled.to_string().as_ref())].into()),
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
tx.commit().await?;
|
||||||
|
Ok(format!(
|
||||||
|
"succesfully updated schedule at path {} to status {}",
|
||||||
|
path, enabled
|
||||||
|
))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn schedule_to_user(path: &str) -> String {
|
||||||
|
format!("schedule-{}", path.replace('/', "-"))
|
||||||
|
}
|
||||||
614
backend/src/scripts.rs
Normal file
614
backend/src/scripts.rs
Normal file
@@ -0,0 +1,614 @@
|
|||||||
|
/*
|
||||||
|
* Author & Copyright: Ruben Fiszel 2021
|
||||||
|
* This file and its contents are licensed under the AGPL License.
|
||||||
|
* Please see the included NOTICE for copyright information and
|
||||||
|
* LICENSE-AGPL for a copy of the license.
|
||||||
|
*/
|
||||||
|
|
||||||
|
use serde::Deserializer;
|
||||||
|
use sql_builder::prelude::*;
|
||||||
|
|
||||||
|
use crate::{
|
||||||
|
audit::{audit_log, ActionKind},
|
||||||
|
db::{UserDB, DB},
|
||||||
|
error::{Error, JsonResult, Result},
|
||||||
|
jobs, parser,
|
||||||
|
users::{owner_to_token_owner, truncate_token, Authed, Tokened},
|
||||||
|
utils::{require_admin, Pagination, StripPath},
|
||||||
|
};
|
||||||
|
use axum::{
|
||||||
|
extract::{Extension, Path, Query},
|
||||||
|
routing::{get, post},
|
||||||
|
Json, Router,
|
||||||
|
};
|
||||||
|
use hyper::StatusCode;
|
||||||
|
use serde::{de::Error as _, ser::SerializeSeq, Deserialize, Serialize};
|
||||||
|
use serde_json::{json, to_string_pretty};
|
||||||
|
use sql_builder::SqlBuilder;
|
||||||
|
use sqlx::{FromRow, Postgres, Transaction};
|
||||||
|
use std::{
|
||||||
|
collections::hash_map::DefaultHasher,
|
||||||
|
fmt::Display,
|
||||||
|
hash::{Hash, Hasher},
|
||||||
|
};
|
||||||
|
|
||||||
|
const MAX_HASH_HISTORY_LENGTH_STORED: usize = 20;
|
||||||
|
|
||||||
|
pub fn global_service() -> Router {
|
||||||
|
Router::new().route("/tojsonschema", post(parse_code_to_jsonschema))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn workspaced_service() -> Router {
|
||||||
|
Router::new()
|
||||||
|
.route("/list", get(list_scripts))
|
||||||
|
.route("/create", post(create_script))
|
||||||
|
.route("/archive/p/*path", post(archive_script_by_path))
|
||||||
|
.route("/get/p/*path", get(get_script_by_path))
|
||||||
|
.route("/archive/h/:hash", post(archive_script_by_hash))
|
||||||
|
.route("/delete/h/:hash", post(delete_script_by_hash))
|
||||||
|
.route("/get/h/:hash", get(get_script_by_hash))
|
||||||
|
.route("/deployment_status/h/:hash", get(get_deployment_status))
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(sqlx::Type, PartialEq, Debug, Hash, Clone, Copy)]
|
||||||
|
#[sqlx(transparent)]
|
||||||
|
pub struct ScriptHash(pub i64);
|
||||||
|
|
||||||
|
#[derive(sqlx::Type, PartialEq)]
|
||||||
|
#[sqlx(transparent)]
|
||||||
|
pub struct ScriptHashes(Vec<i64>);
|
||||||
|
|
||||||
|
impl Display for ScriptHash {
|
||||||
|
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||||
|
write!(f, "{}", to_hex_string(&self.0))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
impl Serialize for ScriptHash {
|
||||||
|
fn serialize<S>(&self, serializer: S) -> std::result::Result<S::Ok, S::Error>
|
||||||
|
where
|
||||||
|
S: serde::Serializer,
|
||||||
|
{
|
||||||
|
serializer.serialize_str(to_hex_string(&self.0).as_str())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
impl<'de> Deserialize<'de> for ScriptHash {
|
||||||
|
fn deserialize<D>(deserializer: D) -> std::result::Result<ScriptHash, D::Error>
|
||||||
|
where
|
||||||
|
D: Deserializer<'de>,
|
||||||
|
{
|
||||||
|
let s = String::deserialize(deserializer)?;
|
||||||
|
let i = to_i64(&s).map_err(|e| D::Error::custom(format!("{}", e)))?;
|
||||||
|
Ok(ScriptHash(i))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Serialize for ScriptHashes {
|
||||||
|
fn serialize<S>(&self, serializer: S) -> std::result::Result<S::Ok, S::Error>
|
||||||
|
where
|
||||||
|
S: serde::Serializer,
|
||||||
|
{
|
||||||
|
let mut seq = serializer.serialize_seq(Some(self.0.len()))?;
|
||||||
|
for element in &self.0 {
|
||||||
|
seq.serialize_element(&ScriptHash(*element))?;
|
||||||
|
}
|
||||||
|
seq.end()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(FromRow, Serialize)]
|
||||||
|
pub struct Script {
|
||||||
|
pub workspace_id: String,
|
||||||
|
pub hash: ScriptHash,
|
||||||
|
pub path: String,
|
||||||
|
pub parent_hashes: Option<ScriptHashes>,
|
||||||
|
pub summary: String,
|
||||||
|
pub description: String,
|
||||||
|
pub content: String,
|
||||||
|
pub created_by: String,
|
||||||
|
pub created_at: chrono::DateTime<chrono::Utc>,
|
||||||
|
pub archived: bool,
|
||||||
|
pub schema: Option<Schema>,
|
||||||
|
pub deleted: bool,
|
||||||
|
pub is_template: bool,
|
||||||
|
pub extra_perms: serde_json::Value,
|
||||||
|
pub lock: Option<String>,
|
||||||
|
pub lock_error_logs: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Serialize, Deserialize, sqlx::Type, Debug)]
|
||||||
|
#[sqlx(transparent)]
|
||||||
|
#[serde(transparent)]
|
||||||
|
pub struct Schema(pub serde_json::Value);
|
||||||
|
|
||||||
|
impl Hash for Schema {
|
||||||
|
fn hash<H: Hasher>(&self, state: &mut H) {
|
||||||
|
if let Ok(s) = to_string_pretty(&self.0) {
|
||||||
|
s.hash(state);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Serialize, Deserialize, Hash)]
|
||||||
|
pub struct NewScript {
|
||||||
|
pub path: String,
|
||||||
|
pub parent_hash: Option<ScriptHash>,
|
||||||
|
pub summary: String,
|
||||||
|
pub description: String,
|
||||||
|
pub content: String,
|
||||||
|
pub schema: Option<Schema>,
|
||||||
|
pub is_template: Option<bool>,
|
||||||
|
pub lock: Option<Vec<String>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
pub struct ListScriptQuery {
|
||||||
|
pub path_start: Option<String>,
|
||||||
|
pub path_exact: Option<String>,
|
||||||
|
pub created_by: Option<String>,
|
||||||
|
pub first_parent_hash: Option<ScriptHash>,
|
||||||
|
pub last_parent_hash: Option<ScriptHash>,
|
||||||
|
pub parent_hash: Option<ScriptHash>,
|
||||||
|
pub show_archived: Option<bool>,
|
||||||
|
pub order_by: Option<String>,
|
||||||
|
pub order_desc: Option<bool>,
|
||||||
|
pub is_template: Option<bool>,
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn list_scripts(
|
||||||
|
authed: Authed,
|
||||||
|
Extension(user_db): Extension<UserDB>,
|
||||||
|
Path(w_id): Path<String>,
|
||||||
|
Query(pagination): Query<Pagination>,
|
||||||
|
Query(lq): Query<ListScriptQuery>,
|
||||||
|
) -> JsonResult<Vec<Script>> {
|
||||||
|
let (per_page, offset) = crate::utils::paginate(pagination);
|
||||||
|
|
||||||
|
let mut sqlb = SqlBuilder::select_from("script as o")
|
||||||
|
.fields(&[
|
||||||
|
"workspace_id",
|
||||||
|
"hash",
|
||||||
|
"path",
|
||||||
|
"array_remove(array[parent_hashes[1]], NULL) as parent_hashes",
|
||||||
|
"summary",
|
||||||
|
"description",
|
||||||
|
"'' as content",
|
||||||
|
"created_by",
|
||||||
|
"created_at",
|
||||||
|
"archived",
|
||||||
|
"schema",
|
||||||
|
"deleted",
|
||||||
|
"is_template",
|
||||||
|
"extra_perms",
|
||||||
|
"null as lock",
|
||||||
|
"CASE WHEN lock_error_logs IS NOT NULL THEN 'error' ELSE null END as lock_error_logs",
|
||||||
|
])
|
||||||
|
.order_by("created_at", lq.order_desc.unwrap_or(true))
|
||||||
|
.and_where("workspace_id = ? OR workspace_id = 'starter'".bind(&w_id))
|
||||||
|
.offset(offset)
|
||||||
|
.limit(per_page)
|
||||||
|
.clone();
|
||||||
|
|
||||||
|
if lq.show_archived.unwrap_or(false) {
|
||||||
|
sqlb.and_where_eq(
|
||||||
|
"created_at",
|
||||||
|
"(select max(created_at) from script where o.path = path
|
||||||
|
AND (workspace_id = $1 OR workspace_id = 'starter'))",
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
sqlb.and_where_eq("archived", false);
|
||||||
|
}
|
||||||
|
if let Some(ps) = &lq.path_start {
|
||||||
|
sqlb.and_where_like_left("path", "?".bind(ps));
|
||||||
|
}
|
||||||
|
if let Some(p) = &lq.path_exact {
|
||||||
|
sqlb.and_where_eq("path", "?".bind(p));
|
||||||
|
}
|
||||||
|
if let Some(cb) = &lq.created_by {
|
||||||
|
sqlb.and_where_eq("created_by", "?".bind(cb));
|
||||||
|
}
|
||||||
|
if let Some(ph) = &lq.first_parent_hash {
|
||||||
|
sqlb.and_where_eq("parent_hashes[1]", &ph.0);
|
||||||
|
}
|
||||||
|
if let Some(ph) = &lq.last_parent_hash {
|
||||||
|
sqlb.and_where_eq("parent_hashes[array_upper(parent_hashes, 1)]", &ph.0);
|
||||||
|
}
|
||||||
|
if let Some(ph) = &lq.parent_hash {
|
||||||
|
sqlb.and_where_eq("any(parent_hashes)", &ph.0);
|
||||||
|
}
|
||||||
|
if let Some(it) = &lq.is_template {
|
||||||
|
sqlb.and_where_eq("is_template", it);
|
||||||
|
}
|
||||||
|
|
||||||
|
let sql = sqlb.sql().map_err(|e| Error::InternalErr(e.to_string()))?;
|
||||||
|
let mut tx = user_db.begin(&authed).await?;
|
||||||
|
let rows = sqlx::query_as::<_, Script>(&sql).fetch_all(&mut tx).await?;
|
||||||
|
tx.commit().await?;
|
||||||
|
Ok(Json(rows))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn hash_script(ns: &NewScript) -> i64 {
|
||||||
|
let mut dh = DefaultHasher::new();
|
||||||
|
ns.hash(&mut dh);
|
||||||
|
dh.finish() as i64
|
||||||
|
}
|
||||||
|
async fn create_script(
|
||||||
|
authed: Authed,
|
||||||
|
Tokened { token }: Tokened,
|
||||||
|
Extension(user_db): Extension<UserDB>,
|
||||||
|
Path(w_id): Path<String>,
|
||||||
|
Json(ns): Json<NewScript>,
|
||||||
|
) -> Result<(StatusCode, String)> {
|
||||||
|
let hash = ScriptHash(hash_script(&ns));
|
||||||
|
let mut tx = user_db.begin(&authed).await?;
|
||||||
|
|
||||||
|
if sqlx::query_scalar!(
|
||||||
|
"SELECT 1 FROM script WHERE hash = $1 AND workspace_id = $2",
|
||||||
|
hash.0,
|
||||||
|
&w_id
|
||||||
|
)
|
||||||
|
.fetch_optional(&mut tx)
|
||||||
|
.await?
|
||||||
|
.is_some()
|
||||||
|
{
|
||||||
|
return Err(Error::BadRequest(
|
||||||
|
"A script with same hash (hence same path, description, summary, content) already \
|
||||||
|
exists!"
|
||||||
|
.to_owned(),
|
||||||
|
));
|
||||||
|
};
|
||||||
|
|
||||||
|
let clashing_script = sqlx::query_as::<_, Script>(
|
||||||
|
"SELECT * FROM script WHERE path = $1 AND archived = false AND workspace_id = $2",
|
||||||
|
)
|
||||||
|
.bind(&ns.path)
|
||||||
|
.bind(&w_id)
|
||||||
|
.fetch_optional(&mut tx)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
let parent_hashes_and_perms: Option<(Vec<i64>, serde_json::Value)> =
|
||||||
|
match (&ns.parent_hash, clashing_script) {
|
||||||
|
(None, None) => Ok(None),
|
||||||
|
(None, Some(s)) => Err(Error::BadRequest(format!(
|
||||||
|
"Path conflict for {} with non-archived hash {}",
|
||||||
|
&ns.path, &s.hash
|
||||||
|
))),
|
||||||
|
(Some(p_hash), o) => {
|
||||||
|
if sqlx::query_scalar!(
|
||||||
|
"SELECT 1 FROM script WHERE hash = $1 AND workspace_id = $2",
|
||||||
|
p_hash.0,
|
||||||
|
&w_id
|
||||||
|
)
|
||||||
|
.fetch_optional(&mut tx)
|
||||||
|
.await?
|
||||||
|
.is_none()
|
||||||
|
{
|
||||||
|
return Err(Error::BadRequest(
|
||||||
|
"The parent hash does not seem to exist".to_owned(),
|
||||||
|
));
|
||||||
|
};
|
||||||
|
|
||||||
|
let clashing_hash_o = sqlx::query_scalar!(
|
||||||
|
"SELECT hash FROM script WHERE parent_hashes[1] = $1 AND workspace_id = $2",
|
||||||
|
p_hash.0,
|
||||||
|
&w_id
|
||||||
|
)
|
||||||
|
.fetch_optional(&mut tx)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
if let Some(clashing_hash) = clashing_hash_o {
|
||||||
|
return Err(Error::BadRequest(format!(
|
||||||
|
"A script with hash {} with same parent_hash has been found. However, the \
|
||||||
|
lineage must be linear: no 2 scripts can have the same parent",
|
||||||
|
ScriptHash(clashing_hash)
|
||||||
|
)));
|
||||||
|
};
|
||||||
|
|
||||||
|
let ps = get_script_by_hash_internal(&mut tx, &w_id, p_hash).await?;
|
||||||
|
|
||||||
|
let ph = {
|
||||||
|
let v = ps.parent_hashes.map(|x| x.0).unwrap_or_default();
|
||||||
|
let mut v: Vec<i64> = v
|
||||||
|
.into_iter()
|
||||||
|
.take(MAX_HASH_HISTORY_LENGTH_STORED - 1)
|
||||||
|
.collect();
|
||||||
|
v.insert(0, p_hash.0);
|
||||||
|
v
|
||||||
|
};
|
||||||
|
let r: Result<Option<(Vec<i64>, serde_json::Value)>> = match o {
|
||||||
|
Some(clashing_script)
|
||||||
|
if clashing_script.path == ns.path
|
||||||
|
&& clashing_script.hash.0 != p_hash.0 =>
|
||||||
|
{
|
||||||
|
Err(Error::BadRequest(format!(
|
||||||
|
"Path conflict for {} with non-archived hash {}",
|
||||||
|
&ns.path, &clashing_script.hash
|
||||||
|
)))
|
||||||
|
}
|
||||||
|
Some(_) => Ok(Some((ph, ps.extra_perms))),
|
||||||
|
None => Ok(Some((ph, ps.extra_perms))),
|
||||||
|
};
|
||||||
|
sqlx::query!(
|
||||||
|
"UPDATE script SET archived = true WHERE hash = $1 AND workspace_id = $2",
|
||||||
|
p_hash.0,
|
||||||
|
&w_id
|
||||||
|
)
|
||||||
|
.execute(&mut tx)
|
||||||
|
.await?;
|
||||||
|
r
|
||||||
|
}
|
||||||
|
}?;
|
||||||
|
|
||||||
|
let p_hashes = parent_hashes_and_perms.as_ref().map(|v| &v.0[..]);
|
||||||
|
let extra_perms = parent_hashes_and_perms
|
||||||
|
.as_ref()
|
||||||
|
.map(|v| v.1.clone())
|
||||||
|
.unwrap_or(json!({}));
|
||||||
|
|
||||||
|
//::text::json is to ensure we use serde_json with preserve order
|
||||||
|
sqlx::query!(
|
||||||
|
"INSERT INTO script (workspace_id, hash, path, parent_hashes, summary, description, content, \
|
||||||
|
created_by, schema, is_template, extra_perms, lock) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9::text::json, $10, $11, $12)",
|
||||||
|
&w_id,
|
||||||
|
&hash.0,
|
||||||
|
ns.path,
|
||||||
|
p_hashes,
|
||||||
|
ns.summary,
|
||||||
|
ns.description,
|
||||||
|
&ns.content,
|
||||||
|
&authed.username,
|
||||||
|
ns.schema.and_then(|x| serde_json::to_string(&x.0).ok()),
|
||||||
|
ns.is_template.unwrap_or(false),
|
||||||
|
extra_perms,
|
||||||
|
ns.lock.as_ref().map(|x| x.join("\n"))
|
||||||
|
)
|
||||||
|
.execute(&mut tx)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
let mut tx = if ns.lock.is_none() {
|
||||||
|
let dependencies = parser::parse_imports(&ns.content)?;
|
||||||
|
let (_, tx) = jobs::push(
|
||||||
|
tx,
|
||||||
|
&w_id,
|
||||||
|
jobs::JobPayload::Dependencies { hash, dependencies },
|
||||||
|
None,
|
||||||
|
&authed.username,
|
||||||
|
owner_to_token_owner(&authed.username, false),
|
||||||
|
None,
|
||||||
|
None,
|
||||||
|
None,
|
||||||
|
false,
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
tx
|
||||||
|
} else {
|
||||||
|
tx
|
||||||
|
};
|
||||||
|
|
||||||
|
if p_hashes.is_some() && !p_hashes.unwrap().is_empty() {
|
||||||
|
audit_log(
|
||||||
|
&mut tx,
|
||||||
|
&authed.username,
|
||||||
|
"scripts.update",
|
||||||
|
ActionKind::Update,
|
||||||
|
&w_id,
|
||||||
|
Some(&ns.path),
|
||||||
|
Some(
|
||||||
|
[
|
||||||
|
("hash", hash.to_string().as_str()),
|
||||||
|
("token", &truncate_token(&token)),
|
||||||
|
]
|
||||||
|
.into(),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
} else {
|
||||||
|
audit_log(
|
||||||
|
&mut tx,
|
||||||
|
&authed.username,
|
||||||
|
"scripts.create",
|
||||||
|
ActionKind::Create,
|
||||||
|
&w_id,
|
||||||
|
Some(&ns.path),
|
||||||
|
Some(
|
||||||
|
[
|
||||||
|
("workspace", w_id.as_str()),
|
||||||
|
("hash", hash.to_string().as_str()),
|
||||||
|
("token", &truncate_token(&token)),
|
||||||
|
]
|
||||||
|
.into(),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
}
|
||||||
|
|
||||||
|
tx.commit().await?;
|
||||||
|
|
||||||
|
Ok((StatusCode::CREATED, format!("{}", hash)))
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn get_script_by_path(
|
||||||
|
authed: Authed,
|
||||||
|
Extension(user_db): Extension<UserDB>,
|
||||||
|
Path((w_id, path)): Path<(String, StripPath)>,
|
||||||
|
) -> JsonResult<Script> {
|
||||||
|
let path = path.to_path();
|
||||||
|
let mut tx = user_db.begin(&authed).await?;
|
||||||
|
|
||||||
|
let script_o = sqlx::query_as::<_, Script>(
|
||||||
|
"SELECT * FROM script WHERE path = $1 AND (workspace_id = $2 OR workspace_id = 'starter') AND
|
||||||
|
created_at = (SELECT max(created_at) FROM script WHERE path = $1 AND archived = false AND (workspace_id = $2 OR workspace_id = 'starter'))",
|
||||||
|
)
|
||||||
|
.bind(path)
|
||||||
|
.bind(w_id)
|
||||||
|
.fetch_optional(&mut tx)
|
||||||
|
.await?;
|
||||||
|
tx.commit().await?;
|
||||||
|
|
||||||
|
let script = crate::utils::not_found_if_none(script_o, "Script", path)?;
|
||||||
|
Ok(Json(script))
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn get_script_by_hash_internal<'c>(
|
||||||
|
db: &mut Transaction<'c, Postgres>,
|
||||||
|
workspace_id: &str,
|
||||||
|
hash: &ScriptHash,
|
||||||
|
) -> Result<Script> {
|
||||||
|
let script_o = sqlx::query_as::<_, Script>(
|
||||||
|
"SELECT * FROM script WHERE hash = $1 AND (workspace_id = $2 OR workspace_id = 'starter')",
|
||||||
|
)
|
||||||
|
.bind(hash)
|
||||||
|
.bind(workspace_id)
|
||||||
|
.fetch_optional(db)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
let script = crate::utils::not_found_if_none(script_o, "Script", hash.to_string())?;
|
||||||
|
Ok(script)
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn get_script_by_hash(
|
||||||
|
authed: Authed,
|
||||||
|
Extension(user_db): Extension<UserDB>,
|
||||||
|
Path((w_id, hash)): Path<(String, ScriptHash)>,
|
||||||
|
) -> JsonResult<Script> {
|
||||||
|
let mut tx = user_db.begin(&authed).await?;
|
||||||
|
let r = get_script_by_hash_internal(&mut tx, &w_id, &hash).await?;
|
||||||
|
tx.commit().await?;
|
||||||
|
|
||||||
|
Ok(Json(r))
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(FromRow, Serialize)]
|
||||||
|
struct DeploymentStatus {
|
||||||
|
lock: Option<String>,
|
||||||
|
lock_error_logs: Option<String>,
|
||||||
|
}
|
||||||
|
async fn get_deployment_status(
|
||||||
|
authed: Authed,
|
||||||
|
Extension(user_db): Extension<UserDB>,
|
||||||
|
Path((w_id, hash)): Path<(String, ScriptHash)>,
|
||||||
|
) -> JsonResult<DeploymentStatus> {
|
||||||
|
let mut tx = user_db.begin(&authed).await?;
|
||||||
|
let status_o: Option<DeploymentStatus> = sqlx::query_as!(DeploymentStatus,
|
||||||
|
"SELECT lock, lock_error_logs FROM script WHERE hash = $1 AND (workspace_id = $2 OR workspace_id = 'starter')",
|
||||||
|
hash.0,
|
||||||
|
w_id,
|
||||||
|
)
|
||||||
|
.fetch_optional(&mut tx)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
let status = crate::utils::not_found_if_none(status_o, "DeploymentStatus", hash.to_string())?;
|
||||||
|
|
||||||
|
tx.commit().await?;
|
||||||
|
Ok(Json(status))
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn archive_script_by_path(
|
||||||
|
authed: Authed,
|
||||||
|
Extension(user_db): Extension<UserDB>,
|
||||||
|
Extension(db): Extension<DB>,
|
||||||
|
Path((w_id, path)): Path<(String, StripPath)>,
|
||||||
|
) -> Result<()> {
|
||||||
|
let path = path.to_path();
|
||||||
|
let mut tx = user_db.begin(&authed).await?;
|
||||||
|
|
||||||
|
let hash: i64 = sqlx::query_scalar!(
|
||||||
|
"UPDATE script SET archived = true WHERE path = $1 AND workspace_id = $2 RETURNING hash",
|
||||||
|
path,
|
||||||
|
&w_id
|
||||||
|
)
|
||||||
|
.fetch_one(&db)
|
||||||
|
.await?;
|
||||||
|
audit_log(
|
||||||
|
&mut tx,
|
||||||
|
&authed.username,
|
||||||
|
"scripts.archive",
|
||||||
|
ActionKind::Delete,
|
||||||
|
&w_id,
|
||||||
|
Some(&ScriptHash(hash).to_string()),
|
||||||
|
Some([("workspace", w_id.as_str())].into()),
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
tx.commit().await?;
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn archive_script_by_hash(
|
||||||
|
authed: Authed,
|
||||||
|
Extension(user_db): Extension<UserDB>,
|
||||||
|
Path((w_id, hash)): Path<(String, ScriptHash)>,
|
||||||
|
) -> JsonResult<Script> {
|
||||||
|
let mut tx = user_db.begin(&authed).await?;
|
||||||
|
|
||||||
|
let script = sqlx::query_as::<_, Script>(
|
||||||
|
"UPDATE script SET archived = true WHERE hash = $1 RETURNING *",
|
||||||
|
)
|
||||||
|
.bind(&hash.0)
|
||||||
|
.fetch_one(&mut tx)
|
||||||
|
.await?;
|
||||||
|
audit_log(
|
||||||
|
&mut tx,
|
||||||
|
&authed.username,
|
||||||
|
"scripts.archive",
|
||||||
|
ActionKind::Delete,
|
||||||
|
&w_id,
|
||||||
|
Some(&hash.to_string()),
|
||||||
|
Some([("workspace", w_id.as_str())].into()),
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
tx.commit().await?;
|
||||||
|
|
||||||
|
Ok(Json(script))
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn delete_script_by_hash(
|
||||||
|
authed: Authed,
|
||||||
|
Extension(user_db): Extension<UserDB>,
|
||||||
|
Extension(db): Extension<DB>,
|
||||||
|
Path((w_id, hash)): Path<(String, ScriptHash)>,
|
||||||
|
) -> JsonResult<Script> {
|
||||||
|
let mut tx = user_db.begin(&authed).await?;
|
||||||
|
|
||||||
|
require_admin(authed.is_admin, &authed.username)?;
|
||||||
|
let script = sqlx::query_as::<_, Script>(
|
||||||
|
"UPDATE script SET content = '', archived = true, deleted = true WHERE hash = $1 AND workspace_id = $2\
|
||||||
|
RETURNING *",
|
||||||
|
)
|
||||||
|
.bind(&hash.0)
|
||||||
|
.bind(&w_id)
|
||||||
|
.fetch_one(&db)
|
||||||
|
.await?;
|
||||||
|
audit_log(
|
||||||
|
&mut tx,
|
||||||
|
&authed.username,
|
||||||
|
"scripts.delete",
|
||||||
|
ActionKind::Delete,
|
||||||
|
&w_id,
|
||||||
|
Some(&hash.to_string()),
|
||||||
|
Some([("workspace", w_id.as_str())].into()),
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
tx.commit().await?;
|
||||||
|
|
||||||
|
Ok(Json(script))
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn parse_code_to_jsonschema(
|
||||||
|
Json(code): Json<String>,
|
||||||
|
) -> JsonResult<parser::MainArgSignature> {
|
||||||
|
parser::parse_signature(&code).map(Json)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn to_i64(s: &str) -> Result<i64> {
|
||||||
|
let v = hex::decode(s)?;
|
||||||
|
let nb: u64 = u64::from_be_bytes(
|
||||||
|
v[0..8]
|
||||||
|
.try_into()
|
||||||
|
.map_err(|_| hex::FromHexError::InvalidStringLength)?,
|
||||||
|
);
|
||||||
|
Ok(nb as i64)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn to_hex_string(i: &i64) -> String {
|
||||||
|
hex::encode(i.to_be_bytes())
|
||||||
|
}
|
||||||
56
backend/src/static_assets.rs
Normal file
56
backend/src/static_assets.rs
Normal file
@@ -0,0 +1,56 @@
|
|||||||
|
/*
|
||||||
|
* Author & Copyright: Ruben Fiszel 2021
|
||||||
|
* This file and its contents are licensed under the AGPL License.
|
||||||
|
* Please see the included NOTICE for copyright information and
|
||||||
|
* LICENSE-AGPL for a copy of the license.
|
||||||
|
*/
|
||||||
|
|
||||||
|
use axum::{
|
||||||
|
body::{self, BoxBody},
|
||||||
|
http::{header, Response, Uri},
|
||||||
|
response::IntoResponse,
|
||||||
|
};
|
||||||
|
|
||||||
|
use rust_embed::RustEmbed;
|
||||||
|
|
||||||
|
// static_handler is a handler that serves static files from the
|
||||||
|
pub async fn static_handler(uri: Uri) -> impl IntoResponse {
|
||||||
|
let path = uri.path().trim_start_matches('/').to_string();
|
||||||
|
StaticFile(path)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(RustEmbed)]
|
||||||
|
#[folder = "../frontend/build/"]
|
||||||
|
struct Asset;
|
||||||
|
pub struct StaticFile<T>(pub T);
|
||||||
|
|
||||||
|
impl<T> IntoResponse for StaticFile<T>
|
||||||
|
where
|
||||||
|
T: Into<String>,
|
||||||
|
{
|
||||||
|
fn into_response(self) -> Response<BoxBody> {
|
||||||
|
let path = self.0.into();
|
||||||
|
serve_path(path)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn serve_path(path: String) -> Response<BoxBody> {
|
||||||
|
if path.starts_with("api/") {
|
||||||
|
return Response::builder()
|
||||||
|
.status(404)
|
||||||
|
.body(body::boxed(body::Empty::new()))
|
||||||
|
.unwrap();
|
||||||
|
}
|
||||||
|
match Asset::get(path.as_str()) {
|
||||||
|
Some(content) => {
|
||||||
|
let body = body::boxed(body::Full::from(content.data));
|
||||||
|
let mime = mime_guess::from_path(path).first_or_octet_stream();
|
||||||
|
Response::builder()
|
||||||
|
.header(header::CONTENT_TYPE, mime.as_ref())
|
||||||
|
.header(header::CACHE_CONTROL, "max-age=3600".to_owned())
|
||||||
|
.body(body)
|
||||||
|
.unwrap()
|
||||||
|
}
|
||||||
|
None => serve_path("200.html".to_owned()),
|
||||||
|
}
|
||||||
|
}
|
||||||
1411
backend/src/users.rs
Normal file
1411
backend/src/users.rs
Normal file
File diff suppressed because it is too large
Load Diff
93
backend/src/utils.rs
Normal file
93
backend/src/utils.rs
Normal file
@@ -0,0 +1,93 @@
|
|||||||
|
/*
|
||||||
|
* Author & Copyright: Ruben Fiszel 2021
|
||||||
|
* This file and its contents are licensed under the AGPL License.
|
||||||
|
* Please see the included NOTICE for copyright information and
|
||||||
|
* LICENSE-AGPL for a copy of the license.
|
||||||
|
*/
|
||||||
|
|
||||||
|
use rand::{distributions::Alphanumeric, thread_rng, Rng};
|
||||||
|
use serde::Deserialize;
|
||||||
|
use sqlx::{Postgres, Transaction};
|
||||||
|
|
||||||
|
use crate::error::{Error, Result};
|
||||||
|
|
||||||
|
pub const MAX_PER_PAGE: usize = 1000;
|
||||||
|
pub const DEFAULT_PER_PAGE: usize = 30;
|
||||||
|
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
pub struct Pagination {
|
||||||
|
pub page: Option<usize>,
|
||||||
|
pub per_page: Option<usize>,
|
||||||
|
}
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
pub struct StripPath(String);
|
||||||
|
|
||||||
|
impl StripPath {
|
||||||
|
pub fn to_path(&self) -> &str {
|
||||||
|
self.0.strip_prefix('/').unwrap()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn require_super_admin<'c>(
|
||||||
|
db: &mut Transaction<'c, Postgres>,
|
||||||
|
email: Option<String>,
|
||||||
|
) -> Result<()> {
|
||||||
|
let is_admin = sqlx::query_scalar!(
|
||||||
|
"SELECT super_admin FROM password WHERE email = $1",
|
||||||
|
email.as_ref()
|
||||||
|
)
|
||||||
|
.fetch_one(db)
|
||||||
|
.await?;
|
||||||
|
if !is_admin {
|
||||||
|
Err(Error::NotAuthorized(
|
||||||
|
"This endpoint require caller to be a super admin".to_owned(),
|
||||||
|
))
|
||||||
|
} else {
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn require_admin(is_admin: bool, username: &str) -> Result<()> {
|
||||||
|
if !is_admin {
|
||||||
|
Err(Error::NotAuthorized(format!(
|
||||||
|
"This endpoint require caller {} to be an admin",
|
||||||
|
username
|
||||||
|
)))
|
||||||
|
} else {
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn rd_string(len: usize) -> String {
|
||||||
|
thread_rng()
|
||||||
|
.sample_iter(&Alphanumeric)
|
||||||
|
.take(len)
|
||||||
|
.map(char::from)
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn paginate(pagination: Pagination) -> (usize, usize) {
|
||||||
|
let per_page = pagination
|
||||||
|
.per_page
|
||||||
|
.unwrap_or(DEFAULT_PER_PAGE)
|
||||||
|
.max(1)
|
||||||
|
.min(MAX_PER_PAGE);
|
||||||
|
let offset = (pagination.page.unwrap_or(1).max(1) - 1) * per_page;
|
||||||
|
(per_page, offset)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn not_found_if_none<T, U: AsRef<str>>(opt: Option<T>, kind: &str, name: U) -> Result<T> {
|
||||||
|
if let Some(o) = opt {
|
||||||
|
Ok(o)
|
||||||
|
} else {
|
||||||
|
Err(Error::NotFound(format!(
|
||||||
|
"{} not found at name {}",
|
||||||
|
kind,
|
||||||
|
name.as_ref()
|
||||||
|
)))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn get_owner_from_path(path: &str) -> String {
|
||||||
|
path.split('/').take(2).collect::<Vec<_>>().join("/")
|
||||||
|
}
|
||||||
365
backend/src/variables.rs
Normal file
365
backend/src/variables.rs
Normal file
@@ -0,0 +1,365 @@
|
|||||||
|
/*
|
||||||
|
* Author & Copyright: Ruben Fiszel 2021
|
||||||
|
* This file and its contents are licensed under the AGPL License.
|
||||||
|
* Please see the included NOTICE for copyright information and
|
||||||
|
* LICENSE-AGPL for a copy of the license.
|
||||||
|
*/
|
||||||
|
|
||||||
|
use crate::{
|
||||||
|
audit::{audit_log, ActionKind},
|
||||||
|
db::{UserDB, DB},
|
||||||
|
error::{Error, JsonResult, Result},
|
||||||
|
users::Authed,
|
||||||
|
utils::StripPath,
|
||||||
|
};
|
||||||
|
use axum::{
|
||||||
|
extract::{Extension, Path, Query},
|
||||||
|
routing::{delete, get, post},
|
||||||
|
Json, Router,
|
||||||
|
};
|
||||||
|
use hyper::StatusCode;
|
||||||
|
|
||||||
|
use magic_crypt::{MagicCrypt256, MagicCryptTrait};
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
use sqlx::{FromRow, Postgres, Transaction};
|
||||||
|
|
||||||
|
pub fn workspaced_service() -> Router {
|
||||||
|
Router::new()
|
||||||
|
.route("/list", get(list_variables))
|
||||||
|
.route("/list_contextual", get(list_contextual_variables))
|
||||||
|
.route("/get/*path", get(get_variable))
|
||||||
|
.route("/update/*path", post(update_variable))
|
||||||
|
.route("/delete/*path", delete(delete_variable))
|
||||||
|
.route("/create", post(create_variable))
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Serialize, Clone)]
|
||||||
|
|
||||||
|
pub struct ContextualVariable {
|
||||||
|
pub name: String,
|
||||||
|
pub value: String,
|
||||||
|
pub description: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Serialize, Deserialize, FromRow)]
|
||||||
|
|
||||||
|
pub struct ListableVariable {
|
||||||
|
pub workspace_id: String,
|
||||||
|
pub path: String,
|
||||||
|
pub value: Option<String>,
|
||||||
|
pub is_secret: bool,
|
||||||
|
pub description: String,
|
||||||
|
pub extra_perms: serde_json::Value,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
pub struct CreateVariable {
|
||||||
|
pub path: String,
|
||||||
|
pub value: String,
|
||||||
|
pub is_secret: bool,
|
||||||
|
pub description: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
struct EditVariable {
|
||||||
|
path: Option<String>,
|
||||||
|
value: Option<String>,
|
||||||
|
is_secret: Option<bool>,
|
||||||
|
description: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn get_reserved_variables(
|
||||||
|
w_id: &str,
|
||||||
|
token: &str,
|
||||||
|
email: &str,
|
||||||
|
username: &str,
|
||||||
|
job_id: &str,
|
||||||
|
) -> [ContextualVariable; 5] {
|
||||||
|
[
|
||||||
|
ContextualVariable {
|
||||||
|
name: "WM_WORKSPACE".to_string(),
|
||||||
|
value: w_id.to_string(),
|
||||||
|
description: "Workspace id of the current script".to_string()
|
||||||
|
},
|
||||||
|
ContextualVariable {
|
||||||
|
name: "WM_TOKEN".to_string(),
|
||||||
|
value: token.to_string(),
|
||||||
|
description: "Token ephemeral to the current script with equal permission to the permission of the run (Usable as a bearer token)".to_string()
|
||||||
|
},
|
||||||
|
ContextualVariable {
|
||||||
|
name: "WM_EMAIL".to_string(),
|
||||||
|
value: email.to_string(),
|
||||||
|
description: "Email of the user that executed the current script".to_string()
|
||||||
|
},
|
||||||
|
ContextualVariable {
|
||||||
|
name: "WM_USERNAME".to_string(),
|
||||||
|
value: username.to_string(),
|
||||||
|
description: "Username of the user that executed the current script".to_string()
|
||||||
|
},
|
||||||
|
ContextualVariable {
|
||||||
|
name: "WM_JOB_ID".to_string(),
|
||||||
|
value: job_id.to_string(),
|
||||||
|
description: "Job id of the current script".to_string()
|
||||||
|
},
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn list_contextual_variables(
|
||||||
|
Path(w_id): Path<String>,
|
||||||
|
Authed {
|
||||||
|
username, email, ..
|
||||||
|
}: Authed,
|
||||||
|
) -> JsonResult<Vec<ContextualVariable>> {
|
||||||
|
Ok(Json(
|
||||||
|
get_reserved_variables(
|
||||||
|
&w_id,
|
||||||
|
"q1A0qcPuO00yxioll7iph76N9CJDqn",
|
||||||
|
&email.unwrap_or_else(|| "no email".to_string()),
|
||||||
|
&username,
|
||||||
|
"017e0ad5-f499-73b6-5488-92a61c5196dd",
|
||||||
|
)
|
||||||
|
.to_vec(),
|
||||||
|
))
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn list_variables(
|
||||||
|
authed: Authed,
|
||||||
|
Extension(user_db): Extension<UserDB>,
|
||||||
|
Path(w_id): Path<String>,
|
||||||
|
) -> JsonResult<Vec<ListableVariable>> {
|
||||||
|
let mut tx = user_db.begin(&authed).await?;
|
||||||
|
|
||||||
|
let rows = sqlx::query_as::<_, ListableVariable>(
|
||||||
|
"SELECT workspace_id, path, CASE WHEN is_secret IS TRUE THEN null ELSE value::text END as value, is_secret, description, extra_perms from variable
|
||||||
|
WHERE (workspace_id = $1 OR (is_secret IS NOT TRUE AND workspace_id = 'starter')) ORDER BY path",
|
||||||
|
)
|
||||||
|
.bind(&w_id)
|
||||||
|
.fetch_all(&mut tx)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
tx.commit().await?;
|
||||||
|
Ok(Json(rows))
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
struct GetVariableQuery {
|
||||||
|
decrypt_secret: Option<bool>,
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn get_variable(
|
||||||
|
authed: Authed,
|
||||||
|
Extension(user_db): Extension<UserDB>,
|
||||||
|
Query(q): Query<GetVariableQuery>,
|
||||||
|
Path((w_id, path)): Path<(String, StripPath)>,
|
||||||
|
) -> JsonResult<ListableVariable> {
|
||||||
|
let path = path.to_path();
|
||||||
|
let mut tx = user_db.begin(&authed).await?;
|
||||||
|
|
||||||
|
let variable_o = sqlx::query_as::<_, ListableVariable>(
|
||||||
|
"SELECT * from variable WHERE path = $1 AND (workspace_id = $2 OR (is_secret IS NOT TRUE AND workspace_id = 'starter'))",
|
||||||
|
)
|
||||||
|
.bind(&path)
|
||||||
|
.bind(&w_id)
|
||||||
|
.fetch_optional(&mut tx)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
let variable = crate::utils::not_found_if_none(variable_o, "Variable", &path)?;
|
||||||
|
|
||||||
|
let decrypt_secret = q.decrypt_secret.unwrap_or(true);
|
||||||
|
|
||||||
|
let r = if variable.is_secret {
|
||||||
|
audit_log(
|
||||||
|
&mut tx,
|
||||||
|
&authed.username,
|
||||||
|
"variables.decrypt_secret",
|
||||||
|
ActionKind::Execute,
|
||||||
|
&w_id,
|
||||||
|
Some(&variable.path),
|
||||||
|
None,
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
let value = variable.value.unwrap_or_else(|| "".to_string());
|
||||||
|
ListableVariable {
|
||||||
|
value: if !value.is_empty() && decrypt_secret {
|
||||||
|
let mc = build_crypt(&mut tx, &w_id).await?;
|
||||||
|
Some(
|
||||||
|
mc.decrypt_base64_to_string(value)
|
||||||
|
.map_err(|e| Error::InternalErr(e.to_string()))?,
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
},
|
||||||
|
..variable
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
variable
|
||||||
|
};
|
||||||
|
tx.commit().await?;
|
||||||
|
|
||||||
|
Ok(Json(r))
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn create_variable(
|
||||||
|
authed: Authed,
|
||||||
|
Extension(user_db): Extension<UserDB>,
|
||||||
|
Path(w_id): Path<String>,
|
||||||
|
Json(variable): Json<CreateVariable>,
|
||||||
|
) -> Result<(StatusCode, String)> {
|
||||||
|
let mut tx = user_db.begin(&authed).await?;
|
||||||
|
|
||||||
|
let value = if variable.is_secret {
|
||||||
|
let mc = build_crypt(&mut tx, &w_id).await?;
|
||||||
|
encrypt(&mc, variable.value)
|
||||||
|
} else {
|
||||||
|
variable.value
|
||||||
|
};
|
||||||
|
|
||||||
|
sqlx::query!(
|
||||||
|
"INSERT INTO variable
|
||||||
|
(workspace_id, path, value, is_secret, description)
|
||||||
|
VALUES ($1, $2, $3, $4, $5)",
|
||||||
|
&w_id,
|
||||||
|
variable.path,
|
||||||
|
value,
|
||||||
|
variable.is_secret,
|
||||||
|
variable.description
|
||||||
|
)
|
||||||
|
.execute(&mut tx)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
audit_log(
|
||||||
|
&mut tx,
|
||||||
|
&authed.username,
|
||||||
|
"variables.create",
|
||||||
|
ActionKind::Create,
|
||||||
|
&w_id,
|
||||||
|
Some(&variable.path),
|
||||||
|
None,
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
tx.commit().await?;
|
||||||
|
|
||||||
|
Ok((
|
||||||
|
StatusCode::CREATED,
|
||||||
|
format!("variable {} created", variable.path),
|
||||||
|
))
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn delete_variable(
|
||||||
|
authed: Authed,
|
||||||
|
Extension(user_db): Extension<UserDB>,
|
||||||
|
Extension(db): Extension<DB>,
|
||||||
|
Path((w_id, path)): Path<(String, StripPath)>,
|
||||||
|
) -> Result<String> {
|
||||||
|
let path = path.to_path();
|
||||||
|
let mut tx = user_db.begin(&authed).await?;
|
||||||
|
|
||||||
|
sqlx::query!(
|
||||||
|
"DELETE FROM variable WHERE path = $1 AND workspace_id = $2",
|
||||||
|
path,
|
||||||
|
w_id
|
||||||
|
)
|
||||||
|
.execute(&db)
|
||||||
|
.await?;
|
||||||
|
audit_log(
|
||||||
|
&mut tx,
|
||||||
|
&authed.username,
|
||||||
|
"variables.delete",
|
||||||
|
ActionKind::Delete,
|
||||||
|
&w_id,
|
||||||
|
Some(path),
|
||||||
|
None,
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
tx.commit().await?;
|
||||||
|
|
||||||
|
Ok(format!("variable {} deleted", path))
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn update_variable(
|
||||||
|
authed: Authed,
|
||||||
|
Extension(user_db): Extension<UserDB>,
|
||||||
|
Extension(db): Extension<DB>,
|
||||||
|
Path((w_id, path)): Path<(String, StripPath)>,
|
||||||
|
Json(ns): Json<EditVariable>,
|
||||||
|
) -> Result<String> {
|
||||||
|
use sql_builder::prelude::*;
|
||||||
|
|
||||||
|
let path = path.to_path();
|
||||||
|
|
||||||
|
let mut tx = user_db.begin(&authed).await?;
|
||||||
|
|
||||||
|
let mut sqlb = SqlBuilder::update_table("variable");
|
||||||
|
sqlb.and_where_eq("path", "?".bind(&path));
|
||||||
|
sqlb.and_where_eq("workspace_id", "?".bind(&w_id));
|
||||||
|
|
||||||
|
if let Some(npath) = &ns.path {
|
||||||
|
sqlb.set_str("path", npath);
|
||||||
|
}
|
||||||
|
if let Some(nvalue) = ns.value {
|
||||||
|
let is_secret = sqlx::query_scalar!(
|
||||||
|
"SELECT is_secret from variable WHERE path = $1 AND workspace_id = $2",
|
||||||
|
&path,
|
||||||
|
&w_id
|
||||||
|
)
|
||||||
|
.fetch_optional(&mut tx)
|
||||||
|
.await?
|
||||||
|
.unwrap_or(false);
|
||||||
|
|
||||||
|
let value = if is_secret {
|
||||||
|
let mc = build_crypt(&mut tx, &w_id).await?;
|
||||||
|
encrypt(&mc, nvalue)
|
||||||
|
} else {
|
||||||
|
nvalue
|
||||||
|
};
|
||||||
|
sqlb.set_str("value", &value);
|
||||||
|
}
|
||||||
|
|
||||||
|
if let Some(desc) = ns.description {
|
||||||
|
sqlb.set_str("description", &desc);
|
||||||
|
}
|
||||||
|
|
||||||
|
if let Some(nbool) = ns.is_secret {
|
||||||
|
if !nbool {
|
||||||
|
return Err(Error::BadRequest(
|
||||||
|
"A variable can not be updated to be non secret".to_owned(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
sqlb.set_str("is_secret", nbool);
|
||||||
|
}
|
||||||
|
let sql = sqlb.sql().map_err(|e| Error::InternalErr(e.to_string()))?;
|
||||||
|
|
||||||
|
sqlx::query(&sql).execute(&db).await?;
|
||||||
|
audit_log(
|
||||||
|
&mut tx,
|
||||||
|
&authed.username,
|
||||||
|
"variables.update",
|
||||||
|
ActionKind::Update,
|
||||||
|
&w_id,
|
||||||
|
Some(path),
|
||||||
|
None,
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
tx.commit().await?;
|
||||||
|
|
||||||
|
Ok(format!("variable {} updated (npath: {:?})", path, ns.path))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn build_crypt<'c>(
|
||||||
|
db: &mut Transaction<'c, Postgres>,
|
||||||
|
w_id: &str,
|
||||||
|
) -> Result<MagicCrypt256> {
|
||||||
|
let key = sqlx::query_scalar!(
|
||||||
|
"SELECT key FROM workspace_key WHERE workspace_id = $1 AND kind = 'cloud'",
|
||||||
|
w_id
|
||||||
|
)
|
||||||
|
.fetch_one(db)
|
||||||
|
.await?;
|
||||||
|
Ok(magic_crypt::new_magic_crypt!(key, 256))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn encrypt(mc: &MagicCrypt256, value: String) -> String {
|
||||||
|
mc.encrypt_str_to_base64(value)
|
||||||
|
}
|
||||||
726
backend/src/worker.rs
Normal file
726
backend/src/worker.rs
Normal file
@@ -0,0 +1,726 @@
|
|||||||
|
/*
|
||||||
|
* Author & Copyright: Ruben Fiszel 2021
|
||||||
|
* This file and its contents are licensed under the AGPL License.
|
||||||
|
* Please see the included NOTICE for copyright information and
|
||||||
|
* LICENSE-AGPL for a copy of the license.
|
||||||
|
*/
|
||||||
|
|
||||||
|
use itertools::Itertools;
|
||||||
|
use std::{
|
||||||
|
process::{ExitStatus, Stdio},
|
||||||
|
sync::{
|
||||||
|
atomic::{AtomicBool, Ordering},
|
||||||
|
Arc,
|
||||||
|
},
|
||||||
|
time::Duration,
|
||||||
|
};
|
||||||
|
|
||||||
|
use crate::{
|
||||||
|
db::DB,
|
||||||
|
error::Error,
|
||||||
|
jobs::{
|
||||||
|
add_completed_job, add_completed_job_error, handle_flow, postprocess_queued_job, pull,
|
||||||
|
update_flow_status_after_job_completion, update_flow_status_in_progress, JobKind,
|
||||||
|
QueuedJob,
|
||||||
|
},
|
||||||
|
parser::{self, Typ},
|
||||||
|
scripts::ScriptHash,
|
||||||
|
users::{create_token_for_owner, get_email_from_username},
|
||||||
|
variables,
|
||||||
|
};
|
||||||
|
|
||||||
|
use serde_json::{json, Map, Value};
|
||||||
|
|
||||||
|
use tokio::{
|
||||||
|
fs::{DirBuilder, File},
|
||||||
|
io::{AsyncBufReadExt, AsyncReadExt, AsyncWriteExt, BufReader},
|
||||||
|
process::{Child, Command},
|
||||||
|
sync::Mutex,
|
||||||
|
time::Instant,
|
||||||
|
};
|
||||||
|
|
||||||
|
use async_recursion::async_recursion;
|
||||||
|
use tokio::sync::mpsc;
|
||||||
|
|
||||||
|
const TMP_DIR: &str = "/tmp/windmill";
|
||||||
|
const PIP_CACHE_DIR: &str = "/tmp/windmill/cache/pip";
|
||||||
|
const NUM_SECS_ENV_CHECK: u64 = 15;
|
||||||
|
|
||||||
|
const INCLUDE_DEPS_SH_CONTENT: &str = include_str!("../../nsjail/download_deps.sh");
|
||||||
|
const NSJAIL_CONFIG_DOWNLOAD_CONTENT: &str = include_str!("../../nsjail/download.config.proto");
|
||||||
|
const NSJAIL_CONFIG_RUN_CONTENT: &str = include_str!("../../nsjail/run.config.proto");
|
||||||
|
|
||||||
|
pub async fn run_worker(
|
||||||
|
db: &DB,
|
||||||
|
timeout: i32,
|
||||||
|
worker_instance: &str,
|
||||||
|
worker_name: String,
|
||||||
|
i_worker: u64,
|
||||||
|
num_workers: u64,
|
||||||
|
_mutex: Arc<Mutex<i32>>,
|
||||||
|
ip: &str,
|
||||||
|
sleep_queue: u64,
|
||||||
|
base_url: &str,
|
||||||
|
tx: tokio::sync::broadcast::Sender<()>,
|
||||||
|
) {
|
||||||
|
let worker_dir = format!("{TMP_DIR}/{worker_name}");
|
||||||
|
tracing::debug!(worker_dir = %worker_dir, worker_name = %worker_name, "Creating worker dir");
|
||||||
|
|
||||||
|
DirBuilder::new()
|
||||||
|
.recursive(true)
|
||||||
|
.create(&worker_dir)
|
||||||
|
.await
|
||||||
|
.expect("could not create initial worker dir");
|
||||||
|
|
||||||
|
DirBuilder::new()
|
||||||
|
.recursive(true)
|
||||||
|
.create(&PIP_CACHE_DIR)
|
||||||
|
.await
|
||||||
|
.expect("could not create initial worker dir");
|
||||||
|
|
||||||
|
let _ = write_file(&worker_dir, "download_deps.sh", INCLUDE_DEPS_SH_CONTENT).await;
|
||||||
|
|
||||||
|
let mut last_ping = Instant::now() - Duration::from_secs(NUM_SECS_ENV_CHECK + 1);
|
||||||
|
|
||||||
|
insert_initial_ping(worker_instance, &worker_name, ip, db).await;
|
||||||
|
|
||||||
|
let mut jobs_executed = 0;
|
||||||
|
let mut rx = tx.subscribe();
|
||||||
|
loop {
|
||||||
|
if last_ping.elapsed().as_secs() > NUM_SECS_ENV_CHECK {
|
||||||
|
sqlx::query!(
|
||||||
|
"UPDATE worker_ping SET ping_at = $1, jobs_executed = $2 WHERE worker = $3",
|
||||||
|
chrono::Utc::now(),
|
||||||
|
jobs_executed,
|
||||||
|
&worker_name
|
||||||
|
)
|
||||||
|
.execute(db)
|
||||||
|
.await
|
||||||
|
.expect("update worker ping");
|
||||||
|
|
||||||
|
last_ping = Instant::now();
|
||||||
|
}
|
||||||
|
|
||||||
|
match pull(db).await {
|
||||||
|
Ok(Some(job)) => {
|
||||||
|
jobs_executed += 1;
|
||||||
|
|
||||||
|
tracing::info!(worker = %worker_name, id = %job.id, "Fetched job");
|
||||||
|
let job2 = job.clone();
|
||||||
|
if let Some(err) =
|
||||||
|
handle_queued_job(job, db, timeout, &worker_name, &worker_dir, base_url)
|
||||||
|
.await
|
||||||
|
.err()
|
||||||
|
{
|
||||||
|
let err_string = err.to_string().clone();
|
||||||
|
let _ = add_completed_job_error(
|
||||||
|
db,
|
||||||
|
&job2,
|
||||||
|
"Unexpected error during job execution:\n".to_string(),
|
||||||
|
err,
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
|
||||||
|
let _ =
|
||||||
|
postprocess_queued_job(job2.schedule_path, &job2.workspace_id, job2.id, db)
|
||||||
|
.await;
|
||||||
|
tracing::error!(job_id = %job2.id, "Error handling job: {err_string}");
|
||||||
|
};
|
||||||
|
}
|
||||||
|
Ok(None) => (),
|
||||||
|
Err(err) => {
|
||||||
|
tracing::error!(worker = %worker_name, "run_worker: pulling jobs: {}", err);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
tokio::select! {
|
||||||
|
_ = tokio::time::sleep(Duration::from_millis(sleep_queue * num_workers)) => (),
|
||||||
|
_ = rx.recv() => {
|
||||||
|
println!("received killpill for worker {}", i_worker);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn insert_initial_ping(worker_instance: &str, worker_name: &str, ip: &str, db: &DB) {
|
||||||
|
sqlx::query!(
|
||||||
|
"INSERT INTO worker_ping (worker_instance, worker, ip) VALUES ($1, $2, $3)",
|
||||||
|
worker_instance,
|
||||||
|
worker_name,
|
||||||
|
ip
|
||||||
|
)
|
||||||
|
.execute(db)
|
||||||
|
.await
|
||||||
|
.expect("insert worker_ping initial value");
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn handle_queued_job(
|
||||||
|
job: QueuedJob,
|
||||||
|
db: &sqlx::Pool<sqlx::Postgres>,
|
||||||
|
timeout: i32,
|
||||||
|
worker_name: &str,
|
||||||
|
worker_dir: &str,
|
||||||
|
base_url: &str,
|
||||||
|
) -> crate::error::Result<()> {
|
||||||
|
let job_id = job.id;
|
||||||
|
let w_id = &job.workspace_id.clone();
|
||||||
|
|
||||||
|
match job.job_kind {
|
||||||
|
JobKind::FlowPreview | JobKind::Flow => {
|
||||||
|
let args = match &job.args {
|
||||||
|
Some(serde_json::Value::Object(m)) => Some(m.to_owned()),
|
||||||
|
_ => None,
|
||||||
|
};
|
||||||
|
handle_flow(&job, db, args).await?;
|
||||||
|
}
|
||||||
|
_ => {
|
||||||
|
let mut logs = "".to_string();
|
||||||
|
let mut last_line = "{}".to_string();
|
||||||
|
|
||||||
|
if job.is_flow_step {
|
||||||
|
update_flow_status_in_progress(
|
||||||
|
db,
|
||||||
|
&job.workspace_id,
|
||||||
|
job.parent_job
|
||||||
|
.ok_or_else(|| Error::InternalErr(format!("expected parent job")))?,
|
||||||
|
job.id,
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
}
|
||||||
|
|
||||||
|
let execution = handle_job(
|
||||||
|
&job,
|
||||||
|
db,
|
||||||
|
timeout,
|
||||||
|
worker_name,
|
||||||
|
worker_dir,
|
||||||
|
&mut logs,
|
||||||
|
&mut last_line,
|
||||||
|
base_url,
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
|
||||||
|
match execution {
|
||||||
|
Ok(r) => {
|
||||||
|
add_completed_job(db, &job, true, r.result.clone(), logs).await?;
|
||||||
|
if job.is_flow_step {
|
||||||
|
update_flow_status_after_job_completion(db, &job, true, r.result).await?;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
let (_, output_map) = add_completed_job_error(db, &job, logs, e).await?;
|
||||||
|
if job.is_flow_step {
|
||||||
|
update_flow_status_after_job_completion(db, &job, false, Some(output_map))
|
||||||
|
.await?;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
let _ = postprocess_queued_job(job.schedule_path, &w_id, job_id, db).await;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
struct JobResult {
|
||||||
|
result: Option<Map<String, Value>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn write_file(dir: &str, path: &str, content: &str) -> Result<File, Error> {
|
||||||
|
let path = format!("{}/{}", dir, path);
|
||||||
|
let mut file = File::create(&path).await?;
|
||||||
|
file.write_all(content.as_bytes()).await?;
|
||||||
|
Ok(file)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[async_recursion]
|
||||||
|
async fn transform_json_value(token: &str, workspace: &str, base_url: &str, v: Value) -> Value {
|
||||||
|
match v {
|
||||||
|
Value::String(y) if y.starts_with("$var:") => {
|
||||||
|
let path = y.strip_prefix("$var:").unwrap();
|
||||||
|
let v = crate::client::get_variable(workspace, path, token, base_url)
|
||||||
|
.await
|
||||||
|
.unwrap_or_else(|_| format!("error fetching variable {path}"));
|
||||||
|
Value::String(v)
|
||||||
|
}
|
||||||
|
Value::String(y) if y.starts_with("$res:") => {
|
||||||
|
let path = y.strip_prefix("$res:").unwrap();
|
||||||
|
let v = crate::client::get_resource(workspace, path, token, base_url)
|
||||||
|
.await
|
||||||
|
.ok()
|
||||||
|
.flatten()
|
||||||
|
.unwrap_or_else(|| Value::String(format!("error fetching resource {path}")));
|
||||||
|
transform_json_value(token, workspace, base_url, v).await
|
||||||
|
}
|
||||||
|
Value::Object(mut m) => {
|
||||||
|
for (a, b) in m.clone().into_iter() {
|
||||||
|
m.insert(a, transform_json_value(token, workspace, base_url, b).await);
|
||||||
|
}
|
||||||
|
Value::Object(m)
|
||||||
|
}
|
||||||
|
a @ _ => a,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[allow(clippy::too_many_arguments)]
|
||||||
|
async fn handle_job(
|
||||||
|
job: &QueuedJob,
|
||||||
|
db: &DB,
|
||||||
|
timeout: i32,
|
||||||
|
worker_name: &str,
|
||||||
|
worker_dir: &str,
|
||||||
|
mut logs: &mut String,
|
||||||
|
mut last_line: &mut String,
|
||||||
|
base_url: &str,
|
||||||
|
) -> Result<JobResult, Error> {
|
||||||
|
tracing::info!(
|
||||||
|
worker = %worker_name,
|
||||||
|
job_id = %job.id,
|
||||||
|
"handling job"
|
||||||
|
);
|
||||||
|
|
||||||
|
logs.push_str(&format!("job {} on worker {}\n", &job.id, &worker_name));
|
||||||
|
let job_dir = format!("{worker_dir}/{}", job.id);
|
||||||
|
DirBuilder::new()
|
||||||
|
.recursive(true)
|
||||||
|
.create(&format!("{job_dir}/dependencies"))
|
||||||
|
.await
|
||||||
|
.expect("could not create initial job dir");
|
||||||
|
|
||||||
|
let mut status: Result<ExitStatus, Error>;
|
||||||
|
if matches!(job.job_kind, JobKind::Dependencies) {
|
||||||
|
let requirements = job
|
||||||
|
.raw_code
|
||||||
|
.as_ref()
|
||||||
|
.ok_or_else(|| Error::ExecutionErr("missing requirements".to_string()))?;
|
||||||
|
logs.push_str(&format!("content of requirements:\n{}\n", &requirements));
|
||||||
|
|
||||||
|
let file = "requirements.in";
|
||||||
|
write_file(&job_dir, file, &requirements).await?;
|
||||||
|
|
||||||
|
let child = Command::new("pip-compile")
|
||||||
|
.current_dir(&job_dir)
|
||||||
|
.args(vec!["-q", "--no-header", file])
|
||||||
|
.stdout(Stdio::piped())
|
||||||
|
.stderr(Stdio::piped())
|
||||||
|
.spawn()?;
|
||||||
|
|
||||||
|
status = handle_child(job, db, &mut logs, &mut last_line, timeout, child).await;
|
||||||
|
|
||||||
|
if status.is_ok() && status.as_ref().unwrap().success() {
|
||||||
|
let path_lock = format!("{}/requirements.txt", job_dir);
|
||||||
|
let mut file = File::open(path_lock).await?;
|
||||||
|
|
||||||
|
let mut content = "".to_string();
|
||||||
|
file.read_to_string(&mut content).await?;
|
||||||
|
content = content
|
||||||
|
.lines()
|
||||||
|
.filter(|x| !x.trim_start().starts_with('#'))
|
||||||
|
.map(|x| x.to_string())
|
||||||
|
.collect::<Vec<String>>()
|
||||||
|
.join("\n");
|
||||||
|
let as_json = json!(content);
|
||||||
|
|
||||||
|
*last_line =
|
||||||
|
format!(r#"{{ "success": "Successful lock file generation", "lock": {as_json} }}"#);
|
||||||
|
|
||||||
|
sqlx::query!(
|
||||||
|
"UPDATE script SET lock = $1 WHERE hash = $2 AND workspace_id = $3",
|
||||||
|
&content,
|
||||||
|
&job.script_hash.unwrap_or(ScriptHash(0)).0,
|
||||||
|
&job.workspace_id
|
||||||
|
)
|
||||||
|
.execute(db)
|
||||||
|
.await?;
|
||||||
|
} else {
|
||||||
|
sqlx::query!(
|
||||||
|
"UPDATE script SET lock_error_logs = $1 WHERE hash = $2 AND workspace_id = $3",
|
||||||
|
&logs.clone(),
|
||||||
|
&job.script_hash.unwrap_or(ScriptHash(0)).0,
|
||||||
|
&job.workspace_id
|
||||||
|
)
|
||||||
|
.execute(db)
|
||||||
|
.await?;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
let (inner_content, requirements_o) = if matches!(job.job_kind, JobKind::Preview) {
|
||||||
|
let code = (job.raw_code.as_ref().unwrap_or(&"no raw code".to_owned())).to_owned();
|
||||||
|
let reqs = parser::parse_imports(&code)?.join("\n");
|
||||||
|
(code, Some(reqs))
|
||||||
|
} else {
|
||||||
|
sqlx::query_as::<_, (String, Option<String>)>("SELECT content, lock FROM script WHERE hash = $1 AND (workspace_id = $2 OR workspace_id = 'starter')")
|
||||||
|
.bind(&job.script_hash.unwrap_or(ScriptHash(0)).0)
|
||||||
|
.bind(&job.workspace_id)
|
||||||
|
.fetch_optional(db)
|
||||||
|
.await?
|
||||||
|
.ok_or_else(|| Error::InternalErr(format!("expected content and lock")))?
|
||||||
|
};
|
||||||
|
|
||||||
|
let requirements =
|
||||||
|
requirements_o.ok_or_else(|| Error::InternalErr(format!("lockfile missing")))?;
|
||||||
|
|
||||||
|
let _ = write_file(
|
||||||
|
&job_dir,
|
||||||
|
"download.config.proto",
|
||||||
|
&NSJAIL_CONFIG_DOWNLOAD_CONTENT
|
||||||
|
.replace("{JOB_DIR}", &job_dir)
|
||||||
|
.replace("{WORKER_DIR}", &worker_dir)
|
||||||
|
.replace("{CACHE_DIR}", PIP_CACHE_DIR),
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
let _ = write_file(&job_dir, "requirements.txt", &requirements).await?;
|
||||||
|
|
||||||
|
let child = Command::new("nsjail")
|
||||||
|
.current_dir(&job_dir)
|
||||||
|
.args(vec!["--config", "download.config.proto"])
|
||||||
|
.stdout(Stdio::piped())
|
||||||
|
.stderr(Stdio::piped())
|
||||||
|
.spawn()?;
|
||||||
|
|
||||||
|
logs.push_str("\n--- DEPENDENCIES INSTALL ---\n");
|
||||||
|
status = handle_child(job, db, &mut logs, &mut last_line, timeout, child).await;
|
||||||
|
if status.is_ok() {
|
||||||
|
logs.push_str("\n\n--- CODE EXECUTION ---\n");
|
||||||
|
|
||||||
|
set_logs(logs, job.id, db).await;
|
||||||
|
|
||||||
|
let _ = write_file(&job_dir, "inner.py", &inner_content).await?;
|
||||||
|
|
||||||
|
let sig = crate::parser::parse_signature(&inner_content)?;
|
||||||
|
let transforms = sig.args.into_iter().map(|x| match x.typ {
|
||||||
|
Typ::Bytes => format!("if \"{}\" in kwargs and kwargs[\"{}\"] is not None:\n kwargs[\"{}\"] = base64.b64decode(kwargs[\"{}\"])\n", x.name, x.name, x.name, x.name),
|
||||||
|
Typ::Datetime => format!("if \"{}\" in kwargs and kwargs[\"{}\"] is not None:\n kwargs[\"{}\"] = datetime.strptime(kwargs[\"{}\"], '%Y-%m-%dT%H:%M')\n", x.name, x.name, x.name, x.name),
|
||||||
|
_ => "".to_string()
|
||||||
|
}).collect::<Vec<String>>().join("");
|
||||||
|
|
||||||
|
let tx = db.begin().await?;
|
||||||
|
|
||||||
|
let token = create_token_for_owner(
|
||||||
|
&db,
|
||||||
|
&job.workspace_id,
|
||||||
|
&job.permissioned_as,
|
||||||
|
crate::users::NewToken {
|
||||||
|
label: Some("ephemeral-script".to_string()),
|
||||||
|
expiration: Some(
|
||||||
|
chrono::Utc::now() + chrono::Duration::seconds((timeout * 2).into()),
|
||||||
|
),
|
||||||
|
},
|
||||||
|
&job.created_by,
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
let args = if let Some(args) = &job.args {
|
||||||
|
Some(transform_json_value(&token, &job.workspace_id, base_url, args.clone()).await)
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
};
|
||||||
|
let ser_args = serde_json::to_string(&args)
|
||||||
|
.map_err(|e| Error::ExecutionErr(e.to_string()))?
|
||||||
|
.replace("\\\"", "\\\\\"");
|
||||||
|
let wrapper_content: String = format!(
|
||||||
|
r#"
|
||||||
|
import json
|
||||||
|
import base64
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
inner_script = __import__("inner")
|
||||||
|
|
||||||
|
kwargs = json.loads("""{ser_args}""", strict=False)
|
||||||
|
for k, v in kwargs.items():
|
||||||
|
if v == '<function call>':
|
||||||
|
kwargs[k] = None
|
||||||
|
{transforms}
|
||||||
|
res = inner_script.main(**kwargs)
|
||||||
|
if res is None:
|
||||||
|
res = {{}}
|
||||||
|
if isinstance(res, tuple):
|
||||||
|
res = {{f"res{{i+1}}": v for i, v in enumerate(res)}}
|
||||||
|
if not isinstance(res, dict):
|
||||||
|
res = {{ "res1": res }}
|
||||||
|
res_json = json.dumps(res, separators=(',', ':'), default=str).replace('\n', '')
|
||||||
|
print()
|
||||||
|
print("result:")
|
||||||
|
print(res_json)
|
||||||
|
"#,
|
||||||
|
);
|
||||||
|
write_file(&job_dir, "main.py", &wrapper_content).await?;
|
||||||
|
|
||||||
|
tx.commit().await?;
|
||||||
|
let reserved_variables = variables::get_reserved_variables(
|
||||||
|
&job.workspace_id,
|
||||||
|
&token,
|
||||||
|
&get_email_from_username(&job.created_by, db)
|
||||||
|
.await?
|
||||||
|
.unwrap_or_else(|| "nosuitable@email.xyz".to_string()),
|
||||||
|
&job.created_by,
|
||||||
|
&job.id.to_string(),
|
||||||
|
)
|
||||||
|
.into_iter()
|
||||||
|
.map(|rv| (rv.name, rv.value));
|
||||||
|
|
||||||
|
let _ = write_file(
|
||||||
|
&job_dir,
|
||||||
|
"run.config.proto",
|
||||||
|
&NSJAIL_CONFIG_RUN_CONTENT.replace("{JOB_DIR}", &job_dir),
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
let child = Command::new("nsjail")
|
||||||
|
.current_dir(&job_dir)
|
||||||
|
.envs(reserved_variables)
|
||||||
|
.args(vec![
|
||||||
|
"--config",
|
||||||
|
"run.config.proto",
|
||||||
|
"--",
|
||||||
|
"/usr/local/bin/python3",
|
||||||
|
"-u",
|
||||||
|
"/tmp/main.py",
|
||||||
|
])
|
||||||
|
.stdout(Stdio::piped())
|
||||||
|
.stderr(Stdio::piped())
|
||||||
|
.spawn()?;
|
||||||
|
status = handle_child(job, db, &mut logs, &mut last_line, timeout, child).await;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
tokio::fs::remove_dir_all(job_dir).await?;
|
||||||
|
|
||||||
|
if status.is_ok() && status.as_ref().unwrap().success() {
|
||||||
|
let result = serde_json::from_str::<Map<String, Value>>(last_line).map_err(|e| {
|
||||||
|
Error::ExecutionErr(format!(
|
||||||
|
"result {} is not parsable.\n err: {}",
|
||||||
|
last_line,
|
||||||
|
e.to_string()
|
||||||
|
))
|
||||||
|
})?;
|
||||||
|
Ok(JobResult {
|
||||||
|
result: Some(result),
|
||||||
|
})
|
||||||
|
} else {
|
||||||
|
let err = match status {
|
||||||
|
Ok(_) => {
|
||||||
|
let s = format!(
|
||||||
|
"Error during execution of the script\nlast 5 logs lines:\n{}",
|
||||||
|
logs.lines()
|
||||||
|
.skip(logs.lines().count().max(5) - 5)
|
||||||
|
.join("\n")
|
||||||
|
);
|
||||||
|
logs.push_str("\n\n--- ERROR ---\n");
|
||||||
|
s
|
||||||
|
}
|
||||||
|
Err(err) => format!("error before termination: {err}"),
|
||||||
|
};
|
||||||
|
Err(Error::ExecutionErr(err))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn handle_child(
|
||||||
|
job: &QueuedJob,
|
||||||
|
db: &DB,
|
||||||
|
logs: &mut String,
|
||||||
|
last_line: &mut String,
|
||||||
|
timeout: i32,
|
||||||
|
mut child: Child,
|
||||||
|
) -> crate::error::Result<ExitStatus> {
|
||||||
|
let stderr = child
|
||||||
|
.stderr
|
||||||
|
.take()
|
||||||
|
.expect("child did not have a handle to stdout");
|
||||||
|
|
||||||
|
let stdout = child
|
||||||
|
.stdout
|
||||||
|
.take()
|
||||||
|
.expect("child did not have a handle to stdout");
|
||||||
|
|
||||||
|
let mut reader = BufReader::new(stdout).lines();
|
||||||
|
let mut stderr_reader = BufReader::new(stderr).lines();
|
||||||
|
|
||||||
|
let done = Arc::new(AtomicBool::new(false));
|
||||||
|
|
||||||
|
let done2 = done.clone();
|
||||||
|
let done3 = done.clone();
|
||||||
|
|
||||||
|
// Ensure the child process is spawned in the runtime so it can
|
||||||
|
// make progress on its own while we await for any output.
|
||||||
|
let handle = tokio::spawn(async move {
|
||||||
|
let inner_done = done2.clone();
|
||||||
|
let r: Result<ExitStatus, anyhow::Error> = tokio::select! {
|
||||||
|
r = child.wait() => {
|
||||||
|
inner_done.store(true, Ordering::Relaxed);
|
||||||
|
Ok(r?)
|
||||||
|
}
|
||||||
|
_ = async move {
|
||||||
|
loop {
|
||||||
|
if done2.load(Ordering::Relaxed) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
tokio::time::sleep(Duration::from_secs(5)).await;
|
||||||
|
}
|
||||||
|
} => {
|
||||||
|
child.kill().await?;
|
||||||
|
return Err(Error::ExecutionErr("execution interrupted (likely timeout or cancel)".to_string()).into())
|
||||||
|
}
|
||||||
|
};
|
||||||
|
r
|
||||||
|
});
|
||||||
|
|
||||||
|
let (tx, mut rx) = mpsc::channel::<String>(100);
|
||||||
|
let id = job.id;
|
||||||
|
|
||||||
|
tokio::spawn(async move {
|
||||||
|
loop {
|
||||||
|
let send = tokio::select! {
|
||||||
|
Ok(Some(out)) = reader.next_line() => {
|
||||||
|
tx.send(out).await
|
||||||
|
},
|
||||||
|
Ok(Some(err)) = stderr_reader.next_line() => tx.send(err).await,
|
||||||
|
else => {
|
||||||
|
break
|
||||||
|
},
|
||||||
|
};
|
||||||
|
if send.err().is_some() {
|
||||||
|
tracing::error!("error sending log line");
|
||||||
|
};
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
let db2 = db.clone();
|
||||||
|
|
||||||
|
tokio::spawn(async move {
|
||||||
|
while !&done3.load(Ordering::Relaxed) {
|
||||||
|
let q = sqlx::query!(
|
||||||
|
"UPDATE queue SET last_ping = $1 WHERE id = $2",
|
||||||
|
chrono::Utc::now(),
|
||||||
|
id
|
||||||
|
)
|
||||||
|
.execute(&db2)
|
||||||
|
.await;
|
||||||
|
|
||||||
|
if q.is_err() {
|
||||||
|
tracing::error!("error setting last ping for id {}", id);
|
||||||
|
}
|
||||||
|
|
||||||
|
tokio::time::sleep(Duration::from_secs(5)).await;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
let mut start = logs.chars().count();
|
||||||
|
|
||||||
|
loop {
|
||||||
|
tokio::select! {
|
||||||
|
_ = tokio::time::sleep(Duration::from_millis(500)) => {
|
||||||
|
let end = logs.chars().count();
|
||||||
|
|
||||||
|
let to_send = logs.chars().skip(start).collect::<String>();
|
||||||
|
|
||||||
|
if start != end {
|
||||||
|
concat_logs(&to_send, id, db).await;
|
||||||
|
start = end;
|
||||||
|
}
|
||||||
|
|
||||||
|
let canceled = sqlx::query_scalar!("SELECT canceled FROM queue WHERE id = $1", id)
|
||||||
|
.fetch_one(db)
|
||||||
|
.await
|
||||||
|
.map_err(|_| tracing::error!("error getting canceled for id {}", id));
|
||||||
|
|
||||||
|
if canceled.unwrap_or(false) {
|
||||||
|
tracing::info!("killed after cancel: {}", job.id);
|
||||||
|
done.store(true, Ordering::Relaxed);
|
||||||
|
}
|
||||||
|
|
||||||
|
let has_timeout = job
|
||||||
|
.started_at
|
||||||
|
.map(|sa| (chrono::Utc::now() - sa).num_seconds() > timeout as i64)
|
||||||
|
.unwrap_or(false);
|
||||||
|
|
||||||
|
if has_timeout {
|
||||||
|
let q = sqlx::query(&format!(
|
||||||
|
"UPDATE queue SET canceled = true, canceled_by = 'timeout', \
|
||||||
|
canceled_reason = 'duration > {}' WHERE id = $1",
|
||||||
|
timeout
|
||||||
|
))
|
||||||
|
.bind(id)
|
||||||
|
.execute(db)
|
||||||
|
.await;
|
||||||
|
|
||||||
|
if q.is_err() {
|
||||||
|
tracing::error!("error setting canceled for id {}", id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
nl = rx.recv() => {
|
||||||
|
if let Some(nl) = nl {
|
||||||
|
logs.push('\n');
|
||||||
|
logs.push_str(&nl);
|
||||||
|
*last_line = nl;
|
||||||
|
} else {
|
||||||
|
let to_send = logs.chars().skip(start).collect::<String>();
|
||||||
|
concat_logs(&to_send, id, db).await;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let status = handle
|
||||||
|
.await
|
||||||
|
.map_err(|e| Error::ExecutionErr(e.to_string()))??;
|
||||||
|
Ok(status)
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn set_logs(logs: &str, id: uuid::Uuid, db: &DB) {
|
||||||
|
if sqlx::query!(
|
||||||
|
"UPDATE queue SET logs = $1 WHERE id = $2",
|
||||||
|
logs.to_owned(),
|
||||||
|
id
|
||||||
|
)
|
||||||
|
.execute(db)
|
||||||
|
.await
|
||||||
|
.is_err()
|
||||||
|
{
|
||||||
|
tracing::error!("error updating logs for id {}", id)
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn concat_logs(logs: &str, id: uuid::Uuid, db: &DB) {
|
||||||
|
if sqlx::query!(
|
||||||
|
"UPDATE queue SET logs = concat(logs, $1::text) WHERE id = $2",
|
||||||
|
logs.to_owned(),
|
||||||
|
id
|
||||||
|
)
|
||||||
|
.execute(db)
|
||||||
|
.await
|
||||||
|
.is_err()
|
||||||
|
{
|
||||||
|
tracing::error!("error updating logs for id {}", id)
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn restart_zombie_jobs_periodically(
|
||||||
|
db: &DB,
|
||||||
|
timeout: i32,
|
||||||
|
mut rx: tokio::sync::broadcast::Receiver<()>,
|
||||||
|
) {
|
||||||
|
loop {
|
||||||
|
let restarted = sqlx::query_scalar!(
|
||||||
|
"UPDATE queue SET running = false WHERE last_ping < $1 RETURNING id",
|
||||||
|
chrono::Utc::now() - chrono::Duration::seconds(timeout as i64 * 2)
|
||||||
|
)
|
||||||
|
.fetch_all(db)
|
||||||
|
.await
|
||||||
|
.ok()
|
||||||
|
.unwrap_or_else(|| vec![]);
|
||||||
|
|
||||||
|
if restarted.len() > 0 {
|
||||||
|
tracing::info!("restarted zombie jobs {restarted:?}");
|
||||||
|
}
|
||||||
|
|
||||||
|
tokio::select! {
|
||||||
|
_ = tokio::time::sleep(Duration::from_secs(60)) => (),
|
||||||
|
_ = rx.recv() => {
|
||||||
|
println!("received killpill for monitor job");
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
51
backend/src/worker_ping.rs
Normal file
51
backend/src/worker_ping.rs
Normal file
@@ -0,0 +1,51 @@
|
|||||||
|
/*
|
||||||
|
* Author & Copyright: Ruben Fiszel 2021
|
||||||
|
* This file and its contents are licensed under the AGPL License.
|
||||||
|
* Please see the included NOTICE for copyright information and
|
||||||
|
* LICENSE-AGPL for a copy of the license.
|
||||||
|
*/
|
||||||
|
|
||||||
|
use crate::{db::UserDB, error::JsonResult, users::Authed, utils::Pagination};
|
||||||
|
use axum::{
|
||||||
|
extract::{Extension, Query},
|
||||||
|
routing::get,
|
||||||
|
Json, Router,
|
||||||
|
};
|
||||||
|
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
use sqlx::FromRow;
|
||||||
|
|
||||||
|
pub fn global_service() -> Router {
|
||||||
|
Router::new().route("/list", get(list_worker_pings))
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(FromRow, Serialize, Deserialize)]
|
||||||
|
struct WorkerPing {
|
||||||
|
worker: String,
|
||||||
|
worker_instance: String,
|
||||||
|
ping_at: chrono::DateTime<chrono::Utc>,
|
||||||
|
started_at: chrono::DateTime<chrono::Utc>,
|
||||||
|
ip: String,
|
||||||
|
jobs_executed: i32,
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn list_worker_pings(
|
||||||
|
authed: Authed,
|
||||||
|
Extension(user_db): Extension<UserDB>,
|
||||||
|
Query(pagination): Query<Pagination>,
|
||||||
|
) -> JsonResult<Vec<WorkerPing>> {
|
||||||
|
let mut tx = user_db.begin(&authed).await?;
|
||||||
|
|
||||||
|
let (per_page, offset) = crate::utils::paginate(pagination);
|
||||||
|
|
||||||
|
let rows = sqlx::query_as!(
|
||||||
|
WorkerPing,
|
||||||
|
"SELECT * FROM worker_ping ORDER BY ping_at desc LIMIT $1 OFFSET $2",
|
||||||
|
per_page as i64,
|
||||||
|
offset as i64
|
||||||
|
)
|
||||||
|
.fetch_all(&mut tx)
|
||||||
|
.await?;
|
||||||
|
tx.commit().await?;
|
||||||
|
Ok(Json(rows))
|
||||||
|
}
|
||||||
666
backend/src/workspaces.rs
Normal file
666
backend/src/workspaces.rs
Normal file
@@ -0,0 +1,666 @@
|
|||||||
|
/*
|
||||||
|
* Author & Copyright: Ruben Fiszel 2021
|
||||||
|
* This file and its contents are licensed under the AGPL License.
|
||||||
|
* Please see the included NOTICE for copyright information and
|
||||||
|
* LICENSE-AGPL for a copy of the license.
|
||||||
|
*/
|
||||||
|
|
||||||
|
use crate::{
|
||||||
|
db::{UserDB, DB},
|
||||||
|
error::{Error, JsonResult, Result},
|
||||||
|
users::{Authed, WorkspaceInvite}, utils::{require_admin, require_super_admin, Pagination}, audit::{audit_log, ActionKind}, scripts::{Script, Schema}, resources::{Resource, ResourceType}, flow::Flow, variables::ListableVariable,
|
||||||
|
};
|
||||||
|
use axum::{extract::{Extension, Path, Query}, routing::{get, post, delete}, Json, Router, response::{IntoResponse}, body::StreamBody};
|
||||||
|
|
||||||
|
use hyper::{StatusCode, header};
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
use sqlx::{FromRow};
|
||||||
|
use tempfile::TempDir;
|
||||||
|
use tokio::fs::File;
|
||||||
|
use tokio_util::io::ReaderStream;
|
||||||
|
|
||||||
|
pub fn workspaced_service() -> Router {
|
||||||
|
Router::new()
|
||||||
|
.route("/list_pending_invites", get(list_pending_invites))
|
||||||
|
.route("/update", post(edit_workspace))
|
||||||
|
.route("/delete", delete(delete_workspace))
|
||||||
|
.route("/invite_user", post(invite_user))
|
||||||
|
.route("/delete_invite", post(delete_invite))
|
||||||
|
.route("/get_settings", get(get_settings))
|
||||||
|
.route("/edit_slack_command", post(edit_slack_command))
|
||||||
|
.route("/tarball", get(tarball_workspace))
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn global_service() -> Router {
|
||||||
|
Router::new()
|
||||||
|
.route("/list_as_superadmin", get(list_workspaces_as_super_admin))
|
||||||
|
.route("/list", get(list_workspaces))
|
||||||
|
.route("/users", get(user_workspaces))
|
||||||
|
.route("/create", post(create_workspace))
|
||||||
|
.route("/exists", post(exists_workspace))
|
||||||
|
.route("/validate_username", post(validate_username))
|
||||||
|
.route("/validate_id", post(validate_id))
|
||||||
|
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(FromRow, Serialize)]
|
||||||
|
struct Workspace {
|
||||||
|
id: String,
|
||||||
|
name: String,
|
||||||
|
owner: String,
|
||||||
|
domain: Option<String>,
|
||||||
|
deleted: bool
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(FromRow, Serialize, Debug)]
|
||||||
|
pub struct WorkspaceSettings {
|
||||||
|
pub workspace_id: String,
|
||||||
|
pub slack_team_id: Option<String>,
|
||||||
|
pub slack_name: Option<String>,
|
||||||
|
pub slack_command_script: Option<String>
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
#[derive(sqlx::Type, Serialize, Deserialize, Debug)]
|
||||||
|
#[sqlx(type_name = "WORKSPACE_KEY_KIND", rename_all = "lowercase")]
|
||||||
|
pub enum WorkspaceKeyKind {
|
||||||
|
Cloud
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
struct EditCommandScript {
|
||||||
|
slack_command_script: Option<String>
|
||||||
|
}
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
struct CreateWorkspace {
|
||||||
|
id: String,
|
||||||
|
name: String,
|
||||||
|
username: String,
|
||||||
|
domain: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
struct EditWorkspace {
|
||||||
|
name: String,
|
||||||
|
owner: String,
|
||||||
|
domain: Option<String>
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
#[derive(Serialize)]
|
||||||
|
struct WorkspaceList {
|
||||||
|
pub email: String,
|
||||||
|
pub workspaces: Vec<UserWorkspace>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Serialize)]
|
||||||
|
struct UserWorkspace {
|
||||||
|
pub id: String,
|
||||||
|
pub name: String,
|
||||||
|
pub username: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
struct WorkspaceId {
|
||||||
|
pub id: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
struct ValidateUsername {
|
||||||
|
pub id: String,
|
||||||
|
pub username: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
pub struct NewWorkspaceInvite {
|
||||||
|
pub email: String,
|
||||||
|
pub is_admin: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
async fn list_pending_invites(
|
||||||
|
authed: Authed,
|
||||||
|
Extension(user_db): Extension<UserDB>,
|
||||||
|
Path(w_id): Path<String>,
|
||||||
|
) -> JsonResult<Vec<WorkspaceInvite>> {
|
||||||
|
require_admin(authed.is_admin, &authed.username)?;
|
||||||
|
let mut tx = user_db.begin(&authed).await?;
|
||||||
|
let rows = sqlx::query_as!(WorkspaceInvite, "SELECT * from workspace_invite WHERE workspace_id = $1", w_id)
|
||||||
|
.fetch_all(&mut tx)
|
||||||
|
.await?;
|
||||||
|
tx.commit().await?;
|
||||||
|
Ok(Json(rows))
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
async fn exists_workspace(
|
||||||
|
authed: Authed,
|
||||||
|
Extension(user_db): Extension<UserDB>,
|
||||||
|
Json(WorkspaceId { id }): Json<WorkspaceId>
|
||||||
|
) -> JsonResult<bool> {
|
||||||
|
let mut tx = user_db.begin(&authed).await?;
|
||||||
|
let exists = sqlx::query_scalar!(
|
||||||
|
"SELECT EXISTS(SELECT 1 FROM workspace WHERE workspace.id = $1)",
|
||||||
|
id)
|
||||||
|
.fetch_one(&mut tx)
|
||||||
|
.await?
|
||||||
|
.unwrap_or(false);
|
||||||
|
tx.commit().await?;
|
||||||
|
Ok(Json(exists))
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn list_workspaces(
|
||||||
|
authed: Authed,
|
||||||
|
Extension(user_db): Extension<UserDB>,
|
||||||
|
) -> JsonResult<Vec<Workspace>> {
|
||||||
|
let mut tx = user_db.begin(&authed).await?;
|
||||||
|
let workspaces = sqlx::query_as!(
|
||||||
|
Workspace,
|
||||||
|
"SELECT workspace.* FROM workspace, usr WHERE usr.workspace_id = workspace.id AND usr.email = $1 AND deleted = false",
|
||||||
|
authed.email.as_ref())
|
||||||
|
.fetch_all(&mut tx)
|
||||||
|
.await?;
|
||||||
|
tx.commit().await?;
|
||||||
|
Ok(Json(workspaces))
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
async fn get_settings(
|
||||||
|
authed: Authed,
|
||||||
|
Path(w_id): Path<String>,
|
||||||
|
Extension(user_db): Extension<UserDB>,
|
||||||
|
) -> JsonResult<WorkspaceSettings> {
|
||||||
|
let mut tx = user_db.begin(&authed).await?;
|
||||||
|
let settings = sqlx::query_as!(
|
||||||
|
WorkspaceSettings,
|
||||||
|
"SELECT * FROM workspace_settings WHERE workspace_id = $1",
|
||||||
|
&w_id)
|
||||||
|
.fetch_one(&mut tx)
|
||||||
|
.await?;
|
||||||
|
tx.commit().await?;
|
||||||
|
Ok(Json(settings))
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn edit_slack_command(
|
||||||
|
authed: Authed,
|
||||||
|
Extension(db): Extension<DB>,
|
||||||
|
Path(w_id): Path<String>,
|
||||||
|
Authed { is_admin, username, .. }: Authed,
|
||||||
|
Json(es): Json<EditCommandScript>
|
||||||
|
) -> Result<String> {
|
||||||
|
require_admin(is_admin, &username)?;
|
||||||
|
let mut tx = db.begin().await?;
|
||||||
|
sqlx::query!(
|
||||||
|
"UPDATE workspace_settings SET slack_command_script = $1 WHERE workspace_id = $2",
|
||||||
|
es.slack_command_script,
|
||||||
|
&w_id
|
||||||
|
)
|
||||||
|
.execute(&mut tx)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
audit_log(
|
||||||
|
&mut tx,
|
||||||
|
&authed.username,
|
||||||
|
"workspaces.edit_command_script",
|
||||||
|
ActionKind::Update,
|
||||||
|
&w_id,
|
||||||
|
Some(&authed.email.unwrap()),
|
||||||
|
Some([
|
||||||
|
("script", es.slack_command_script.unwrap_or("NO_SCRIPT".to_string()).as_str())
|
||||||
|
].into()),
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
tx.commit().await?;
|
||||||
|
|
||||||
|
Ok(format!("Edit command script {}", &w_id))
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
async fn list_workspaces_as_super_admin(
|
||||||
|
authed: Authed,
|
||||||
|
Extension(user_db): Extension<UserDB>,
|
||||||
|
Query(pagination): Query<Pagination>,
|
||||||
|
Authed { email, .. }: Authed,
|
||||||
|
) -> JsonResult<Vec<Workspace>> {
|
||||||
|
let mut tx = user_db.begin(&authed).await?;
|
||||||
|
require_super_admin(&mut tx, email).await?;
|
||||||
|
let (per_page, offset) = crate::utils::paginate(pagination);
|
||||||
|
|
||||||
|
let workspaces = sqlx::query_as!(
|
||||||
|
Workspace,
|
||||||
|
"SELECT * FROM workspace LIMIT $1 OFFSET $2", per_page as i32, offset as i32)
|
||||||
|
.fetch_all(&mut tx)
|
||||||
|
.await?;
|
||||||
|
tx.commit().await?;
|
||||||
|
Ok(Json(workspaces))
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn user_workspaces(
|
||||||
|
Extension(db): Extension<DB>,
|
||||||
|
Authed { email, .. }: Authed,
|
||||||
|
) -> JsonResult<WorkspaceList> {
|
||||||
|
let email = email
|
||||||
|
.ok_or("not a personal token")
|
||||||
|
.map_err(|x| Error::NotAuthorized(x.to_string()))?;
|
||||||
|
let mut tx = db.begin().await?;
|
||||||
|
let workspaces = sqlx::query_as!(
|
||||||
|
UserWorkspace,
|
||||||
|
"SELECT workspace.id, workspace.name, usr.username
|
||||||
|
FROM workspace, usr WHERE usr.workspace_id = workspace.id AND usr.email = $1 AND deleted = false",
|
||||||
|
email)
|
||||||
|
.fetch_all(&mut tx)
|
||||||
|
.await?;
|
||||||
|
tx.commit().await?;
|
||||||
|
Ok(Json(WorkspaceList { email, workspaces }))
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn create_workspace(
|
||||||
|
authed: Authed,
|
||||||
|
Extension(db): Extension<DB>,
|
||||||
|
Json(nw): Json<CreateWorkspace>
|
||||||
|
) -> Result<String> {
|
||||||
|
if &nw.username == "bot" {
|
||||||
|
return Err(Error::BadRequest("bot is a reserved username".to_string()))
|
||||||
|
}
|
||||||
|
let mut tx = db.begin().await?;
|
||||||
|
sqlx::query!(
|
||||||
|
"INSERT INTO workspace
|
||||||
|
(id, name, owner, domain)
|
||||||
|
VALUES ($1, $2, $3, $4)",
|
||||||
|
nw.id,
|
||||||
|
nw.name,
|
||||||
|
authed.email,
|
||||||
|
nw.domain
|
||||||
|
)
|
||||||
|
.execute(&mut tx)
|
||||||
|
.await?;
|
||||||
|
sqlx::query!(
|
||||||
|
"INSERT INTO workspace_settings
|
||||||
|
(workspace_id)
|
||||||
|
VALUES ($1)",
|
||||||
|
nw.id
|
||||||
|
)
|
||||||
|
.execute(&mut tx)
|
||||||
|
.await?;
|
||||||
|
let key = crate::utils::rd_string(64);
|
||||||
|
sqlx::query!(
|
||||||
|
"INSERT INTO workspace_key
|
||||||
|
(workspace_id, kind, key)
|
||||||
|
VALUES ($1, 'cloud', $2)",
|
||||||
|
nw.id, &key
|
||||||
|
)
|
||||||
|
.execute(&mut tx)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
let mc = magic_crypt::new_magic_crypt!(key, 256);
|
||||||
|
sqlx::query!(
|
||||||
|
"INSERT INTO variable
|
||||||
|
(workspace_id, path, value, is_secret, description)
|
||||||
|
VALUES ($1, 'g/all/pretty_secret', $2, true, 'This item is secret'),
|
||||||
|
($3, 'g/all/not_secret', $4, false, 'This item is not secret')",
|
||||||
|
nw.id,
|
||||||
|
crate::variables::encrypt(&mc, "pretty secret value".to_string()),
|
||||||
|
nw.id,
|
||||||
|
"finland does not actually exist",
|
||||||
|
)
|
||||||
|
.execute(&mut tx)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
sqlx::query!(
|
||||||
|
"INSERT INTO usr
|
||||||
|
(workspace_id, email, username, is_admin)
|
||||||
|
VALUES ($1, $2, $3, true)",
|
||||||
|
nw.id,
|
||||||
|
authed.email,
|
||||||
|
nw.username,
|
||||||
|
)
|
||||||
|
.execute(&mut tx)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
sqlx::query!(
|
||||||
|
"INSERT INTO group_
|
||||||
|
VALUES ($1, 'all', 'The group that always contains all users of this workspace')",
|
||||||
|
nw.id
|
||||||
|
)
|
||||||
|
.execute(&mut tx)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
sqlx::query!(
|
||||||
|
"INSERT INTO usr_to_group
|
||||||
|
VALUES ($1, 'all', $2)",
|
||||||
|
nw.id,
|
||||||
|
nw.username
|
||||||
|
)
|
||||||
|
.execute(&mut tx)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
audit_log(
|
||||||
|
&mut tx,
|
||||||
|
&authed.username,
|
||||||
|
"workspaces.create",
|
||||||
|
ActionKind::Create,
|
||||||
|
&nw.id,
|
||||||
|
Some(&authed.email.unwrap()),
|
||||||
|
None,
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
tx.commit().await?;
|
||||||
|
|
||||||
|
|
||||||
|
Ok(format!("Created workspace {}", &nw.id))
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn edit_workspace(
|
||||||
|
authed: Authed,
|
||||||
|
Extension(db): Extension<DB>,
|
||||||
|
Path(w_id): Path<String>,
|
||||||
|
Authed { is_admin, username, .. }: Authed,
|
||||||
|
Json(ew): Json<EditWorkspace>
|
||||||
|
) -> Result<String> {
|
||||||
|
require_admin(is_admin, &username)?;
|
||||||
|
let mut tx = db.begin().await?;
|
||||||
|
sqlx::query!(
|
||||||
|
"UPDATE workspace SET name = $1, owner = $2, domain = $3 WHERE id = $4",
|
||||||
|
ew.name,
|
||||||
|
ew.owner,
|
||||||
|
ew.domain,
|
||||||
|
&w_id
|
||||||
|
)
|
||||||
|
.execute(&mut tx)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
audit_log(
|
||||||
|
&mut tx,
|
||||||
|
&authed.username,
|
||||||
|
"workspaces.update",
|
||||||
|
ActionKind::Update,
|
||||||
|
&w_id,
|
||||||
|
Some(&authed.email.unwrap()),
|
||||||
|
Some([
|
||||||
|
("domain", ew.domain.unwrap_or("NO_DOMAIN".to_string()).as_str())
|
||||||
|
].into()),
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
tx.commit().await?;
|
||||||
|
|
||||||
|
Ok(format!("Updated workspace {}", &w_id))
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn delete_workspace(
|
||||||
|
Extension(db): Extension<DB>,
|
||||||
|
Path(w_id): Path<String>,
|
||||||
|
Authed { is_admin, username, email, .. }: Authed,
|
||||||
|
) -> Result<String> {
|
||||||
|
require_admin(is_admin, &username)?;
|
||||||
|
let mut tx = db.begin().await?;
|
||||||
|
sqlx::query!(
|
||||||
|
"UPDATE workspace SET deleted = true WHERE id = $1",
|
||||||
|
&w_id
|
||||||
|
)
|
||||||
|
.execute(&mut tx)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
audit_log(
|
||||||
|
&mut tx,
|
||||||
|
&username,
|
||||||
|
"workspaces.delete",
|
||||||
|
ActionKind::Update,
|
||||||
|
&w_id,
|
||||||
|
Some(&email.unwrap_or("noemail".to_string())),
|
||||||
|
None,
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
tx.commit().await?;
|
||||||
|
|
||||||
|
Ok(format!("Deleted workspace {}", &w_id))
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
async fn invite_user(
|
||||||
|
Authed { username, is_admin, .. }: Authed,
|
||||||
|
Extension(db): Extension<DB>,
|
||||||
|
Path(w_id): Path<String>,
|
||||||
|
Json(nu): Json<NewWorkspaceInvite>,
|
||||||
|
) -> Result<(StatusCode, String)> {
|
||||||
|
|
||||||
|
require_admin(is_admin, &username)?;
|
||||||
|
|
||||||
|
let mut tx = db.begin().await?;
|
||||||
|
|
||||||
|
sqlx::query!(
|
||||||
|
"INSERT INTO workspace_invite
|
||||||
|
(workspace_id, email, is_admin)
|
||||||
|
VALUES ($1, $2, $3)",
|
||||||
|
&w_id,
|
||||||
|
nu.email,
|
||||||
|
nu.is_admin
|
||||||
|
)
|
||||||
|
.execute(&mut tx)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
tx.commit().await?;
|
||||||
|
|
||||||
|
Ok((
|
||||||
|
StatusCode::CREATED,
|
||||||
|
format!("user with email {} invited", nu.email),
|
||||||
|
))
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
async fn delete_invite(
|
||||||
|
Authed { username, is_admin, .. }: Authed,
|
||||||
|
Extension(db): Extension<DB>,
|
||||||
|
Path(w_id): Path<String>,
|
||||||
|
Json(nu): Json<NewWorkspaceInvite>,
|
||||||
|
) -> Result<(StatusCode, String)> {
|
||||||
|
|
||||||
|
require_admin(is_admin, &username)?;
|
||||||
|
|
||||||
|
let mut tx = db.begin().await?;
|
||||||
|
|
||||||
|
sqlx::query!(
|
||||||
|
"DELETE FROM workspace_invite WHERE
|
||||||
|
workspace_id = $1 AND email = $2 AND is_admin = $3",
|
||||||
|
&w_id,
|
||||||
|
nu.email,
|
||||||
|
nu.is_admin
|
||||||
|
)
|
||||||
|
.execute(&mut tx)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
tx.commit().await?;
|
||||||
|
|
||||||
|
Ok((
|
||||||
|
StatusCode::CREATED,
|
||||||
|
format!("invite to email {} deleted", nu.email),
|
||||||
|
))
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn validate_username(
|
||||||
|
Extension(db): Extension<DB>,
|
||||||
|
Json(vu): Json<ValidateUsername>,
|
||||||
|
) -> Result<String> {
|
||||||
|
|
||||||
|
let exists = sqlx::query_scalar!(
|
||||||
|
"SELECT EXISTS(SELECT 1 FROM usr WHERE username = $1 AND workspace_id = $2)",
|
||||||
|
vu.username,
|
||||||
|
vu.id
|
||||||
|
)
|
||||||
|
.fetch_one(&db)
|
||||||
|
.await?
|
||||||
|
.unwrap_or(true);
|
||||||
|
|
||||||
|
if exists {
|
||||||
|
return Err(Error::BadRequest("username already taken".to_string()))
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok("valid username".to_string())
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
async fn validate_id(
|
||||||
|
Extension(db): Extension<DB>,
|
||||||
|
Json(wi): Json<WorkspaceId>,
|
||||||
|
) -> Result<String> {
|
||||||
|
|
||||||
|
let exists = sqlx::query_scalar!(
|
||||||
|
"SELECT EXISTS(SELECT 1 FROM workspace WHERE id = $1)",
|
||||||
|
wi.id
|
||||||
|
)
|
||||||
|
.fetch_one(&db)
|
||||||
|
.await?
|
||||||
|
.unwrap_or(true);
|
||||||
|
|
||||||
|
if exists {
|
||||||
|
return Err(Error::BadRequest("id already taken".to_string()))
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok("valid workspace".to_string())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Serialize)]
|
||||||
|
struct ScriptMetadata {
|
||||||
|
summary: String,
|
||||||
|
description: String,
|
||||||
|
schema: Option<Schema>,
|
||||||
|
is_template: bool,
|
||||||
|
lock: Vec<String>
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn tarball_workspace(
|
||||||
|
authed: Authed,
|
||||||
|
Extension(db): Extension<DB>,
|
||||||
|
Path(w_id): Path<String>,
|
||||||
|
) -> Result<([(headers::HeaderName, String); 2], impl IntoResponse)> {
|
||||||
|
require_admin(authed.is_admin, &authed.username)?;
|
||||||
|
|
||||||
|
let tmp_dir = TempDir::new_in(".")?;
|
||||||
|
|
||||||
|
let name = format!("windmill-{w_id}.tar");
|
||||||
|
let file_path = tmp_dir.path().join(&name);
|
||||||
|
let file = File::create(&file_path).await?;
|
||||||
|
let mut a = tokio_tar::Builder::new(file);
|
||||||
|
|
||||||
|
|
||||||
|
{
|
||||||
|
let scripts = sqlx::query_as::<_, Script>(
|
||||||
|
"SELECT * FROM script as o WHERE workspace_id = $1 AND archived = false
|
||||||
|
AND created_at = (select max(created_at) from script where path = o.path AND workspace_id = $1)"
|
||||||
|
)
|
||||||
|
.bind(&w_id)
|
||||||
|
.fetch_all(&db)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
for script in scripts {
|
||||||
|
write_to_archive(script.content, format!("scripts/{}.py", script.path), &mut a).await?;
|
||||||
|
|
||||||
|
let lock = script.lock.unwrap_or_else(|| "".to_string())
|
||||||
|
.lines()
|
||||||
|
.map(|x| x.to_string())
|
||||||
|
.collect();
|
||||||
|
let metadata = ScriptMetadata {
|
||||||
|
summary: script.summary, description: script.description, schema: script.schema, is_template: script.is_template, lock };
|
||||||
|
let metadata_str = serde_json::to_string_pretty(&metadata).unwrap();
|
||||||
|
write_to_archive(metadata_str, format!("scripts/{}.json", script.path), &mut a).await?;
|
||||||
|
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
{
|
||||||
|
|
||||||
|
let resources = sqlx::query_as!(Resource,
|
||||||
|
"SELECT * FROM resource WHERE workspace_id = $1",
|
||||||
|
&w_id
|
||||||
|
)
|
||||||
|
.fetch_all(&db)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
for resource in resources {
|
||||||
|
let resource_str = serde_json::to_string_pretty(&resource).unwrap();
|
||||||
|
write_to_archive(resource_str, format!("resources/{}.json", resource.path), &mut a).await?;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
{
|
||||||
|
let resource_types = sqlx::query_as!(ResourceType,
|
||||||
|
"SELECT * FROM resource_type WHERE workspace_id = $1",
|
||||||
|
&w_id
|
||||||
|
)
|
||||||
|
.fetch_all(&db)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
for resource_type in resource_types {
|
||||||
|
let resource_str = serde_json::to_string_pretty(&resource_type).unwrap();
|
||||||
|
write_to_archive(resource_str, format!("resource_types/{}.json", resource_type.name), &mut a).await?;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
{
|
||||||
|
let flows = sqlx::query_as::<_, Flow>(
|
||||||
|
"SELECT * FROM flow WHERE workspace_id = $1 AND archived = false"
|
||||||
|
)
|
||||||
|
.bind(&w_id)
|
||||||
|
.fetch_all(&db)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
for flow in flows {
|
||||||
|
let flow_str = serde_json::to_string_pretty(&flow).unwrap();
|
||||||
|
write_to_archive(flow_str, format!("flows/{}.json", flow.path), &mut a).await?;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
{
|
||||||
|
let variables = sqlx::query_as::<_, ListableVariable>(
|
||||||
|
"SELECT * FROM variable WHERE workspace_id = $1 AND is_secret = false"
|
||||||
|
)
|
||||||
|
.bind(&w_id)
|
||||||
|
.fetch_all(&db)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
for var in variables {
|
||||||
|
let flow_str = serde_json::to_string_pretty(&var).unwrap();
|
||||||
|
write_to_archive(flow_str, format!("variables/{}.json", var.path), &mut a).await?;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
a.into_inner().await?;
|
||||||
|
|
||||||
|
let file = tokio::fs::File::open(file_path).await?;
|
||||||
|
|
||||||
|
let stream = ReaderStream::new(file);
|
||||||
|
let body = StreamBody::new(stream);
|
||||||
|
|
||||||
|
let headers = [
|
||||||
|
(header::CONTENT_TYPE, "application/x-tar".to_string()),
|
||||||
|
(
|
||||||
|
header::CONTENT_DISPOSITION,
|
||||||
|
format!("attachment; filename=\"{name}\""),
|
||||||
|
),
|
||||||
|
];
|
||||||
|
|
||||||
|
Ok((headers, body))
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn write_to_archive(content: String, path: String, a: &mut tokio_tar::Builder<File>) -> Result<()> {
|
||||||
|
let bytes = content.as_bytes();
|
||||||
|
let mut header = tokio_tar::Header::new_gnu();
|
||||||
|
header.set_size(bytes.len() as u64);
|
||||||
|
header.set_mtime(0);
|
||||||
|
header.set_uid(0);
|
||||||
|
header.set_gid(0);
|
||||||
|
header.set_mode(0o777);
|
||||||
|
header.set_cksum();
|
||||||
|
a.append_data(&mut header, path, bytes).await?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
BIN
backend/v8.snap
Normal file
BIN
backend/v8.snap
Normal file
Binary file not shown.
28
community/README.md
Normal file
28
community/README.md
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
# Windmill community scripts
|
||||||
|
|
||||||
|
**Contributions welcome!**
|
||||||
|
|
||||||
|
The structure of the public templates are as follows:
|
||||||
|
|
||||||
|
- `starter/`:
|
||||||
|
- `resource_types/`: The resource types as
|
||||||
|
[jsonschema](https://json-schema.org/)
|
||||||
|
- `resources/u/bot/<foo>.json`: The resources
|
||||||
|
- `scripts/u/bot/<foo>.py`: The scripts code
|
||||||
|
- `scripts/u/bot/<foo>.json`: The corresponding script metadata
|
||||||
|
- `flows/u/bot/<foo>.json`: The flow entire definition
|
||||||
|
|
||||||
|
On merge to the main branch, the changes are automatically pushed to the
|
||||||
|
`starter` workspace of windmill.dev using the github action:
|
||||||
|
[windmill-gh-action-deploy](https://github.com/windmill-labs/windmill-gh-action-deploy).
|
||||||
|
|
||||||
|
Any element in the `starter` workspace is available read-only in every
|
||||||
|
workspace. Whenever an element is updated in `starter`, it is updated in every
|
||||||
|
workspaces, not just new ones.
|
||||||
|
|
||||||
|
This repo serves also as an example of how to setup a repo to back and
|
||||||
|
automatically deploy scripts and resources to your workspace using
|
||||||
|
[windmill-gh-action-deploy](https://github.com/windmill-labs/windmill-gh-action-deploy).
|
||||||
|
Indeed, it is sufficient to use the same folder layout and setup an action
|
||||||
|
similar to the one
|
||||||
|
[here](https://github.com/windmill-labs/windmill/blob/main/.github/workflows/deploy_to_windmill.yml).
|
||||||
32
community/resource_types/SMTP.json
Executable file
32
community/resource_types/SMTP.json
Executable file
@@ -0,0 +1,32 @@
|
|||||||
|
{
|
||||||
|
"workspace_id": "starter",
|
||||||
|
"name": "SMTP",
|
||||||
|
"schema": {
|
||||||
|
"type": "object",
|
||||||
|
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||||
|
"required": [
|
||||||
|
"host"
|
||||||
|
],
|
||||||
|
"properties": {
|
||||||
|
"host": {
|
||||||
|
"type": "string",
|
||||||
|
"default": "mail.smtpbucket.com",
|
||||||
|
"description": "SMTP host address"
|
||||||
|
},
|
||||||
|
"port": {
|
||||||
|
"type": "integer",
|
||||||
|
"default": 8025,
|
||||||
|
"description": "port number on which to connect"
|
||||||
|
},
|
||||||
|
"user": {
|
||||||
|
"type": "string",
|
||||||
|
"description": ""
|
||||||
|
},
|
||||||
|
"password": {
|
||||||
|
"type": "string",
|
||||||
|
"description": ""
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"description": "SMTP connection info"
|
||||||
|
}
|
||||||
38
community/resource_types/mongodb.json
Executable file
38
community/resource_types/mongodb.json
Executable file
@@ -0,0 +1,38 @@
|
|||||||
|
{
|
||||||
|
"workspace_id": "starter",
|
||||||
|
"name": "mongodb",
|
||||||
|
"schema": {
|
||||||
|
"type": "object",
|
||||||
|
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||||
|
"required": [
|
||||||
|
"host",
|
||||||
|
"password",
|
||||||
|
"username"
|
||||||
|
],
|
||||||
|
"properties": {
|
||||||
|
"tls": {
|
||||||
|
"type": "boolean",
|
||||||
|
"description": ""
|
||||||
|
},
|
||||||
|
"host": {
|
||||||
|
"type": "string",
|
||||||
|
"default": "localhost:27017",
|
||||||
|
"description": "Hostname or IP address or Unix domain socket path of a single mongod or mongos instance to connect to, or a mongodb URI"
|
||||||
|
},
|
||||||
|
"port": {
|
||||||
|
"type": "integer",
|
||||||
|
"default": 27017,
|
||||||
|
"description": "port number on which to connect"
|
||||||
|
},
|
||||||
|
"password": {
|
||||||
|
"type": "string",
|
||||||
|
"description": ""
|
||||||
|
},
|
||||||
|
"username": {
|
||||||
|
"type": "string",
|
||||||
|
"description": ""
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"description": "MongoDB connection info"
|
||||||
|
}
|
||||||
36
community/resource_types/mysql.json
Executable file
36
community/resource_types/mysql.json
Executable file
@@ -0,0 +1,36 @@
|
|||||||
|
{
|
||||||
|
"workspace_id": "starter",
|
||||||
|
"name": "mysql",
|
||||||
|
"schema": {
|
||||||
|
"type": "object",
|
||||||
|
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||||
|
"required": [
|
||||||
|
"password",
|
||||||
|
"user"
|
||||||
|
],
|
||||||
|
"properties": {
|
||||||
|
"host": {
|
||||||
|
"type": "string",
|
||||||
|
"description": "The instance host"
|
||||||
|
},
|
||||||
|
"port": {
|
||||||
|
"type": "integer",
|
||||||
|
"default": 3306,
|
||||||
|
"description": "The instance port"
|
||||||
|
},
|
||||||
|
"user": {
|
||||||
|
"type": "string",
|
||||||
|
"description": "The postgres username"
|
||||||
|
},
|
||||||
|
"database": {
|
||||||
|
"type": "string",
|
||||||
|
"description": "The database to connect to"
|
||||||
|
},
|
||||||
|
"password": {
|
||||||
|
"type": "string",
|
||||||
|
"description": "The postgres users password"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"description": "MySQL connection info"
|
||||||
|
}
|
||||||
48
community/resource_types/postgres.json
Executable file
48
community/resource_types/postgres.json
Executable file
@@ -0,0 +1,48 @@
|
|||||||
|
{
|
||||||
|
"workspace_id": "starter",
|
||||||
|
"name": "postgres",
|
||||||
|
"schema": {
|
||||||
|
"type": "object",
|
||||||
|
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||||
|
"required": [
|
||||||
|
"dbname",
|
||||||
|
"user",
|
||||||
|
"password"
|
||||||
|
],
|
||||||
|
"properties": {
|
||||||
|
"host": {
|
||||||
|
"type": "string",
|
||||||
|
"description": "The instance host"
|
||||||
|
},
|
||||||
|
"port": {
|
||||||
|
"type": "integer",
|
||||||
|
"description": "The instance port"
|
||||||
|
},
|
||||||
|
"user": {
|
||||||
|
"type": "string",
|
||||||
|
"description": "The postgres username"
|
||||||
|
},
|
||||||
|
"dbname": {
|
||||||
|
"type": "string",
|
||||||
|
"description": "The database name"
|
||||||
|
},
|
||||||
|
"sslmode": {
|
||||||
|
"enum": [
|
||||||
|
"disable",
|
||||||
|
"allow",
|
||||||
|
"prefer",
|
||||||
|
"require",
|
||||||
|
"verify-ca",
|
||||||
|
"verify-full"
|
||||||
|
],
|
||||||
|
"type": "string",
|
||||||
|
"description": "The sslmode"
|
||||||
|
},
|
||||||
|
"password": {
|
||||||
|
"type": "string",
|
||||||
|
"description": "The postgres users password"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"description": "A postgres database connection resource"
|
||||||
|
}
|
||||||
16
community/resource_types/slack.json
Executable file
16
community/resource_types/slack.json
Executable file
@@ -0,0 +1,16 @@
|
|||||||
|
{
|
||||||
|
"workspace_id": "starter",
|
||||||
|
"name": "slack",
|
||||||
|
"schema": {
|
||||||
|
"type": "object",
|
||||||
|
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||||
|
"required": [],
|
||||||
|
"properties": {
|
||||||
|
"token": {
|
||||||
|
"type": "string",
|
||||||
|
"description": "The slack token"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"description": "A slack token to interact with a specific workspace. Can be obtained from the OAuth integration in the workspace settings."
|
||||||
|
}
|
||||||
15
community/resources/g/all/demodb.json
Executable file
15
community/resources/g/all/demodb.json
Executable file
@@ -0,0 +1,15 @@
|
|||||||
|
{
|
||||||
|
"workspace_id": "starter",
|
||||||
|
"path": "g/all/demodb",
|
||||||
|
"value": {
|
||||||
|
"host": "demodb.service.consul",
|
||||||
|
"port": "6543",
|
||||||
|
"user": "postgres",
|
||||||
|
"dbname": "demodb",
|
||||||
|
"sslmode": "disable",
|
||||||
|
"password": "demodb"
|
||||||
|
},
|
||||||
|
"description": "demodb",
|
||||||
|
"resource_type": "postgres",
|
||||||
|
"extra_perms": {}
|
||||||
|
}
|
||||||
33
community/scripts/u/bot/hello_world.json
Executable file
33
community/scripts/u/bot/hello_world.json
Executable file
@@ -0,0 +1,33 @@
|
|||||||
|
{
|
||||||
|
"summary": "Hello World",
|
||||||
|
"description": "The simplest hello world script",
|
||||||
|
"schema": {
|
||||||
|
"type": "object",
|
||||||
|
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||||
|
"required": [],
|
||||||
|
"properties": {
|
||||||
|
"name": {
|
||||||
|
"type": "string",
|
||||||
|
"default": "Nicolas Bourbaki",
|
||||||
|
"description": ""
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"is_template": false,
|
||||||
|
"lock": [
|
||||||
|
"anyio==3.5.0",
|
||||||
|
"attrs==21.4.0",
|
||||||
|
"certifi==2021.10.8",
|
||||||
|
"charset-normalizer==2.0.12",
|
||||||
|
"h11==0.12.0",
|
||||||
|
"httpcore==0.14.7",
|
||||||
|
"httpx==0.21.3",
|
||||||
|
"idna==3.3",
|
||||||
|
"python-dateutil==2.8.2",
|
||||||
|
"rfc3986[idna2008]==1.5.0",
|
||||||
|
"six==1.16.0",
|
||||||
|
"sniffio==1.2.0",
|
||||||
|
"windmill-api==1.4.2",
|
||||||
|
"wmill==1.4.2"
|
||||||
|
]
|
||||||
|
}
|
||||||
6
community/scripts/u/bot/hello_world.py
Executable file
6
community/scripts/u/bot/hello_world.py
Executable file
@@ -0,0 +1,6 @@
|
|||||||
|
import wmill
|
||||||
|
|
||||||
|
def main(name: str = "Nicolas Bourbaki"):
|
||||||
|
print(f"Hello World and a warm welcome especially to {name}")
|
||||||
|
print("The env variable at `g/all/pretty_secret`:", wmill.get_variable("g/all/pretty_secret"))
|
||||||
|
return {"len": len(name), "splitted": name.split() }
|
||||||
26
community/scripts/u/bot/http_request.json
Executable file
26
community/scripts/u/bot/http_request.json
Executable file
@@ -0,0 +1,26 @@
|
|||||||
|
{
|
||||||
|
"summary": "Do a simple HTTP post request",
|
||||||
|
"description": "This example pass a value as JSON data and use a secret as bearer token",
|
||||||
|
"schema": {
|
||||||
|
"type": "object",
|
||||||
|
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||||
|
"required": [
|
||||||
|
"my_value"
|
||||||
|
],
|
||||||
|
"properties": {
|
||||||
|
"my_value": {
|
||||||
|
"type": "string",
|
||||||
|
"default": null,
|
||||||
|
"description": ""
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"is_template": true,
|
||||||
|
"lock": [
|
||||||
|
"certifi==2021.10.8",
|
||||||
|
"charset-normalizer==2.0.12",
|
||||||
|
"idna==3.3",
|
||||||
|
"requests==2.27.1",
|
||||||
|
"urllib3==1.26.9"
|
||||||
|
]
|
||||||
|
}
|
||||||
9
community/scripts/u/bot/http_request.py
Executable file
9
community/scripts/u/bot/http_request.py
Executable file
@@ -0,0 +1,9 @@
|
|||||||
|
import requests
|
||||||
|
import os
|
||||||
|
|
||||||
|
def main(
|
||||||
|
my_value: str
|
||||||
|
):
|
||||||
|
headers={'Authorization': "Bearer: {}".format(os.environ.get("WM_TOKEN"))}
|
||||||
|
r = requests.post('https://httpbin.org/post', data={'key': my_value})
|
||||||
|
return r.text
|
||||||
42
community/scripts/u/bot/import_workspace_from_tarball.json
Executable file
42
community/scripts/u/bot/import_workspace_from_tarball.json
Executable file
@@ -0,0 +1,42 @@
|
|||||||
|
{
|
||||||
|
"summary": "Import workspace from tarball",
|
||||||
|
"description": "Takes a tarball in and import scripts, resources, and resource types from a tarball exported of a workspace.\n\nIs used as webhook target for the windmill-gh-action-deploy",
|
||||||
|
"schema": {
|
||||||
|
"type": "object",
|
||||||
|
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||||
|
"required": [
|
||||||
|
"tarball"
|
||||||
|
],
|
||||||
|
"properties": {
|
||||||
|
"dry_run": {
|
||||||
|
"type": "boolean",
|
||||||
|
"default": false,
|
||||||
|
"description": ""
|
||||||
|
},
|
||||||
|
"tarball": {
|
||||||
|
"type": "string",
|
||||||
|
"default": null,
|
||||||
|
"description": "",
|
||||||
|
"contentEncoding": "base64"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"is_template": false,
|
||||||
|
"lock": [
|
||||||
|
"anyio==3.5.0",
|
||||||
|
"attrs==21.4.0",
|
||||||
|
"certifi==2021.10.8",
|
||||||
|
"charset-normalizer==2.0.12",
|
||||||
|
"h11==0.12.0",
|
||||||
|
"httpcore==0.14.7",
|
||||||
|
"httpx==0.22.0",
|
||||||
|
"idna==3.3",
|
||||||
|
"psycopg2-binary==2.9.3",
|
||||||
|
"python-dateutil==2.8.2",
|
||||||
|
"rfc3986[idna2008]==1.5.0",
|
||||||
|
"six==1.16.0",
|
||||||
|
"sniffio==1.2.0",
|
||||||
|
"windmill-api==1.2.0",
|
||||||
|
"wmill==1.2.0"
|
||||||
|
]
|
||||||
|
}
|
||||||
187
community/scripts/u/bot/import_workspace_from_tarball.py
Executable file
187
community/scripts/u/bot/import_workspace_from_tarball.py
Executable file
@@ -0,0 +1,187 @@
|
|||||||
|
import tarfile
|
||||||
|
import io
|
||||||
|
import wmill
|
||||||
|
import json
|
||||||
|
|
||||||
|
from windmill_api.api.script import get_script_by_path, create_script
|
||||||
|
from windmill_api.models.create_script_json_body import CreateScriptJsonBody
|
||||||
|
from windmill_api.models.create_script_json_body_schema import (
|
||||||
|
CreateScriptJsonBodySchema,
|
||||||
|
)
|
||||||
|
from windmill_api.models.update_resource_json_body import UpdateResourceJsonBody
|
||||||
|
from windmill_api.models.create_resource_json_body import CreateResourceJsonBody
|
||||||
|
|
||||||
|
from windmill_api.models.update_resource_type_json_body import (
|
||||||
|
UpdateResourceTypeJsonBody,
|
||||||
|
)
|
||||||
|
|
||||||
|
from windmill_api.models.create_resource_type_json_body import (
|
||||||
|
CreateResourceTypeJsonBody,
|
||||||
|
)
|
||||||
|
from windmill_api.api.resource import (
|
||||||
|
create_resource,
|
||||||
|
create_resource_type,
|
||||||
|
update_resource,
|
||||||
|
update_resource_type,
|
||||||
|
get_resource,
|
||||||
|
get_resource_type,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
client = wmill.Client()
|
||||||
|
|
||||||
|
SCRIPTS_PREFIX = "scripts/"
|
||||||
|
RESOURCES_PREFIX = "resources/"
|
||||||
|
RESOURCE_TYPES_PREFIX = "resource_types/"
|
||||||
|
|
||||||
|
|
||||||
|
def main(tarball: bytes, dry_run: bool = False):
|
||||||
|
print("Tarball of size: {} bytes".format(len(tarball)))
|
||||||
|
io_bytes = io.BytesIO(tarball)
|
||||||
|
tar = tarfile.open(fileobj=io_bytes, mode="r")
|
||||||
|
names = tar.getnames()
|
||||||
|
|
||||||
|
for m in tar.getmembers():
|
||||||
|
if m.name.startswith(SCRIPTS_PREFIX) and m.name.endswith(".py"):
|
||||||
|
path = m.name[len(SCRIPTS_PREFIX) : -len(".py")]
|
||||||
|
print("Processing script {}, {}".format(path, m.name))
|
||||||
|
get_script_response = get_script_by_path.sync_detailed(
|
||||||
|
workspace=client.workspace, path=path, client=client.client
|
||||||
|
)
|
||||||
|
|
||||||
|
json_tar_path = "{}.json".format(m.name[: -len(".py")])
|
||||||
|
has_json = json_tar_path in names
|
||||||
|
|
||||||
|
parent_hash = None
|
||||||
|
summary = None
|
||||||
|
description = None
|
||||||
|
is_template = None
|
||||||
|
schema = None
|
||||||
|
|
||||||
|
content = tar.extractfile(m).read().decode("utf-8")
|
||||||
|
|
||||||
|
if get_script_response.status_code == 200:
|
||||||
|
old_script = json.loads(get_script_response.content)
|
||||||
|
|
||||||
|
if old_script["content"] == content:
|
||||||
|
if not has_json:
|
||||||
|
print(
|
||||||
|
"same content and no metadata for this script in tarball, no need to update"
|
||||||
|
)
|
||||||
|
continue
|
||||||
|
|
||||||
|
json_content = tar.extractfile(tar.getmember(json_tar_path)).read()
|
||||||
|
metadata = json.loads(json_content)
|
||||||
|
|
||||||
|
if (
|
||||||
|
old_script.get("summary") == metadata.get("summary")
|
||||||
|
and old_script.get("description") == metadata.get("description")
|
||||||
|
and old_script.get("is_template") == metadata.get("is_template")
|
||||||
|
and old_script.get("schema") == metadata.get("schema")
|
||||||
|
and old_script.get("lock") == metadata.get("lock")
|
||||||
|
):
|
||||||
|
print("same content and no metadata, no need to update")
|
||||||
|
continue
|
||||||
|
|
||||||
|
parent_hash = old_script["hash"]
|
||||||
|
summary = old_script.get("summary")
|
||||||
|
description = old_script.get("description")
|
||||||
|
is_template = old_script.get("is_template")
|
||||||
|
schema = old_script.get("schema")
|
||||||
|
lock = None
|
||||||
|
|
||||||
|
if has_json:
|
||||||
|
json_content = tar.extractfile(tar.getmember(json_tar_path)).read()
|
||||||
|
metadata = json.loads(json_content)
|
||||||
|
|
||||||
|
summary = metadata.get("summary")
|
||||||
|
description = metadata.get("description")
|
||||||
|
is_template = metadata.get("is_template")
|
||||||
|
schema = metadata.get("schema")
|
||||||
|
lock = metadata.get("lock")
|
||||||
|
if lock == []:
|
||||||
|
lock = None
|
||||||
|
|
||||||
|
if schema:
|
||||||
|
schema = CreateScriptJsonBodySchema.from_dict(schema)
|
||||||
|
|
||||||
|
print("Uploading new version of script at path: {}".format(path))
|
||||||
|
|
||||||
|
if dry_run:
|
||||||
|
print("Skipped because dry-run")
|
||||||
|
else:
|
||||||
|
r = create_script.sync_detailed(
|
||||||
|
client.workspace,
|
||||||
|
client=client.client,
|
||||||
|
json_body=CreateScriptJsonBody(
|
||||||
|
content=content,
|
||||||
|
path=path,
|
||||||
|
parent_hash=parent_hash,
|
||||||
|
summary=summary,
|
||||||
|
description=description,
|
||||||
|
is_template=is_template,
|
||||||
|
schema=schema,
|
||||||
|
lock=lock,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
print(r.content)
|
||||||
|
if m.name.startswith(RESOURCES_PREFIX) and m.name.endswith(".json"):
|
||||||
|
path = m.name[len(RESOURCES_PREFIX) : -len(".json")]
|
||||||
|
print("Processing resource {}, {}".format(path, m.name))
|
||||||
|
get_resource_response = get_resource.sync_detailed(
|
||||||
|
workspace=client.workspace, path=path, client=client.client
|
||||||
|
)
|
||||||
|
content = tar.extractfile(m).read().decode("utf-8")
|
||||||
|
resource = json.loads(content)
|
||||||
|
if get_resource_response.status_code == 200:
|
||||||
|
old_resource = json.loads(get_resource_response.content)
|
||||||
|
if resource["value"] != old_resource["value"]:
|
||||||
|
print("Updating existing resource")
|
||||||
|
r = update_resource.sync_detailed(
|
||||||
|
client.workspace,
|
||||||
|
path=path,
|
||||||
|
json_body=UpdateResourceJsonBody.from_dict(resource),
|
||||||
|
client=client.client,
|
||||||
|
)
|
||||||
|
print(r)
|
||||||
|
else:
|
||||||
|
print("Skipping updating identical resource")
|
||||||
|
else:
|
||||||
|
print("Creating new resource")
|
||||||
|
r = create_resource.sync_detailed(
|
||||||
|
client.workspace,
|
||||||
|
json_body=CreateResourceJsonBody.from_dict(resource),
|
||||||
|
client=client.client,
|
||||||
|
)
|
||||||
|
print(r)
|
||||||
|
|
||||||
|
if m.name.startswith(RESOURCE_TYPES_PREFIX) and m.name.endswith(".json"):
|
||||||
|
path = m.name[len(RESOURCE_TYPES_PREFIX) : -len(".json")]
|
||||||
|
print("Processing resource type {}, {}".format(path, m.name))
|
||||||
|
get_resource_response = get_resource_type.sync_detailed(
|
||||||
|
workspace=client.workspace, path=path, client=client.client
|
||||||
|
)
|
||||||
|
content = tar.extractfile(m).read().decode("utf-8")
|
||||||
|
resource = json.loads(content)
|
||||||
|
print(resource)
|
||||||
|
if get_resource_response.status_code == 200:
|
||||||
|
old_resource_type = json.loads(get_resource_response.content)
|
||||||
|
if resource["schema"] != old_resource_type["schema"]:
|
||||||
|
print("Updating existing resource type")
|
||||||
|
r = update_resource_type.sync_detailed(
|
||||||
|
client.workspace,
|
||||||
|
path=path,
|
||||||
|
json_body=UpdateResourceTypeJsonBody.from_dict(resource),
|
||||||
|
client=client.client,
|
||||||
|
)
|
||||||
|
print(r)
|
||||||
|
else:
|
||||||
|
print("Skipping updating identical resource type")
|
||||||
|
else:
|
||||||
|
print("Creating new resource type")
|
||||||
|
r = create_resource_type.sync_detailed(
|
||||||
|
client.workspace,
|
||||||
|
json_body=CreateResourceTypeJsonBody.from_dict(resource),
|
||||||
|
client=client.client,
|
||||||
|
)
|
||||||
|
print(r)
|
||||||
56
community/scripts/u/bot/message_slack.json
Executable file
56
community/scripts/u/bot/message_slack.json
Executable file
@@ -0,0 +1,56 @@
|
|||||||
|
{
|
||||||
|
"summary": "Send a slack message",
|
||||||
|
"description": "Send a message to a channel or person in the connected slack workspace",
|
||||||
|
"schema": {
|
||||||
|
"type": "object",
|
||||||
|
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||||
|
"required": [
|
||||||
|
"slack_resource",
|
||||||
|
"text"
|
||||||
|
],
|
||||||
|
"properties": {
|
||||||
|
"slack_resource": {
|
||||||
|
"description": "",
|
||||||
|
"type": "object",
|
||||||
|
"default": null,
|
||||||
|
"format": "resource-slack"
|
||||||
|
},
|
||||||
|
"text": {
|
||||||
|
"type": "string",
|
||||||
|
"default": null,
|
||||||
|
"description": "",
|
||||||
|
"format": ""
|
||||||
|
},
|
||||||
|
"channel": {
|
||||||
|
"type": "string",
|
||||||
|
"default": null,
|
||||||
|
"description": "",
|
||||||
|
"format": ""
|
||||||
|
},
|
||||||
|
"user": {
|
||||||
|
"type": "string",
|
||||||
|
"default": null,
|
||||||
|
"description": "",
|
||||||
|
"format": ""
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"is_template": false,
|
||||||
|
"lock": [
|
||||||
|
"anyio==3.5.0",
|
||||||
|
"attrs==21.4.0",
|
||||||
|
"certifi==2021.10.8",
|
||||||
|
"charset-normalizer==2.0.12",
|
||||||
|
"h11==0.12.0",
|
||||||
|
"httpcore==0.14.7",
|
||||||
|
"httpx==0.21.3",
|
||||||
|
"idna==3.3",
|
||||||
|
"python-dateutil==2.8.2",
|
||||||
|
"rfc3986[idna2008]==1.5.0",
|
||||||
|
"six==1.16.0",
|
||||||
|
"slack-sdk==3.15.2",
|
||||||
|
"sniffio==1.2.0",
|
||||||
|
"windmill-api==1.5.0",
|
||||||
|
"wmill==1.5.0"
|
||||||
|
]
|
||||||
|
}
|
||||||
24
community/scripts/u/bot/message_slack.py
Executable file
24
community/scripts/u/bot/message_slack.py
Executable file
@@ -0,0 +1,24 @@
|
|||||||
|
import os
|
||||||
|
import wmill
|
||||||
|
from slack_sdk.web.client import WebClient
|
||||||
|
|
||||||
|
|
||||||
|
def main(
|
||||||
|
slack_resource: dict,
|
||||||
|
text: str,
|
||||||
|
channel: str = None,
|
||||||
|
user: str = None,
|
||||||
|
):
|
||||||
|
slack_client = WebClient(token=slack_resource["token"])
|
||||||
|
|
||||||
|
if channel == "":
|
||||||
|
channel = None
|
||||||
|
if user == "":
|
||||||
|
user = None
|
||||||
|
|
||||||
|
if channel is None and user is None or (channel is not None and user is not None):
|
||||||
|
raise Exception("one and only one of channel or user need to be set")
|
||||||
|
|
||||||
|
if user is not None:
|
||||||
|
channel = "@{}".format(user)
|
||||||
|
slack_client.chat_postMessage(channel=channel, text=text)
|
||||||
43
community/scripts/u/bot/query_postgres.json
Executable file
43
community/scripts/u/bot/query_postgres.json
Executable file
@@ -0,0 +1,43 @@
|
|||||||
|
{
|
||||||
|
"summary": "Execute an SQL query on a postgres resource",
|
||||||
|
"description": "An example of how to use resources from scripts. ",
|
||||||
|
"schema": {
|
||||||
|
"type": "object",
|
||||||
|
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||||
|
"required": [
|
||||||
|
"pg_con"
|
||||||
|
],
|
||||||
|
"properties": {
|
||||||
|
"pg_con": {
|
||||||
|
"description": "",
|
||||||
|
"type": "object",
|
||||||
|
"default": null,
|
||||||
|
"format": "resource-postgres"
|
||||||
|
},
|
||||||
|
"sql_query": {
|
||||||
|
"description": "",
|
||||||
|
"type": "string",
|
||||||
|
"default": "SELECT * from demo"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"is_template": false,
|
||||||
|
"lock": [
|
||||||
|
"anyio==3.5.0",
|
||||||
|
"attrs==21.4.0",
|
||||||
|
"certifi==2021.10.8",
|
||||||
|
"charset-normalizer==2.0.12",
|
||||||
|
"h11==0.12.0",
|
||||||
|
"httpcore==0.14.7",
|
||||||
|
"httpx==0.21.3",
|
||||||
|
"idna==3.3",
|
||||||
|
"psycopg2-binary==2.9.3",
|
||||||
|
"python-dateutil==2.8.2",
|
||||||
|
"rfc3986[idna2008]==1.5.0",
|
||||||
|
"six==1.16.0",
|
||||||
|
"sniffio==1.2.0",
|
||||||
|
"windmill-api==1.5.0",
|
||||||
|
"wmill==1.5.0",
|
||||||
|
"wmill-pg==1.5.0"
|
||||||
|
]
|
||||||
|
}
|
||||||
12
community/scripts/u/bot/query_postgres.py
Executable file
12
community/scripts/u/bot/query_postgres.py
Executable file
@@ -0,0 +1,12 @@
|
|||||||
|
import os
|
||||||
|
|
||||||
|
from wmill_pg import query
|
||||||
|
|
||||||
|
def main(
|
||||||
|
pg_con: dict, # a resource of type postgres (constrained at step 3: UI customisation)
|
||||||
|
sql_query: str = "SELECT * from demo"
|
||||||
|
):
|
||||||
|
|
||||||
|
# query that returns rows will return them as a list
|
||||||
|
return query(sql_query, pg_con)
|
||||||
|
|
||||||
33
community/scripts/u/bot/return_chart_as_image.json
Executable file
33
community/scripts/u/bot/return_chart_as_image.json
Executable file
@@ -0,0 +1,33 @@
|
|||||||
|
{
|
||||||
|
"summary": "Build seaborn charts and return a rendered image",
|
||||||
|
"description": "This script showcase that image can be rendered from their base64 encoding when their dict key is \"png\"",
|
||||||
|
"schema": {
|
||||||
|
"type": "object",
|
||||||
|
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||||
|
"required": [],
|
||||||
|
"properties": {
|
||||||
|
"seed": {
|
||||||
|
"type": "integer",
|
||||||
|
"default": 1234,
|
||||||
|
"description": ""
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"is_template": false,
|
||||||
|
"lock": [
|
||||||
|
"cycler==0.11.0",
|
||||||
|
"fonttools==4.33.3",
|
||||||
|
"kiwisolver==1.4.2",
|
||||||
|
"matplotlib==3.5.2",
|
||||||
|
"numpy==1.22.3",
|
||||||
|
"packaging==21.3",
|
||||||
|
"pandas==1.4.2",
|
||||||
|
"pillow==9.1.0",
|
||||||
|
"pyparsing==3.0.8",
|
||||||
|
"python-dateutil==2.8.2",
|
||||||
|
"pytz==2022.1",
|
||||||
|
"scipy==1.8.0",
|
||||||
|
"seaborn==0.11.2",
|
||||||
|
"six==1.16.0"
|
||||||
|
]
|
||||||
|
}
|
||||||
22
community/scripts/u/bot/return_chart_as_image.py
Executable file
22
community/scripts/u/bot/return_chart_as_image.py
Executable file
@@ -0,0 +1,22 @@
|
|||||||
|
import seaborn as sns
|
||||||
|
import base64
|
||||||
|
import numpy as np
|
||||||
|
import pandas as pd
|
||||||
|
|
||||||
|
def main(
|
||||||
|
seed: int = 1234
|
||||||
|
):
|
||||||
|
np.random.seed(seed)
|
||||||
|
data = np.random.multivariate_normal([0, 0], [[5, 2], [2, 2]], size=2000)
|
||||||
|
data = pd.DataFrame(data, columns=['x', 'y'])
|
||||||
|
|
||||||
|
file_output = "output.png"
|
||||||
|
|
||||||
|
with sns.axes_style('white'):
|
||||||
|
plot = sns.jointplot("x", "y", data, kind='hex')
|
||||||
|
fig = plot.figure.savefig(file_output)
|
||||||
|
|
||||||
|
with open(file_output, "rb") as image_file:
|
||||||
|
encoded_string = base64.b64encode(image_file.read()).decode('ascii')
|
||||||
|
|
||||||
|
return {"png": encoded_string}
|
||||||
23
community/scripts/u/bot/return_table.json
Executable file
23
community/scripts/u/bot/return_table.json
Executable file
@@ -0,0 +1,23 @@
|
|||||||
|
{
|
||||||
|
"summary": "Return Table",
|
||||||
|
"description": "An example of a script that return a table that can get rendered",
|
||||||
|
"schema": {
|
||||||
|
"type": "object",
|
||||||
|
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||||
|
"required": [],
|
||||||
|
"properties": {
|
||||||
|
"seed": {
|
||||||
|
"default": 123,
|
||||||
|
"description": ""
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"is_template": false,
|
||||||
|
"lock": [
|
||||||
|
"numpy==1.22.3",
|
||||||
|
"pandas==1.4.2",
|
||||||
|
"python-dateutil==2.8.2",
|
||||||
|
"pytz==2022.1",
|
||||||
|
"six==1.16.0"
|
||||||
|
]
|
||||||
|
}
|
||||||
11
community/scripts/u/bot/return_table.py
Executable file
11
community/scripts/u/bot/return_table.py
Executable file
@@ -0,0 +1,11 @@
|
|||||||
|
import numpy as np
|
||||||
|
import pandas as pd
|
||||||
|
|
||||||
|
def main(
|
||||||
|
seed = 123
|
||||||
|
):
|
||||||
|
np.random.seed(seed)
|
||||||
|
df = pd.DataFrame(np.random.randint(0,100,size=(100, 4)), columns=list('ABCD'))
|
||||||
|
print(df)
|
||||||
|
print("See the result tab to see it rendered as a table")
|
||||||
|
return [df.columns.values.tolist()] + df.values.tolist()
|
||||||
57
community/scripts/u/bot/send_email_gmail.json
Executable file
57
community/scripts/u/bot/send_email_gmail.json
Executable file
@@ -0,0 +1,57 @@
|
|||||||
|
{
|
||||||
|
"summary": "Send an email (smtp and gmail)",
|
||||||
|
"description": "Send an email using smtplib and in particular with a gmail account set with a [secure app password](https://support.google.com/accounts/answer/185833?hl=en).\n\nFor gmail the following smtp configuration set in resource would work:\n```json\n{\n \"host\": \"smtp.gmail.com\",\n \"password\": \"$var:u/user/your_app_password\",\n \"port\": 587,\n \"user\": \"youremail@gmail.com\"\n}\n```\nwhere you have set the variable `u/you/your_secure_password` as a variable containing the app password.",
|
||||||
|
"schema": {
|
||||||
|
"type": "object",
|
||||||
|
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||||
|
"required": [
|
||||||
|
"smtp_config",
|
||||||
|
"to",
|
||||||
|
"subject",
|
||||||
|
"body"
|
||||||
|
],
|
||||||
|
"properties": {
|
||||||
|
"smtp_config": {
|
||||||
|
"description": "",
|
||||||
|
"type": "object",
|
||||||
|
"default": null,
|
||||||
|
"format": "resource-SMTP"
|
||||||
|
},
|
||||||
|
"to": {
|
||||||
|
"type": "string",
|
||||||
|
"default": null,
|
||||||
|
"description": "",
|
||||||
|
"format": ""
|
||||||
|
},
|
||||||
|
"subject": {
|
||||||
|
"type": "string",
|
||||||
|
"default": null,
|
||||||
|
"description": "",
|
||||||
|
"format": ""
|
||||||
|
},
|
||||||
|
"body": {
|
||||||
|
"type": "string",
|
||||||
|
"default": null,
|
||||||
|
"description": "",
|
||||||
|
"format": ""
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"is_template": true,
|
||||||
|
"lock": [
|
||||||
|
"anyio==3.5.0",
|
||||||
|
"attrs==21.4.0",
|
||||||
|
"certifi==2021.10.8",
|
||||||
|
"charset-normalizer==2.0.12",
|
||||||
|
"h11==0.12.0",
|
||||||
|
"httpcore==0.14.7",
|
||||||
|
"httpx==0.21.3",
|
||||||
|
"idna==3.3",
|
||||||
|
"python-dateutil==2.8.2",
|
||||||
|
"rfc3986[idna2008]==1.5.0",
|
||||||
|
"six==1.16.0",
|
||||||
|
"sniffio==1.2.0",
|
||||||
|
"windmill-api==1.5.0",
|
||||||
|
"wmill==1.5.0"
|
||||||
|
]
|
||||||
|
}
|
||||||
19
community/scripts/u/bot/send_email_gmail.py
Executable file
19
community/scripts/u/bot/send_email_gmail.py
Executable file
@@ -0,0 +1,19 @@
|
|||||||
|
from wmill import get_resource
|
||||||
|
import smtplib
|
||||||
|
|
||||||
|
|
||||||
|
def main(smtp_config: dict, to: str, subject: str, body: str):
|
||||||
|
server = smtplib.SMTP(host=smtp_config["host"], port=smtp_config["port"])
|
||||||
|
server.ehlo()
|
||||||
|
server.starttls()
|
||||||
|
server.login(smtp_config["user"], smtp_config["password"])
|
||||||
|
|
||||||
|
server.sendmail(
|
||||||
|
smtp_config["user"],
|
||||||
|
to,
|
||||||
|
f"""Subject: {subject}
|
||||||
|
|
||||||
|
{body}
|
||||||
|
""")
|
||||||
|
|
||||||
|
|
||||||
43
community/scripts/u/bot/send_slack_image.json
Executable file
43
community/scripts/u/bot/send_slack_image.json
Executable file
@@ -0,0 +1,43 @@
|
|||||||
|
{
|
||||||
|
"summary": "Send image to slack",
|
||||||
|
"description": "Send a base64 image to a slack channel or user. Choose one of user or channel but not both.",
|
||||||
|
"schema": {
|
||||||
|
"type": "object",
|
||||||
|
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||||
|
"required": [
|
||||||
|
"slack_resource",
|
||||||
|
"img_data"
|
||||||
|
],
|
||||||
|
"properties": {
|
||||||
|
"slack_resource": {
|
||||||
|
"description": "",
|
||||||
|
"type": "object",
|
||||||
|
"default": null,
|
||||||
|
"format": "resource-slack"
|
||||||
|
},
|
||||||
|
"img_data": {
|
||||||
|
"type": "string",
|
||||||
|
"default": null,
|
||||||
|
"description": "",
|
||||||
|
"contentEncoding": "base64",
|
||||||
|
"format": ""
|
||||||
|
},
|
||||||
|
"channel": {
|
||||||
|
"type": "string",
|
||||||
|
"default": null,
|
||||||
|
"description": "",
|
||||||
|
"format": ""
|
||||||
|
},
|
||||||
|
"user": {
|
||||||
|
"type": "string",
|
||||||
|
"default": null,
|
||||||
|
"description": "",
|
||||||
|
"format": ""
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"is_template": false,
|
||||||
|
"lock": [
|
||||||
|
"slack-sdk==3.15.2"
|
||||||
|
]
|
||||||
|
}
|
||||||
37
community/scripts/u/bot/send_slack_image.py
Executable file
37
community/scripts/u/bot/send_slack_image.py
Executable file
@@ -0,0 +1,37 @@
|
|||||||
|
import os
|
||||||
|
from slack_sdk.web.client import WebClient
|
||||||
|
from datetime import date, datetime
|
||||||
|
|
||||||
|
|
||||||
|
def main(
|
||||||
|
slack_resource: dict,
|
||||||
|
img_data: bytes,
|
||||||
|
channel: str = None,
|
||||||
|
user: str = None,
|
||||||
|
):
|
||||||
|
slack_client = WebClient(token=slack_resource["token"])
|
||||||
|
|
||||||
|
if channel == "":
|
||||||
|
channel = None
|
||||||
|
if user == "":
|
||||||
|
user = None
|
||||||
|
|
||||||
|
if channel is None and user is None or (channel is not None and user is not None):
|
||||||
|
raise Exception("one and only one of channel or user need to be set")
|
||||||
|
|
||||||
|
if user is not None:
|
||||||
|
channel = "@{}".format(user)
|
||||||
|
|
||||||
|
tmp_image = "image.png"
|
||||||
|
with open(tmp_image, "wb") as fh:
|
||||||
|
fh.write(img_data)
|
||||||
|
slack_client.files_upload(
|
||||||
|
file=tmp_image, initial_comment="Weekly report", channels=channel
|
||||||
|
)
|
||||||
|
|
||||||
|
now = datetime.now()
|
||||||
|
current_time = now.strftime("%H:%M")
|
||||||
|
today = date.today()
|
||||||
|
print("Sent to slack successfully on", today, current_time)
|
||||||
|
|
||||||
|
|
||||||
49
community/scripts/u/bot/table_to_pie.json
Executable file
49
community/scripts/u/bot/table_to_pie.json
Executable file
@@ -0,0 +1,49 @@
|
|||||||
|
{
|
||||||
|
"summary": "Table to pie plot",
|
||||||
|
"description": "Transform a table of rows into a pieplot image and return its base64",
|
||||||
|
"schema": {
|
||||||
|
"type": "object",
|
||||||
|
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||||
|
"required": [
|
||||||
|
"rows"
|
||||||
|
],
|
||||||
|
"properties": {
|
||||||
|
"rows": {
|
||||||
|
"type": "array",
|
||||||
|
"default": null,
|
||||||
|
"description": ""
|
||||||
|
},
|
||||||
|
"title": {
|
||||||
|
"type": "string",
|
||||||
|
"default": "",
|
||||||
|
"description": ""
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"is_template": false,
|
||||||
|
"lock": [
|
||||||
|
"anyio==3.5.0",
|
||||||
|
"attrs==21.4.0",
|
||||||
|
"certifi==2021.10.8",
|
||||||
|
"charset-normalizer==2.0.12",
|
||||||
|
"cycler==0.11.0",
|
||||||
|
"fonttools==4.32.0",
|
||||||
|
"h11==0.12.0",
|
||||||
|
"httpcore==0.14.7",
|
||||||
|
"httpx==0.22.0",
|
||||||
|
"idna==3.3",
|
||||||
|
"kiwisolver==1.4.2",
|
||||||
|
"matplotlib==3.5.1",
|
||||||
|
"numpy==1.22.3",
|
||||||
|
"packaging==21.3",
|
||||||
|
"pillow==9.1.0",
|
||||||
|
"psycopg2-binary==2.9.3",
|
||||||
|
"pyparsing==3.0.8",
|
||||||
|
"python-dateutil==2.8.2",
|
||||||
|
"rfc3986[idna2008]==1.5.0",
|
||||||
|
"six==1.16.0",
|
||||||
|
"sniffio==1.2.0",
|
||||||
|
"windmill-api==1.2.0",
|
||||||
|
"wmill==1.2.0"
|
||||||
|
]
|
||||||
|
}
|
||||||
38
community/scripts/u/bot/table_to_pie.py
Executable file
38
community/scripts/u/bot/table_to_pie.py
Executable file
@@ -0,0 +1,38 @@
|
|||||||
|
import wmill
|
||||||
|
|
||||||
|
import matplotlib.pyplot as plt
|
||||||
|
import base64
|
||||||
|
from datetime import date
|
||||||
|
|
||||||
|
|
||||||
|
client = wmill.Client()
|
||||||
|
|
||||||
|
|
||||||
|
def main(rows: list, title: str = ""):
|
||||||
|
file_output = "output.png"
|
||||||
|
|
||||||
|
fig, ax = plt.subplots(figsize=(6, 3), subplot_kw=dict(aspect="equal"))
|
||||||
|
|
||||||
|
# Get labels and data from list
|
||||||
|
labels = [x[0] for x in rows]
|
||||||
|
data = [x[1] for x in rows]
|
||||||
|
|
||||||
|
# Create donut chart
|
||||||
|
plt.pie(
|
||||||
|
data,
|
||||||
|
labels=labels,
|
||||||
|
autopct="%.0f%%",
|
||||||
|
wedgeprops={"linewidth": 5, "edgecolor": "white"},
|
||||||
|
)
|
||||||
|
my_circle = plt.Circle((0, 0), 0.38, color="white")
|
||||||
|
p = plt.gcf()
|
||||||
|
p.gca().add_artist(my_circle)
|
||||||
|
ax.set_title(title)
|
||||||
|
|
||||||
|
# Create png
|
||||||
|
plt.savefig(file_output)
|
||||||
|
|
||||||
|
with open(file_output, "rb") as image_file:
|
||||||
|
encoded_string = base64.b64encode(image_file.read()).decode("ascii")
|
||||||
|
|
||||||
|
return {"png": encoded_string}
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user