Compare commits
364 Commits
activitypu
...
main
Author | SHA1 | Date | |
---|---|---|---|
bdd62cbcfd | |||
4f4a71bf72 | |||
239adc4816 | |||
15bfb43965 | |||
384cf75400 | |||
89ffa94553 | |||
e03ceed668 | |||
31d6f41276 | |||
fe24bcef1e | |||
29d0af6e5b | |||
045a0af4a2 | |||
8446c209b3 | |||
c6fdc1d537 | |||
397ec5072b | |||
82f0a1eb6d | |||
e174abce33 | |||
d72038212f | |||
957cbebdd2 | |||
fdb363abe4 | |||
859d78cf5d | |||
f0a0a55bce | |||
0019f4e019 | |||
af07226227 | |||
46965c0040 | |||
97584a8d91 | |||
bed153b5f8 | |||
a1d05684ed | |||
5f903d61b1 | |||
0d942dc864 | |||
870a53e956 | |||
ff4f48ba00 | |||
e9fb964b51 | |||
1bb38a8e09 | |||
b8f7622b9f | |||
dc9b388465 | |||
e866895a84 | |||
72d164f394 | |||
210a984a07 | |||
3927cc6d67 | |||
92513715bd | |||
b5b86d599a | |||
8a69a1b21d | |||
0f47a25529 | |||
716ca31a60 | |||
e3fd2c8f27 | |||
d56c201ec7 | |||
65e059655f | |||
dd7af64508 | |||
06bd0fb8ac | |||
8c1145e54f | |||
5692258cc1 | |||
7dac76558d | |||
229eec89ea | |||
81bed97d42 | |||
402a25d980 | |||
f021914089 | |||
a8a000ae55 | |||
980a5bb14b | |||
8afd7c5694 | |||
17806d410b | |||
9cd1ae4628 | |||
d037aa6a9f | |||
a7f87e0b76 | |||
6ddf2d4ae7 | |||
059fc0d966 | |||
f8f06f8b68 | |||
e74a7461fa | |||
22ebf35382 | |||
2398ef5cf9 | |||
c0a5f32060 | |||
849ff0a1e9 | |||
f3b3ce3d57 | |||
032e737ab9 | |||
1428c83050 | |||
6ad9105c82 | |||
c060a6fc41 | |||
48db95ff6e | |||
ddb723ab88 | |||
933e601572 | |||
fddfa5df0b | |||
39b65dd806 | |||
047d0b25d8 | |||
d08e7826b9 | |||
41493c3534 | |||
845178997d | |||
0d6febbde6 | |||
ccc6fbe13a | |||
40f7e25d8f | |||
e85ad4f4bc | |||
934086251b | |||
ba9713e3eb | |||
c25a2c5fe2 | |||
d22dbd7629 | |||
2e5d25c6f2 | |||
116224d784 | |||
9920a0a596 | |||
ec32489de5 | |||
78a197d5e4 | |||
fad3bc835b | |||
9d3ed248b3 | |||
14ca22f3e9 | |||
2fe05b2118 | |||
f8808bde3d | |||
2a55cfcc2e | |||
007e4822a6 | |||
3df577e666 | |||
a85f0de85a | |||
25ed716849 | |||
350ab15306 | |||
a22c2d7d16 | |||
0b041b04ad | |||
b1d9a41f9d | |||
33241747cd | |||
12f985b7aa | |||
9eff558ebf | |||
bad485ac1d | |||
bfd828f520 | |||
7647542421 | |||
2b37756567 | |||
8abdf00ef8 | |||
062d5f94e4 | |||
9d2574bcd6 | |||
d4ccd62a34 | |||
522c13cd75 | |||
1179d8859f | |||
6e15952ffc | |||
20b94e553d | |||
572d39e947 | |||
dcf44f6b1d | |||
4b410ec31e | |||
f63866a04c | |||
104879ee27 | |||
f94769e228 | |||
88c0b44444 | |||
3403134072 | |||
e6178c8a72 | |||
58bf8ca74e | |||
6cfcf0c5a1 | |||
65b379f162 | |||
4bf03c7c67 | |||
aeb60dd840 | |||
3b2c1e7f4c | |||
a2cde7de81 | |||
b14f438597 | |||
ac97481e6e | |||
1129a9df48 | |||
afc32578cf | |||
f44d56182b | |||
14f930af13 | |||
9ed84493cc | |||
ee11984a00 | |||
dcb9453ccd | |||
45725f52c1 | |||
5e108cf8c8 | |||
1406f9c4c8 | |||
3e79d950b1 | |||
ea792bd75d | |||
46ed954e53 | |||
2f92b3b883 | |||
e51f3af984 | |||
1e85112f6d | |||
c3598dfa5c | |||
3aa70c48e7 | |||
c368e675b4 | |||
68351f84e3 | |||
e46d1c63a7 | |||
29aba03ba8 | |||
e387c0e05a | |||
164ca73913 | |||
d70858ffb7 | |||
073335a322 | |||
450efe8b36 | |||
ba1ba5ed94 | |||
80cf4d3b4c | |||
79299ab978 | |||
46e2f76778 | |||
a00de971ae | |||
8eb4eb4bd9 | |||
78c3908bf9 | |||
c394de8286 | |||
3b691fed81 | |||
00f4e7a8b9 | |||
dd6d5ab18a | |||
aed7813d62 | |||
b0fb8af468 | |||
17d0ae31a8 | |||
822f2cef84 | |||
ca300ced7a | |||
4c61879883 | |||
aa6afec3b7 | |||
e780dff8cc | |||
a0b655d69a | |||
b3a44e17bc | |||
ee4b3ef439 | |||
cda8874547 | |||
4f53120d11 | |||
fed94fa543 | |||
b93e048509 | |||
9f09905f31 | |||
20d9d3391c | |||
961ca473c7 | |||
64297a2d28 | |||
84c965b175 | |||
5e528d2a21 | |||
7be01d0955 | |||
2812bb3a2d | |||
835777e562 | |||
a3a04fa8e9 | |||
d77dbc12ab | |||
cf9601de90 | |||
f36694c54e | |||
8473d2c480 | |||
00946f92d9 | |||
41f8dee19e | |||
4a6c96edb5 | |||
46be02067f | |||
8f498cfe1c | |||
369fd67101 | |||
a2128c7b35 | |||
0b697d22dc | |||
9fd8ea9755 | |||
4c899c2309 | |||
77fc0a502b | |||
781d3e23dd | |||
92860ea082 | |||
9f75631d58 | |||
bdb4db100e | |||
e68ebface7 | |||
27e7a11eba | |||
ee5cd5dea5 | |||
55f21bd18e | |||
be5cb2cb12 | |||
d3b9142e4f | |||
21ee690409 | |||
0a56844784 | |||
ee6584a396 | |||
ac6020cb8e | |||
958edd7556 | |||
2d680d7142 | |||
ecbfb2d9ca | |||
ec38525a43 | |||
d90b40d6f2 | |||
edd9c1e3ee | |||
f701bc46d8 | |||
9e9491e064 | |||
a6de05a0ca | |||
d4f56345c6 | |||
447f76e807 | |||
a583234743 | |||
30ebc7d22d | |||
49ccdbd8ab | |||
57de689815 | |||
55325dfe8b | |||
214b80b72c | |||
084ae20664 | |||
4b656d31e1 | |||
2f21d8fe2c | |||
196587616a | |||
22c80781d4 | |||
57032a60f0 | |||
d7a737e814 | |||
be97df6331 | |||
87e53b42a6 | |||
|
d1a6a83467 | ||
|
544f202e11 | ||
|
b0d32b6578 | ||
2adcbaa7c2 | |||
7dd30c0a91 | |||
6dacf7a097 | |||
c22f0170ad | |||
3e87e1bd83 | |||
6d2bebd0c6 | |||
b14c6db62c | |||
8e3c886a2c | |||
4719b88d49 | |||
89ba589d66 | |||
ab3a933164 | |||
93002d44d8 | |||
e121bfa4ca | |||
f77df1a053 | |||
67a404d981 | |||
3a8f10e2a8 | |||
d875571cf4 | |||
555944140a | |||
bce95d1d21 | |||
2ad061584a | |||
ace3527c66 | |||
8506d221c0 | |||
b7d81433fb | |||
ff8d17690d | |||
8c71656aa4 | |||
c78fefc0d4 | |||
6d9a1edc5c | |||
7547aa37fc | |||
7e2fea2eb8 | |||
851495ef57 | |||
d65f7aaf88 | |||
56d437b773 | |||
126b0dcf78 | |||
f41326b6ac | |||
b57f4dce55 | |||
8f5a7c81e9 | |||
0aaccce21f | |||
adc4f163b7 | |||
4b0014ba83 | |||
e1e49b06da | |||
9fcd193a87 | |||
fb5559158f | |||
e4bdadba6e | |||
080d604a17 | |||
a31dca4006 | |||
2a3330e38d | |||
80ce9beef5 | |||
4161397d56 | |||
ce81d99cdf | |||
ce32c85b64 | |||
8f8e799323 | |||
c9a5593fe0 | |||
0575d795ea | |||
5220c1a484 | |||
99e4a01ef2 | |||
8b482d6761 | |||
9fcb902ee2 | |||
cb67d7afe0 | |||
e100a8520c | |||
c2fbd272fd | |||
5970422d58 | |||
1124e0af52 | |||
919382f4e7 | |||
75f10cb225 | |||
4d34712a86 | |||
28247b5c06 | |||
c084ac01d2 | |||
3adb9803f4 | |||
673f647001 | |||
941cd0f5aa | |||
34db6374e1 | |||
b0641dfe40 | |||
6863bdb1c4 | |||
347791c878 | |||
6ec29a4a35 | |||
b1764503ab | |||
4e92d717d5 | |||
8c25b953b5 | |||
f3638d918d | |||
75b4d12173 | |||
790abd4b5d | |||
78227153f7 | |||
a196954e1c | |||
b044f9d8bb | |||
5fcd32e7cd | |||
c3e8680eea | |||
cf4308fbfb | |||
7ffc442c25 | |||
55ef989a0e | |||
a0c099bcbc | |||
e33f8bbff9 | |||
3e3c3e12f6 | |||
3b529729c9 | |||
4b4cbaa74f | |||
9b03662b17 | |||
|
52ac62d634 | ||
4a3e8b6077 | |||
0004a82f77 |
3
.dockerignore
Normal file
@ -0,0 +1,3 @@
|
|||||||
|
api/.venv
|
||||||
|
web/node_modules
|
||||||
|
*.pyc
|
2
.gitignore
vendored
Normal file
@ -0,0 +1,2 @@
|
|||||||
|
*.swp
|
||||||
|
.DS_Store
|
42
.woodpecker.yml
Normal file
@ -0,0 +1,42 @@
|
|||||||
|
steps:
|
||||||
|
buildweb:
|
||||||
|
group: build
|
||||||
|
image: node
|
||||||
|
when:
|
||||||
|
path: "web/**/*"
|
||||||
|
environment:
|
||||||
|
- VITE_API_URL=https://api.treadl.com
|
||||||
|
- VITE_IMAGINARY_URL=https://images.treadl.com
|
||||||
|
- VITE_SENTRY_DSN=https://7c88f77dd19c57bfb92bb9eb53e33c4b@o4508066290532352.ingest.de.sentry.io/4508075022090320
|
||||||
|
commands:
|
||||||
|
- cd web
|
||||||
|
- npm install
|
||||||
|
- npx vite build
|
||||||
|
|
||||||
|
buildapi:
|
||||||
|
group: build
|
||||||
|
image: woodpeckerci/plugin-docker-buildx
|
||||||
|
secrets: [docker_username, docker_password]
|
||||||
|
when:
|
||||||
|
path: "api/**/*"
|
||||||
|
settings:
|
||||||
|
repo: wilw/treadl-api
|
||||||
|
dockerfile: api/Dockerfile
|
||||||
|
context: api
|
||||||
|
platforms: linux/amd64
|
||||||
|
|
||||||
|
deployweb:
|
||||||
|
image: alpine
|
||||||
|
secrets: [ LINODE_ACCESS_KEY, LINODE_SECRET_ACCESS_KEY, BUNNY_KEY ]
|
||||||
|
when:
|
||||||
|
path: "web/**/*"
|
||||||
|
commands:
|
||||||
|
- cd web
|
||||||
|
- apk update
|
||||||
|
- apk add s3cmd curl
|
||||||
|
- s3cmd --configure --access_key=$LINODE_ACCESS_KEY --secret_key=$LINODE_SECRET_ACCESS_KEY --host=https://eu-central-1.linodeobjects.com --host-bucket="%(bucket)s.eu-central-1.linodeobjects.com" --dump-config > /root/.s3cfg
|
||||||
|
- s3cmd -c /root/.s3cfg sync --no-mime-magic --guess-mime-type dist/* s3://treadl.com
|
||||||
|
- 'curl -X POST -H "AccessKey: $BUNNY_KEY" https://api.bunny.net/pullzone/782753/purgeCache'
|
||||||
|
|
||||||
|
when:
|
||||||
|
branch: main
|
2
LICENSE
@ -1,4 +1,4 @@
|
|||||||
Copyright (c) 2021 Seastorm Limited. All rights reserved.
|
Copyright (c) 2022 Will Webberley. All rights reserved.
|
||||||
|
|
||||||
Redistribution and use in source and binary forms, with or without modification,
|
Redistribution and use in source and binary forms, with or without modification,
|
||||||
are permitted provided that the following conditions are met:
|
are permitted provided that the following conditions are met:
|
||||||
|
93
README.md
@ -1,7 +1,96 @@
|
|||||||
# Treadl
|
# Treadl
|
||||||
|
|
||||||
This is a monorepo containing the code for the web front-end and web API for the Treadl web application.
|
This is a monorepo containing the code for the web and mobile front-ends and web API for the Treadl platform.
|
||||||
|
|
||||||
|
|
||||||
|
## Deploying your own version of Treadl
|
||||||
|
|
||||||
|
### Run with Docker (recommended)
|
||||||
|
|
||||||
|
We publish and maintain a [Docker image](https://hub.docker.com/r/wilw/treadl) for Treadl, which is the easiest way to get started.
|
||||||
|
|
||||||
|
We recommend using Docker Compose and our [template `docker-compose.yml`](https://git.wilw.dev/wilw/treadl/src/branch/main/docker/docker-compose.yml) to configure the app and the MongoDB database. Download this file to your computer and then run `docker compose up` to start Treadl.
|
||||||
|
|
||||||
|
In production, it is very important to change the values in the file's `environment` block to suit your own setup. We also strongly recommend the use of a reverse-proxy to handle TLS connections to the app.
|
||||||
|
|
||||||
|
|
||||||
|
### Alternative deployment
|
||||||
|
|
||||||
|
In scenarios where you want more control over the deployment, or you are more concerned with scalability, you may wish to use a more manual approach.
|
||||||
|
|
||||||
|
In this case you'll need to:
|
||||||
|
- Launch (or re-use) a MongoDB cluster/instance
|
||||||
|
- Provision a server or service for running the Flask app (in the `api/` directory), ensuring all dependencies are installed and that it runs with the needed [environment variables](https://git.wilw.dev/wilw/treadl/src/branch/main/api/envfile.template)
|
||||||
|
- Build the web front-end (with `npx vite build` using your needed [environment variables](https://git.wilw.dev/wilw/treadl/src/branch/main/web/.env), having installed dependencies with `npm install`) and host the resulting `dist/` directory on a server or object store.
|
||||||
|
|
||||||
|
|
||||||
|
### S3-compatible object storage
|
||||||
|
|
||||||
|
Treadl uses S3-compatible object storage for storing user uploads. If you want to allow file uploads (apart from WIF files, which are processed directly), you should create and configure a bucket for Treadl to use.
|
||||||
|
|
||||||
|
Hosted options:
|
||||||
|
|
||||||
|
* [Amazon S3](https://aws.amazon.com/s3)
|
||||||
|
* [Linode Object Storage](https://www.linode.com/products/object-storage)
|
||||||
|
* [DigitalOcean Spaces](https://www.digitalocean.com/products/spaces)
|
||||||
|
|
||||||
|
Self-hosted options:
|
||||||
|
|
||||||
|
* [MinIO](https://min.io/download)
|
||||||
|
|
||||||
|
Once you have a bucket, generate some access keys for the bucket that will enable Treadl to read from and write to it. Ensure you make a record of the following for inclusion in your environment file/variables:
|
||||||
|
|
||||||
|
* Bucket name: The name of the S3-compatible bucket you created
|
||||||
|
* Endpoint URL: The endpoint for your bucket. This helps Treadl understand which provider you are using.
|
||||||
|
* Access key: The "username" or access key for your bucket
|
||||||
|
* Secret access key: The "password" or secret access key for the bucket
|
||||||
|
|
||||||
|
_Note: assets in your bucket should be public. Treadl does not currently used signed requests to access uploaded files._
|
||||||
|
|
||||||
|
|
||||||
|
## Running Treadl locally in development mode
|
||||||
|
|
||||||
|
To run Treadl locally, first ensure you have the needed software installed:
|
||||||
|
|
||||||
|
- Python ^3.12
|
||||||
|
- Node.js (we recommend v22.x)
|
||||||
|
- Docker (we use this for the Mongo database)
|
||||||
|
- It can be installed via the Docker website or your package manager
|
||||||
|
- Ensure the Docker service is running
|
||||||
|
- [Taskfile](https://taskfile.dev) (convenience tool for running tasks)
|
||||||
|
- This can be installed using `brew install go-task`
|
||||||
|
|
||||||
|
To begin, clone this repository to your computer:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git clone https://git.wilw.dev/wilw/treadl.git
|
||||||
|
```
|
||||||
|
|
||||||
|
Next, initialise the project by installing dependencies and creating an environment file for the API:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
task init
|
||||||
|
```
|
||||||
|
|
||||||
|
This generates a 'envfile' in your 'api' directory. You can edit this as needed (though the defaults should allow you to at least launch the app). Note: if you run this command again then any changes you made to your `envfile` will be overwritten.
|
||||||
|
|
||||||
|
Finally, you can start the API and web UI by running:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
task
|
||||||
|
```
|
||||||
|
|
||||||
|
Note: this command also starts the MongoDB database on port 27017. If the DB is already running, you'll see errors reported, but the API and web will still be launched.
|
||||||
|
|
||||||
|
You can now navigate to [http://localhost:8002](http://localhost:8002) to start using the app.
|
||||||
|
|
||||||
|
If you pull updates from the repository in the future (e.g. with `git pull`) you may need to ensure your dependencies are up-to-date before starting the app again. This can be done with:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
task install-deps
|
||||||
|
```
|
||||||
|
|
||||||
|
|
||||||
## Contributions
|
## Contributions
|
||||||
|
|
||||||
Contributions to the core project are certainly welcomed. Please [get in touch](https://wilw.dev) for an invitation to join this repository.
|
Contributions to the core project are certainly welcomed. Please [get in touch with the developer](https://wilw.dev) for an invitation to join this repository.
|
||||||
|
118
Taskfile.yml
Normal file
@ -0,0 +1,118 @@
|
|||||||
|
version: '3'
|
||||||
|
|
||||||
|
vars:
|
||||||
|
VENV: ".venv/bin/activate"
|
||||||
|
|
||||||
|
tasks:
|
||||||
|
default:
|
||||||
|
desc: Run web bundler and API
|
||||||
|
deps:
|
||||||
|
- start-db
|
||||||
|
- run-api
|
||||||
|
- run-web
|
||||||
|
|
||||||
|
run-web:
|
||||||
|
desc: Run web frontend
|
||||||
|
dir: 'web'
|
||||||
|
cmds:
|
||||||
|
- echo "[Web] Starting React app..."
|
||||||
|
- npx vite --port 8002
|
||||||
|
|
||||||
|
run-api:
|
||||||
|
desc: Run API server
|
||||||
|
dir: 'api'
|
||||||
|
dotenv: ['envfile']
|
||||||
|
cmds:
|
||||||
|
- echo "[FLASK] Starting Flask app..."
|
||||||
|
- bash -c "source {{.VENV}} && flask run --debug"
|
||||||
|
|
||||||
|
start-db:
|
||||||
|
desc: Start database
|
||||||
|
ignore_error: true
|
||||||
|
cmds:
|
||||||
|
- echo "[DB] Starting database..."
|
||||||
|
- docker run --rm -d --name mongo -v ~/.mongo:/data/db -p 27017:27017 mongo:6
|
||||||
|
|
||||||
|
init:
|
||||||
|
desc: Initialize project
|
||||||
|
cmds:
|
||||||
|
- task: install-deps
|
||||||
|
- cp api/envfile.template api/envfile
|
||||||
|
|
||||||
|
install-deps:
|
||||||
|
desc: Install all dependencies
|
||||||
|
deps:
|
||||||
|
- install-deps-web
|
||||||
|
- install-deps-api
|
||||||
|
|
||||||
|
install-deps-web:
|
||||||
|
desc: Install web dependencies
|
||||||
|
dir: 'web'
|
||||||
|
cmds:
|
||||||
|
- echo "[Web] Installing dependencies..."
|
||||||
|
- npm install
|
||||||
|
|
||||||
|
install-deps-api:
|
||||||
|
desc: Install API dependencies
|
||||||
|
dir: 'api'
|
||||||
|
cmds:
|
||||||
|
- echo "[FLASK] Installing dependencies..."
|
||||||
|
- cmd: python3.12 -m venv .venv
|
||||||
|
ignore_error: true
|
||||||
|
- bash -c "source {{.VENV}} && pip install poetry"
|
||||||
|
- bash -c "source {{.VENV}} && poetry install"
|
||||||
|
|
||||||
|
lint:
|
||||||
|
desc: Lint all
|
||||||
|
deps:
|
||||||
|
- lint-web
|
||||||
|
- lint-api
|
||||||
|
|
||||||
|
lint-web:
|
||||||
|
desc: Lint web frontend
|
||||||
|
dir: 'web'
|
||||||
|
cmds:
|
||||||
|
- echo "[Web] Linting React app..."
|
||||||
|
- npx standard --fix
|
||||||
|
|
||||||
|
lint-api:
|
||||||
|
desc: Lint API server
|
||||||
|
dir: 'api'
|
||||||
|
cmds:
|
||||||
|
- echo "[FLASK] Linting Flask app..."
|
||||||
|
- bash -c "source {{.VENV}} && ruff format ."
|
||||||
|
- bash -c "source {{.VENV}} && ruff check --fix ."
|
||||||
|
|
||||||
|
clean:
|
||||||
|
desc: Remove all dependencies
|
||||||
|
cmds:
|
||||||
|
- rm -rf web/node_modules
|
||||||
|
- rm -rf api/.venv
|
||||||
|
|
||||||
|
build-docker:
|
||||||
|
desc: Build all-in-one Docker image
|
||||||
|
cmds:
|
||||||
|
- echo "Building Docker image..."
|
||||||
|
- docker build -f docker/Dockerfile -t wilw/treadl --platform linux/amd64,linux/arm64 .
|
||||||
|
|
||||||
|
deploy:
|
||||||
|
desc: Deploy all
|
||||||
|
deps:
|
||||||
|
- deploy-web
|
||||||
|
- deploy-api
|
||||||
|
|
||||||
|
deploy-web:
|
||||||
|
desc: Deploy web front-end
|
||||||
|
dir: 'web'
|
||||||
|
cmds:
|
||||||
|
- npm install
|
||||||
|
- npx vite build
|
||||||
|
- aws --profile personal s3 sync dist s3://treadl.com
|
||||||
|
- 'curl -X POST -H "AccessKey: $BUNNY_PERSONAL" https://api.bunny.net/pullzone/782753/purgeCache'
|
||||||
|
|
||||||
|
deploy-api:
|
||||||
|
desc: Deploy API
|
||||||
|
dir: 'api'
|
||||||
|
cmds:
|
||||||
|
- docker build -t wilw/treadl-api --platform linux/amd64 .
|
||||||
|
- docker push wilw/treadl-api
|
2
api/.gitignore
vendored
@ -7,3 +7,5 @@ __pycache__/
|
|||||||
config-prod.yml
|
config-prod.yml
|
||||||
envfile
|
envfile
|
||||||
firebase.json
|
firebase.json
|
||||||
|
.DS_Store
|
||||||
|
migration_projects/
|
@ -1,11 +1,16 @@
|
|||||||
FROM python:3.7.7-slim-buster
|
FROM amd64/python:3.12-slim
|
||||||
|
|
||||||
# set work directory
|
# set work directory
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|
||||||
# install dependencies
|
# Install dependencies
|
||||||
COPY . /app/
|
|
||||||
RUN pip install poetry
|
RUN pip install poetry
|
||||||
RUN poetry export -f requirements.txt | pip install -r /dev/stdin
|
COPY poetry.lock .
|
||||||
|
COPY pyproject.toml .
|
||||||
|
RUN poetry config virtualenvs.create false --local
|
||||||
|
RUN poetry install
|
||||||
|
|
||||||
|
# Add remaining files
|
||||||
|
COPY . /app/
|
||||||
|
|
||||||
CMD ["gunicorn" , "-b", "0.0.0.0:8000", "app:app"]
|
CMD ["gunicorn" , "-b", "0.0.0.0:8000", "app:app"]
|
||||||
|
@ -1,36 +1,3 @@
|
|||||||
# Treadl web API
|
# Treadl web API
|
||||||
|
|
||||||
This directory contains the code for the back-end Treadl API.
|
This directory contains the code for the back-end Treadl API.
|
||||||
|
|
||||||
## Run locally
|
|
||||||
|
|
||||||
To run this code locally, first clone this repository and then;
|
|
||||||
|
|
||||||
Create and activate a Python virtual environment:
|
|
||||||
|
|
||||||
```shell
|
|
||||||
$ virtualenv -p python3 .venv # You only need to run this the first time
|
|
||||||
$ source .venv/bin/activate
|
|
||||||
```
|
|
||||||
|
|
||||||
Install dependencies (you may need to [install Poetry](https://python-poetry.org) first):
|
|
||||||
|
|
||||||
```shell
|
|
||||||
$ poetry install
|
|
||||||
```
|
|
||||||
|
|
||||||
Source the environment file:
|
|
||||||
|
|
||||||
```shell
|
|
||||||
$ source envfile # Note: you will need to create this file from the template
|
|
||||||
```
|
|
||||||
|
|
||||||
Run the API:
|
|
||||||
|
|
||||||
```shell
|
|
||||||
$ flask run
|
|
||||||
```
|
|
||||||
|
|
||||||
The API will now be available on port 2001.
|
|
||||||
|
|
||||||
Note that you will need a local instance of [MongoDB](https://www.mongodb.com) for the API to connect to.
|
|
321
api/api/accounts.py
Normal file
@ -0,0 +1,321 @@
|
|||||||
|
import datetime
|
||||||
|
import jwt
|
||||||
|
import bcrypt
|
||||||
|
import re
|
||||||
|
import os
|
||||||
|
from bson.objectid import ObjectId
|
||||||
|
from util import database, mail, util
|
||||||
|
|
||||||
|
jwt_secret = os.environ["JWT_SECRET"]
|
||||||
|
MIN_PASSWORD_LENGTH = 8
|
||||||
|
|
||||||
|
|
||||||
|
def register(username, email, password, how_find_us):
|
||||||
|
if not username or len(username) < 4 or not email or len(email) < 6:
|
||||||
|
raise util.errors.BadRequest("Your username or email is too short or invalid.")
|
||||||
|
username = username.lower()
|
||||||
|
email = email.lower()
|
||||||
|
if not re.match("^[a-z0-9_]+$", username):
|
||||||
|
raise util.errors.BadRequest(
|
||||||
|
"Usernames can only contain letters, numbers, and underscores"
|
||||||
|
)
|
||||||
|
if not password or len(password) < MIN_PASSWORD_LENGTH:
|
||||||
|
raise util.errors.BadRequest(
|
||||||
|
"Your password should be at least {0} characters.".format(
|
||||||
|
MIN_PASSWORD_LENGTH
|
||||||
|
)
|
||||||
|
)
|
||||||
|
db = database.get_db()
|
||||||
|
existingUser = db.users.find_one(
|
||||||
|
{"$or": [{"username": username}, {"email": email}]}
|
||||||
|
)
|
||||||
|
if existingUser:
|
||||||
|
raise util.errors.BadRequest(
|
||||||
|
"An account with this username or email already exists."
|
||||||
|
)
|
||||||
|
|
||||||
|
try:
|
||||||
|
hashed_password = bcrypt.hashpw(password.encode("utf-8"), bcrypt.gensalt())
|
||||||
|
result = db.users.insert_one(
|
||||||
|
{
|
||||||
|
"username": username,
|
||||||
|
"email": email,
|
||||||
|
"password": hashed_password,
|
||||||
|
"createdAt": datetime.datetime.now(),
|
||||||
|
"subscriptions": {
|
||||||
|
"email": [
|
||||||
|
"groups.invited",
|
||||||
|
"groups.joinRequested",
|
||||||
|
"groups.joined",
|
||||||
|
"messages.replied",
|
||||||
|
"projects.commented",
|
||||||
|
]
|
||||||
|
},
|
||||||
|
}
|
||||||
|
)
|
||||||
|
mail.send(
|
||||||
|
{
|
||||||
|
"to": os.environ.get("ADMIN_EMAIL"),
|
||||||
|
"subject": "{} signup".format(os.environ.get("APP_NAME")),
|
||||||
|
"text": "A new user signed up with username {0} and email {1}, discovered from {2}".format(
|
||||||
|
username, email, how_find_us
|
||||||
|
),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
mail.send(
|
||||||
|
{
|
||||||
|
"to": email,
|
||||||
|
"subject": "Welcome to {}!".format(os.environ.get("APP_NAME")),
|
||||||
|
"text": """Dear {0},
|
||||||
|
|
||||||
|
Welcome to {3}! We won't send you many emails but we just want to introduce ourselves and to give you some tips to help you get started.
|
||||||
|
|
||||||
|
LOGGING-IN
|
||||||
|
|
||||||
|
To login to your account please visit {1} and click Login. Use your username ({0}) and password to get back into your account.
|
||||||
|
|
||||||
|
INTRODUCTION
|
||||||
|
|
||||||
|
{3} has been designed as a resource for weavers – not only for those working alone as individuals, but also for groups who wish to share ideas, design inspirations and weaving patterns. It is ideal for those looking for a depository to store their individual work, and also for groups such as guilds, teaching groups, or any other collaborative working partnerships.
|
||||||
|
Projects can be created within {3} using the integral WIF-compatible draft editor, or alternatively files can be imported from other design software along with supporting images and other information you may wish to be saved within the project file. Once complete, projects may be stored privately, shared within a closed group, or made public for other {3} users to see. The choice is yours!
|
||||||
|
|
||||||
|
{3} is free to use. For more information please visit our website at {1}.
|
||||||
|
|
||||||
|
GETTING STARTED
|
||||||
|
|
||||||
|
Creating a profile: You can add a picture, links to a personal website, and other social media accounts to tell others more about yourself.
|
||||||
|
|
||||||
|
Creating a group: You have the option to do things alone, or create a group. By clicking on the ‘Create a group’ button, you can name your group, and then invite members via email or directly through {3} if they are existing {3} users.
|
||||||
|
|
||||||
|
Creating a new project: When you are ready to create/store a project on the system, you are invited to give the project a name, and a brief description. You will then be taken to a ‘Welcome to your project’ screen, where if you click on ‘add something’, you have the option of creating a new weaving pattern directly inside {3} or you can simply import a WIF file from your preferred weaving software. Once imported, you can perform further editing within {3}, or you can add supporting picture files and any other additional information you wish to keep (eg weaving notes, yarn details etc).
|
||||||
|
|
||||||
|
Once complete you then have the option of saving the file privately, shared within a group, or made public for other {3} users to see.
|
||||||
|
|
||||||
|
We hope you enjoy using {3} and if you have any comments or feedback please tell us by emailing {2}!
|
||||||
|
|
||||||
|
Best wishes,
|
||||||
|
|
||||||
|
The {3} Team
|
||||||
|
""".format(
|
||||||
|
username,
|
||||||
|
os.environ.get("APP_URL"),
|
||||||
|
os.environ.get("CONTACT_EMAIL"),
|
||||||
|
os.environ.get("APP_NAME"),
|
||||||
|
),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
return {"token": generate_access_token(result.inserted_id)}
|
||||||
|
except Exception as e:
|
||||||
|
print(e)
|
||||||
|
raise util.errors.BadRequest(
|
||||||
|
"Unable to register your account. Please try again later"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def login(email, password):
|
||||||
|
db = database.get_db()
|
||||||
|
user = db.users.find_one(
|
||||||
|
{"$or": [{"username": email.lower()}, {"email": email.lower()}]}
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
if user and bcrypt.checkpw(password.encode("utf-8"), user["password"]):
|
||||||
|
return {"token": generate_access_token(user["_id"])}
|
||||||
|
else:
|
||||||
|
raise util.errors.BadRequest("Your username or password is incorrect.")
|
||||||
|
except Exception:
|
||||||
|
raise util.errors.BadRequest("Your username or password is incorrect.")
|
||||||
|
|
||||||
|
|
||||||
|
def logout(user):
|
||||||
|
db = database.get_db()
|
||||||
|
db.users.update_one(
|
||||||
|
{"_id": user["_id"]}, {"$pull": {"tokens.login": user["currentToken"]}}
|
||||||
|
)
|
||||||
|
return {"loggedOut": True}
|
||||||
|
|
||||||
|
|
||||||
|
def update_email(user, data):
|
||||||
|
if not data:
|
||||||
|
raise util.errors.BadRequest("Invalid request")
|
||||||
|
if "email" not in data:
|
||||||
|
raise util.errors.BadRequest("Invalid request")
|
||||||
|
if len(data["email"]) < 4:
|
||||||
|
raise util.errors.BadRequest("New email is too short")
|
||||||
|
db = database.get_db()
|
||||||
|
db.users.update_one({"_id": user["_id"]}, {"$set": {"email": data["email"]}})
|
||||||
|
mail.send(
|
||||||
|
{
|
||||||
|
"to": user["email"],
|
||||||
|
"subject": "Your email address has changed on {}".format(
|
||||||
|
os.environ.get("APP_NAME")
|
||||||
|
),
|
||||||
|
"text": "Dear {0},\n\nThis email is to let you know that we recently received a request to change your account email address on {2}. We have now made this change.\n\nThe new email address for your account is {1}.\n\nIf you think this is a mistake then please get in touch with us as soon as possible.".format(
|
||||||
|
user["username"],
|
||||||
|
data["email"],
|
||||||
|
os.environ.get("APP_NAME"),
|
||||||
|
),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
mail.send(
|
||||||
|
{
|
||||||
|
"to": data["email"],
|
||||||
|
"subject": "Your email address has changed on {}".format(
|
||||||
|
os.environ.get("APP_NAME")
|
||||||
|
),
|
||||||
|
"text": "Dear {0},\n\nThis email is to let you know that we recently received a request to change your account email address on {2}. We have now made this change.\n\nThe new email address for your account is {1}.\n\nIf you think this is a mistake then please get in touch with us as soon as possible.".format(
|
||||||
|
user["username"],
|
||||||
|
data["email"],
|
||||||
|
os.environ.get("APP_NAME"),
|
||||||
|
),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
return {"email": data["email"]}
|
||||||
|
|
||||||
|
|
||||||
|
def update_password(user, data):
|
||||||
|
if not data:
|
||||||
|
raise util.errors.BadRequest("Invalid request")
|
||||||
|
if "newPassword" not in data:
|
||||||
|
raise util.errors.BadRequest("Invalid request")
|
||||||
|
if len(data["newPassword"]) < MIN_PASSWORD_LENGTH:
|
||||||
|
raise util.errors.BadRequest(
|
||||||
|
"New password should be at least {0} characters long".format(
|
||||||
|
MIN_PASSWORD_LENGTH
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
db = database.get_db()
|
||||||
|
if "currentPassword" in data:
|
||||||
|
if not user:
|
||||||
|
raise util.errors.BadRequest("User context is required")
|
||||||
|
if not bcrypt.checkpw(
|
||||||
|
data["currentPassword"].encode("utf-8"), user["password"]
|
||||||
|
):
|
||||||
|
raise util.errors.BadRequest("Incorrect password")
|
||||||
|
elif "token" in data:
|
||||||
|
try:
|
||||||
|
id = jwt.decode(data["token"], jwt_secret, algorithms="HS256")["sub"]
|
||||||
|
user = db.users.find_one(
|
||||||
|
{"_id": ObjectId(id), "tokens.passwordReset": data["token"]}
|
||||||
|
)
|
||||||
|
if not user:
|
||||||
|
raise Exception
|
||||||
|
except Exception:
|
||||||
|
raise util.errors.BadRequest(
|
||||||
|
"There was a problem updating your password. Your token may be invalid or out of date"
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
raise util.errors.BadRequest("Current password or reset token is required")
|
||||||
|
if not user:
|
||||||
|
raise util.errors.BadRequest("Unable to change your password")
|
||||||
|
|
||||||
|
hashed_password = bcrypt.hashpw(
|
||||||
|
data["newPassword"].encode("utf-8"), bcrypt.gensalt()
|
||||||
|
)
|
||||||
|
db.users.update_one(
|
||||||
|
{"_id": user["_id"]},
|
||||||
|
{"$set": {"password": hashed_password}, "$unset": {"tokens.passwordReset": ""}},
|
||||||
|
)
|
||||||
|
|
||||||
|
mail.send(
|
||||||
|
{
|
||||||
|
"to_user": user,
|
||||||
|
"subject": "Your {} password has changed".format(
|
||||||
|
os.environ.get("APP_NAME")
|
||||||
|
),
|
||||||
|
"text": "Dear {0},\n\nThis email is to let you know that we recently received a request to change your account password on {1}. We have now made this change.\n\nIf you think this is a mistake then please login to change your password as soon as possible.".format(
|
||||||
|
user["username"],
|
||||||
|
os.environ.get("APP_NAME"),
|
||||||
|
),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
return {"passwordUpdated": True}
|
||||||
|
|
||||||
|
|
||||||
|
def delete(user, password):
|
||||||
|
if not password or not bcrypt.checkpw(password.encode("utf-8"), user["password"]):
|
||||||
|
raise util.errors.BadRequest("Incorrect password")
|
||||||
|
db = database.get_db()
|
||||||
|
for project in db.projects.find({"user": user["_id"]}):
|
||||||
|
db.objects.delete_many({"project": project["_id"]})
|
||||||
|
db.projects.delete_one({"_id": project["_id"]})
|
||||||
|
db.comments.delete_many({"user": user["_id"]})
|
||||||
|
db.users.update_many(
|
||||||
|
{"following.user": user["_id"]}, {"$pull": {"following": {"user": user["_id"]}}}
|
||||||
|
)
|
||||||
|
db.users.delete_one({"_id": user["_id"]})
|
||||||
|
return {"deletedUser": user["_id"]}
|
||||||
|
|
||||||
|
|
||||||
|
def generate_access_token(user_id):
|
||||||
|
payload = {
|
||||||
|
"exp": datetime.datetime.utcnow() + datetime.timedelta(days=30),
|
||||||
|
"iat": datetime.datetime.utcnow(),
|
||||||
|
"sub": str(user_id),
|
||||||
|
}
|
||||||
|
token = jwt.encode(payload, jwt_secret, algorithm="HS256")
|
||||||
|
db = database.get_db()
|
||||||
|
db.users.update_one({"_id": user_id}, {"$addToSet": {"tokens.login": token}})
|
||||||
|
return token
|
||||||
|
|
||||||
|
|
||||||
|
def get_user_context(token):
|
||||||
|
if not token:
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
payload = jwt.decode(token, jwt_secret, algorithms="HS256")
|
||||||
|
id = payload["sub"]
|
||||||
|
if id:
|
||||||
|
db = database.get_db()
|
||||||
|
user = db.users.find_one({"_id": ObjectId(id), "tokens.login": token})
|
||||||
|
db.users.update_one(
|
||||||
|
{"_id": user["_id"]}, {"$set": {"lastSeenAt": datetime.datetime.now()}}
|
||||||
|
)
|
||||||
|
user["currentToken"] = token
|
||||||
|
return user
|
||||||
|
except Exception as e:
|
||||||
|
print(e)
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def reset_password(data):
|
||||||
|
if not data or "email" not in data:
|
||||||
|
raise util.errors.BadRequest("Invalid request")
|
||||||
|
if len(data["email"]) < 5:
|
||||||
|
raise util.errors.BadRequest("Your email is too short")
|
||||||
|
db = database.get_db()
|
||||||
|
user = db.users.find_one({"email": data["email"].lower()})
|
||||||
|
if user:
|
||||||
|
payload = {
|
||||||
|
"exp": datetime.datetime.utcnow() + datetime.timedelta(days=1),
|
||||||
|
"iat": datetime.datetime.utcnow(),
|
||||||
|
"sub": str(user["_id"]),
|
||||||
|
}
|
||||||
|
token = jwt.encode(payload, jwt_secret, algorithm="HS256")
|
||||||
|
mail.send(
|
||||||
|
{
|
||||||
|
"to_user": user,
|
||||||
|
"subject": "Reset your password",
|
||||||
|
"text": "Dear {0},\n\nA password reset email was recently requested for your {2} account. If this was you and you want to continue, please follow the link below:\n\n{1}\n\nThis link will expire after 24 hours.\n\nIf this was not you, then someone may be trying to gain access to your account. We recommend using a strong and unique password for your account.".format(
|
||||||
|
user["username"],
|
||||||
|
"{}/password/reset?token={}".format(
|
||||||
|
os.environ.get("APP_URL"), token
|
||||||
|
),
|
||||||
|
os.environ.get("APP_NAME"),
|
||||||
|
),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
db.users.update_one(
|
||||||
|
{"_id": user["_id"]}, {"$set": {"tokens.passwordReset": token}}
|
||||||
|
)
|
||||||
|
return {"passwordResetEmailSent": True}
|
||||||
|
|
||||||
|
|
||||||
|
def update_push_token(user, data):
|
||||||
|
if not data or "pushToken" not in data:
|
||||||
|
raise util.errors.BadRequest("Push token is required")
|
||||||
|
db = database.get_db()
|
||||||
|
db.users.update_one(
|
||||||
|
{"_id": user["_id"]}, {"$set": {"pushToken": data["pushToken"]}}
|
||||||
|
)
|
||||||
|
return {"addedPushToken": data["pushToken"]}
|
190
api/api/activitypub.py
Normal file
@ -0,0 +1,190 @@
|
|||||||
|
import os
|
||||||
|
import re
|
||||||
|
from util import database, util
|
||||||
|
from api import uploads
|
||||||
|
|
||||||
|
DOMAIN = os.environ.get("APP_DOMAIN")
|
||||||
|
|
||||||
|
|
||||||
|
def webfinger(resource):
|
||||||
|
if not resource:
|
||||||
|
raise util.errors.BadRequest("Resource required")
|
||||||
|
resource = resource.lower()
|
||||||
|
exp = re.compile("acct:([a-z0-9_-]+)@([a-z0-9_\-\.]+)", re.IGNORECASE)
|
||||||
|
matches = exp.findall(resource)
|
||||||
|
if not matches or not matches[0]:
|
||||||
|
raise util.errors.BadRequest("Resource invalid")
|
||||||
|
username, host = matches[0]
|
||||||
|
if not username or not host:
|
||||||
|
raise util.errors.BadRequest("Resource invalid")
|
||||||
|
if host != DOMAIN:
|
||||||
|
raise util.errors.NotFound("Host unknown")
|
||||||
|
|
||||||
|
db = database.get_db()
|
||||||
|
user = db.users.find_one({"username": username})
|
||||||
|
if not user:
|
||||||
|
raise util.errors.NotFound("User unknown")
|
||||||
|
|
||||||
|
return {
|
||||||
|
"subject": resource,
|
||||||
|
"aliases": [
|
||||||
|
"https://{}/{}".format(DOMAIN, username),
|
||||||
|
"https://{}/u/{}".format(DOMAIN, username),
|
||||||
|
],
|
||||||
|
"links": [
|
||||||
|
{
|
||||||
|
"rel": "http://webfinger.net/rel/profile-page",
|
||||||
|
"type": "text/html",
|
||||||
|
"href": "https://{}/{}".format(DOMAIN, username),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"rel": "self",
|
||||||
|
"type": "application/activity+json",
|
||||||
|
"href": "https://{}/u/{}".format(DOMAIN, username),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"rel": "http://ostatus.org/schema/1.0/subscribe",
|
||||||
|
"template": "https://{}/authorize_interaction".format(DOMAIN)
|
||||||
|
+ "?uri={uri}",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def user(username):
|
||||||
|
if not username:
|
||||||
|
raise util.errors.BadRequest("Username required")
|
||||||
|
username = username.lower()
|
||||||
|
db = database.get_db()
|
||||||
|
user = db.users.find_one({"username": username})
|
||||||
|
if not user:
|
||||||
|
raise util.errors.NotFound("User unknown")
|
||||||
|
avatar_url = user.get("avatar") and uploads.get_presigned_url(
|
||||||
|
"users/{0}/{1}".format(user["_id"], user["avatar"])
|
||||||
|
)
|
||||||
|
|
||||||
|
pub_key = None
|
||||||
|
if user.get("services", {}).get("activityPub", {}).get("publicKey"):
|
||||||
|
pub_key = user["services"]["activityPub"]["publicKey"]
|
||||||
|
else:
|
||||||
|
priv_key, pub_key = util.generate_rsa_keypair()
|
||||||
|
db.users.update_one(
|
||||||
|
{"_id": user["_id"]},
|
||||||
|
{
|
||||||
|
"$set": {
|
||||||
|
"services.activityPub.publicKey": pub_key,
|
||||||
|
"services.activityPub.privateKey": priv_key,
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
resp = {
|
||||||
|
"@context": [
|
||||||
|
"https://www.w3.org/ns/activitystreams",
|
||||||
|
"https://w3id.org/security/v1",
|
||||||
|
],
|
||||||
|
"id": "https://{}/u/{}".format(DOMAIN, username),
|
||||||
|
"type": "Person",
|
||||||
|
# "following": "https://fosstodon.org/users/wilw/following",
|
||||||
|
# "followers": "https://fosstodon.org/users/wilw/followers",
|
||||||
|
"inbox": "https://{}/inbox".format(DOMAIN),
|
||||||
|
"outbox": "https://{}/u/{}/outbox".format(DOMAIN, username),
|
||||||
|
"preferredUsername": username,
|
||||||
|
"name": username,
|
||||||
|
"summary": user.get("bio", ""),
|
||||||
|
"url": "https://{}/{}".format(DOMAIN, username),
|
||||||
|
"discoverable": True,
|
||||||
|
"published": "2021-01-27T00:00:00Z",
|
||||||
|
"publicKey": {
|
||||||
|
"id": "https://{}/u/{}#main-key".format(DOMAIN, username),
|
||||||
|
"owner": "https://{}/u/{}".format(DOMAIN, username),
|
||||||
|
"publicKeyPem": pub_key.decode("utf-8"),
|
||||||
|
},
|
||||||
|
"attachment": [],
|
||||||
|
"endpoints": {"sharedInbox": "https://{}/inbox".format(DOMAIN)},
|
||||||
|
"icon": {"type": "Image", "mediaType": "image/jpeg", "url": avatar_url},
|
||||||
|
"image": {"type": "Image", "mediaType": "image/jpeg", "url": avatar_url},
|
||||||
|
}
|
||||||
|
|
||||||
|
if user.get("website"):
|
||||||
|
resp["attachment"].append(
|
||||||
|
{
|
||||||
|
"type": "PropertyValue",
|
||||||
|
"name": "Website",
|
||||||
|
"value": '<a href="https://{}" target="_blank" rel="nofollow noopener noreferrer me"><span class="invisible">https://</span><span class="">{}</span><span class="invisible"></span></a>'.format(
|
||||||
|
user["website"], user["website"]
|
||||||
|
),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
return resp
|
||||||
|
|
||||||
|
|
||||||
|
def outbox(username, page, min_id, max_id):
|
||||||
|
if not username:
|
||||||
|
raise util.errors.BadRequest("Username required")
|
||||||
|
username = username.lower()
|
||||||
|
db = database.get_db()
|
||||||
|
user = db.users.find_one({"username": username})
|
||||||
|
if not user:
|
||||||
|
raise util.errors.NotFound("User unknown")
|
||||||
|
|
||||||
|
if not page or page != "true":
|
||||||
|
return {
|
||||||
|
"@context": "https://www.w3.org/ns/activitystreams",
|
||||||
|
"id": "https://{}/u/{}/outbox".format(DOMAIN, username),
|
||||||
|
"type": "OrderedCollection",
|
||||||
|
"first": "https://{}/u/{}/outbox?page=true".format(DOMAIN, username),
|
||||||
|
}
|
||||||
|
if page == "true":
|
||||||
|
min_string = "&min_id={}".format(min_id) if min_id else ""
|
||||||
|
max_string = "&max_id={}".format(max_id) if max_id else ""
|
||||||
|
ret = {
|
||||||
|
"id": "https://{}/u/{}/outbox?page=true{}{}".format(
|
||||||
|
DOMAIN, username, min_string, max_string
|
||||||
|
),
|
||||||
|
"type": "OrderedCollectionPage",
|
||||||
|
# "next": "https://example.org/users/whatever/outbox?max_id=01FJC1Q0E3SSQR59TD2M1KP4V8&page=true",
|
||||||
|
# "prev": "https://example.org/users/whatever/outbox?min_id=01FJC1Q0E3SSQR59TD2M1KP4V8&page=true",
|
||||||
|
"partOf": "https://{}/u/{}/outbox".format(DOMAIN, username),
|
||||||
|
"orderedItems": [],
|
||||||
|
}
|
||||||
|
|
||||||
|
project_list = list(
|
||||||
|
db.projects.find({"user": user["_id"], "visibility": "public"})
|
||||||
|
)
|
||||||
|
for p in project_list:
|
||||||
|
ret["orderedItems"].append(
|
||||||
|
{
|
||||||
|
"id": "https://{}/{}/{}/activity".format(
|
||||||
|
DOMAIN, username, p["path"]
|
||||||
|
),
|
||||||
|
"type": "Create",
|
||||||
|
"actor": "https://{}/u/{}".format(DOMAIN, username),
|
||||||
|
"published": p["createdAt"].strftime(
|
||||||
|
"%Y-%m-%dT%H:%M:%SZ"
|
||||||
|
), # "2021-10-18T20:06:18Z",
|
||||||
|
"to": ["https://www.w3.org/ns/activitystreams#Public"],
|
||||||
|
"object": {
|
||||||
|
"id": "https://{}/{}/{}".format(DOMAIN, username, p["path"]),
|
||||||
|
"type": "Note",
|
||||||
|
"summary": None,
|
||||||
|
# "inReplyTo": "https://mastodon.lhin.space/users/0xvms/statuses/108759565436297722",
|
||||||
|
"published": p["createdAt"].strftime(
|
||||||
|
"%Y-%m-%dT%H:%M:%SZ"
|
||||||
|
), # "2022-08-03T15:43:30Z",
|
||||||
|
"url": "https://{}/{}/{}".format(DOMAIN, username, p["path"]),
|
||||||
|
"attributedTo": "https://{}/u/{}".format(DOMAIN, username),
|
||||||
|
"to": ["https://www.w3.org/ns/activitystreams#Public"],
|
||||||
|
"cc": [
|
||||||
|
"https://{}/u/{}/followers".format(DOMAIN, username),
|
||||||
|
],
|
||||||
|
"sensitive": False,
|
||||||
|
"content": "{} created a project: {}".format(
|
||||||
|
username, p["name"]
|
||||||
|
),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
return ret
|
804
api/api/groups.py
Normal file
@ -0,0 +1,804 @@
|
|||||||
|
import datetime
|
||||||
|
import re
|
||||||
|
import os
|
||||||
|
import math
|
||||||
|
import pymongo
|
||||||
|
from bson.objectid import ObjectId
|
||||||
|
from util import database, util, mail, push
|
||||||
|
from api import uploads
|
||||||
|
|
||||||
|
APP_NAME = os.environ.get("APP_NAME")
|
||||||
|
APP_URL = os.environ.get("APP_URL")
|
||||||
|
|
||||||
|
|
||||||
|
def has_group_permission(user, group, permission=None):
|
||||||
|
if not user or not group:
|
||||||
|
return False
|
||||||
|
if user["_id"] in group.get("admins", []):
|
||||||
|
return True
|
||||||
|
if group["_id"] not in user.get("groups", []):
|
||||||
|
return False
|
||||||
|
if permission:
|
||||||
|
return permission in group.get("memberPermissions", [])
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def create(user, data):
|
||||||
|
if not data:
|
||||||
|
raise util.errors.BadRequest("Invalid request")
|
||||||
|
if len(data.get("name")) < 3:
|
||||||
|
raise util.errors.BadRequest("A longer name is required")
|
||||||
|
db = database.get_db()
|
||||||
|
|
||||||
|
group = {
|
||||||
|
"createdAt": datetime.datetime.now(),
|
||||||
|
"user": user["_id"],
|
||||||
|
"admins": [user["_id"]],
|
||||||
|
"name": data["name"],
|
||||||
|
"description": data.get("description", ""),
|
||||||
|
"closed": data.get("closed", False),
|
||||||
|
"advertised": data.get("advertised", False),
|
||||||
|
"memberPermissions": [
|
||||||
|
"viewMembers",
|
||||||
|
"viewNoticeboard",
|
||||||
|
"postNoticeboard",
|
||||||
|
"viewProjects",
|
||||||
|
"postProjects",
|
||||||
|
"viewForumTopics",
|
||||||
|
"postForumTopics",
|
||||||
|
"postForumTopicReplies",
|
||||||
|
],
|
||||||
|
}
|
||||||
|
result = db.groups.insert_one(group)
|
||||||
|
group["_id"] = result.inserted_id
|
||||||
|
create_member(user, group["_id"], user["_id"])
|
||||||
|
return group
|
||||||
|
|
||||||
|
|
||||||
|
def get(user):
|
||||||
|
db = database.get_db()
|
||||||
|
groups = list(db.groups.find({"_id": {"$in": user.get("groups", [])}}))
|
||||||
|
return {"groups": groups}
|
||||||
|
|
||||||
|
|
||||||
|
def get_one(user, id):
|
||||||
|
db = database.get_db()
|
||||||
|
id = ObjectId(id)
|
||||||
|
group = db.groups.find_one({"_id": id})
|
||||||
|
if not group:
|
||||||
|
raise util.errors.NotFound("Group not found")
|
||||||
|
if group.get("image"):
|
||||||
|
group["imageUrl"] = uploads.get_presigned_url(
|
||||||
|
"groups/{0}/{1}".format(id, group["image"])
|
||||||
|
)
|
||||||
|
group["adminUsers"] = list(
|
||||||
|
db.users.find(
|
||||||
|
{"_id": {"$in": group.get("admins", [])}}, {"username": 1, "avatar": 1}
|
||||||
|
)
|
||||||
|
)
|
||||||
|
for u in group["adminUsers"]:
|
||||||
|
if "avatar" in u:
|
||||||
|
u["avatarUrl"] = uploads.get_presigned_url(
|
||||||
|
"users/{0}/{1}".format(u["_id"], u["avatar"])
|
||||||
|
)
|
||||||
|
return group
|
||||||
|
|
||||||
|
|
||||||
|
def update(user, id, update):
|
||||||
|
db = database.get_db()
|
||||||
|
id = ObjectId(id)
|
||||||
|
group = db.groups.find_one({"_id": id}, {"admins": 1})
|
||||||
|
if not group:
|
||||||
|
raise util.errors.NotFound("Group not found")
|
||||||
|
if user["_id"] not in group.get("admins", []):
|
||||||
|
raise util.errors.Forbidden("You're not a group admin")
|
||||||
|
allowed_keys = [
|
||||||
|
"name",
|
||||||
|
"description",
|
||||||
|
"closed",
|
||||||
|
"advertised",
|
||||||
|
"memberPermissions",
|
||||||
|
"image",
|
||||||
|
]
|
||||||
|
updater = util.build_updater(update, allowed_keys)
|
||||||
|
if updater:
|
||||||
|
if "$set" in updater and (
|
||||||
|
"name" in update or "description" in update or "image" in update
|
||||||
|
):
|
||||||
|
updater["$set"]["moderationRequired"] = True
|
||||||
|
util.send_moderation_request(user, "groups", group)
|
||||||
|
db.groups.update_one({"_id": id}, updater)
|
||||||
|
return get_one(user, id)
|
||||||
|
|
||||||
|
|
||||||
|
def delete(user, id):
|
||||||
|
db = database.get_db()
|
||||||
|
id = ObjectId(id)
|
||||||
|
group = db.groups.find_one({"_id": id}, {"admins": 1})
|
||||||
|
if not group:
|
||||||
|
raise util.errors.NotFound("Group not found")
|
||||||
|
if user["_id"] not in group.get("admins", []):
|
||||||
|
raise util.errors.Forbidden("You're not a group admin")
|
||||||
|
db.groups.delete_one({"_id": id})
|
||||||
|
db.groupEntries.delete_many({"group": id})
|
||||||
|
db.users.update_many({"groups": id}, {"$pull": {"groups": id}})
|
||||||
|
return {"deletedGroup": id}
|
||||||
|
|
||||||
|
|
||||||
|
def create_entry(user, id, data):
|
||||||
|
if not data or "content" not in data:
|
||||||
|
raise util.errors.BadRequest("Invalid request")
|
||||||
|
db = database.get_db()
|
||||||
|
id = ObjectId(id)
|
||||||
|
group = db.groups.find_one({"_id": id})
|
||||||
|
if not group:
|
||||||
|
raise util.errors.NotFound("Group not found")
|
||||||
|
if group["_id"] not in user.get("groups", []):
|
||||||
|
raise util.errors.Forbidden("You must be a member to write in the feed")
|
||||||
|
if not has_group_permission(user, group, "postNoticeboard"):
|
||||||
|
raise util.errors.Forbidden("You don't have permission to post in the feed")
|
||||||
|
entry = {
|
||||||
|
"createdAt": datetime.datetime.now(),
|
||||||
|
"group": id,
|
||||||
|
"user": user["_id"],
|
||||||
|
"content": data["content"],
|
||||||
|
"moderationRequired": True,
|
||||||
|
}
|
||||||
|
if "attachments" in data:
|
||||||
|
entry["attachments"] = data["attachments"]
|
||||||
|
for attachment in entry["attachments"]:
|
||||||
|
if re.search(
|
||||||
|
r"(.jpg)|(.png)|(.jpeg)|(.gif)$", attachment["storedName"].lower()
|
||||||
|
):
|
||||||
|
attachment["isImage"] = True
|
||||||
|
if attachment["type"] == "file":
|
||||||
|
attachment["url"] = uploads.get_presigned_url(
|
||||||
|
"groups/{0}/{1}".format(id, attachment["storedName"])
|
||||||
|
)
|
||||||
|
|
||||||
|
result = db.groupEntries.insert_one(entry)
|
||||||
|
entry["_id"] = result.inserted_id
|
||||||
|
entry["authorUser"] = {
|
||||||
|
"_id": user["_id"],
|
||||||
|
"username": user["username"],
|
||||||
|
"avatar": user.get("avatar"),
|
||||||
|
}
|
||||||
|
if "avatar" in user:
|
||||||
|
entry["authorUser"]["avatarUrl"] = uploads.get_presigned_url(
|
||||||
|
"users/{0}/{1}".format(user["_id"], user["avatar"])
|
||||||
|
)
|
||||||
|
util.send_moderation_request(user, "groupEntries", entry)
|
||||||
|
return entry
|
||||||
|
|
||||||
|
|
||||||
|
def send_entry_notification(id):
|
||||||
|
db = database.get_db()
|
||||||
|
entry = db.groupEntries.find_one({"_id": ObjectId(id)})
|
||||||
|
# If this is a reply, then send the reply email instead
|
||||||
|
if entry.get("inReplyTo"):
|
||||||
|
return send_entry_reply_notification(id)
|
||||||
|
group = db.groups.find_one({"_id": entry["group"]})
|
||||||
|
user = db.users.find_one({"_id": entry["user"]})
|
||||||
|
|
||||||
|
for u in db.users.find(
|
||||||
|
{
|
||||||
|
"_id": {"$ne": user["_id"]},
|
||||||
|
"groups": group["_id"],
|
||||||
|
"subscriptions.email": "groupFeed-" + str(group["_id"]),
|
||||||
|
},
|
||||||
|
{"email": 1, "username": 1},
|
||||||
|
):
|
||||||
|
mail.send(
|
||||||
|
{
|
||||||
|
"to_user": u,
|
||||||
|
"subject": "New message in " + group["name"],
|
||||||
|
"text": "Dear {0},\n\n{1} posted a message in the Notice Board of {2} on {5}:\n\n{3}\n\nFollow the link below to visit the group:\n\n{4}".format(
|
||||||
|
u["username"],
|
||||||
|
user["username"],
|
||||||
|
group["name"],
|
||||||
|
entry["content"],
|
||||||
|
"{}/groups/{}".format(APP_URL, str(group["_id"])),
|
||||||
|
APP_NAME,
|
||||||
|
),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
push.send_multiple(
|
||||||
|
list(db.users.find({"_id": {"$ne": user["_id"]}, "groups": group["_id"]})),
|
||||||
|
"{} posted in {}".format(user["username"], group["name"]),
|
||||||
|
entry["content"][:30] + "...",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def get_entries(user, id):
|
||||||
|
db = database.get_db()
|
||||||
|
id = ObjectId(id)
|
||||||
|
group = db.groups.find_one({"_id": id})
|
||||||
|
if not group:
|
||||||
|
raise util.errors.NotFound("Group not found")
|
||||||
|
if id not in user.get("groups", []):
|
||||||
|
raise util.errors.BadRequest("You're not a member of this group")
|
||||||
|
if not has_group_permission(user, group, "viewNoticeboard"):
|
||||||
|
raise util.errors.Forbidden("You don't have permission to view the feed")
|
||||||
|
# Only return entries that have been moderated or are owned by the user
|
||||||
|
entries = list(
|
||||||
|
db.groupEntries.find(
|
||||||
|
{
|
||||||
|
"group": id,
|
||||||
|
"$or": [{"user": user["_id"]}, {"moderationRequired": {"$ne": True}}],
|
||||||
|
}
|
||||||
|
).sort("createdAt", pymongo.DESCENDING)
|
||||||
|
)
|
||||||
|
authors = list(
|
||||||
|
db.users.find(
|
||||||
|
{"_id": {"$in": [e["user"] for e in entries]}}, {"username": 1, "avatar": 1}
|
||||||
|
)
|
||||||
|
)
|
||||||
|
for entry in entries:
|
||||||
|
if "attachments" in entry:
|
||||||
|
for attachment in entry["attachments"]:
|
||||||
|
attachment["url"] = uploads.get_presigned_url(
|
||||||
|
"groups/{0}/{1}".format(id, attachment["storedName"])
|
||||||
|
)
|
||||||
|
for author in authors:
|
||||||
|
if entry["user"] == author["_id"]:
|
||||||
|
entry["authorUser"] = author
|
||||||
|
if "avatar" in author:
|
||||||
|
entry["authorUser"]["avatarUrl"] = uploads.get_presigned_url(
|
||||||
|
"users/{0}/{1}".format(author["_id"], author["avatar"])
|
||||||
|
)
|
||||||
|
return {"entries": entries}
|
||||||
|
|
||||||
|
|
||||||
|
def delete_entry(user, id, entry_id):
|
||||||
|
db = database.get_db()
|
||||||
|
id = ObjectId(id)
|
||||||
|
entry_id = ObjectId(entry_id)
|
||||||
|
group = db.groups.find_one({"_id": id}, {"admins": 1})
|
||||||
|
if not group:
|
||||||
|
raise util.errors.NotFound("Group not found")
|
||||||
|
entry = db.groupEntries.find_one(entry_id, {"user": 1, "group": 1})
|
||||||
|
if not entry or entry["group"] != id:
|
||||||
|
raise util.errors.NotFound("Entry not found")
|
||||||
|
if entry["user"] != user["_id"] and user["_id"] not in group.get("admins", []):
|
||||||
|
raise util.errors.Forbidden(
|
||||||
|
"You must own the entry or be an admin of the group"
|
||||||
|
)
|
||||||
|
db.groupEntries.delete_one({"$or": [{"_id": entry_id}, {"inReplyTo": entry_id}]})
|
||||||
|
return {"deletedEntry": entry_id}
|
||||||
|
|
||||||
|
|
||||||
|
def create_entry_reply(user, id, entry_id, data):
|
||||||
|
if not data or "content" not in data:
|
||||||
|
raise util.errors.BadRequest("Invalid request")
|
||||||
|
db = database.get_db()
|
||||||
|
id = ObjectId(id)
|
||||||
|
entry_id = ObjectId(entry_id)
|
||||||
|
group = db.groups.find_one({"_id": id})
|
||||||
|
if not group:
|
||||||
|
raise util.errors.NotFound("Group not found")
|
||||||
|
entry = db.groupEntries.find_one({"_id": entry_id})
|
||||||
|
if not entry or entry.get("group") != group["_id"]:
|
||||||
|
raise util.errors.NotFound("Entry to reply to not found")
|
||||||
|
if group["_id"] not in user.get("groups", []):
|
||||||
|
raise util.errors.Forbidden("You must be a member to write in the feed")
|
||||||
|
if not has_group_permission(user, group, "postNoticeboard"):
|
||||||
|
raise util.errors.Forbidden("You don't have permission to post in the feed")
|
||||||
|
reply = {
|
||||||
|
"createdAt": datetime.datetime.now(),
|
||||||
|
"group": id,
|
||||||
|
"inReplyTo": entry_id,
|
||||||
|
"user": user["_id"],
|
||||||
|
"content": data["content"],
|
||||||
|
"moderationRequired": True,
|
||||||
|
}
|
||||||
|
if "attachments" in data:
|
||||||
|
reply["attachments"] = data["attachments"]
|
||||||
|
for attachment in reply["attachments"]:
|
||||||
|
if re.search(
|
||||||
|
r"(.jpg)|(.png)|(.jpeg)|(.gif)$", attachment["storedName"].lower()
|
||||||
|
):
|
||||||
|
attachment["isImage"] = True
|
||||||
|
if attachment["type"] == "file":
|
||||||
|
attachment["url"] = uploads.get_presigned_url(
|
||||||
|
"groups/{0}/{1}".format(id, attachment["storedName"])
|
||||||
|
)
|
||||||
|
|
||||||
|
result = db.groupEntries.insert_one(reply)
|
||||||
|
reply["_id"] = result.inserted_id
|
||||||
|
reply["authorUser"] = {
|
||||||
|
"_id": user["_id"],
|
||||||
|
"username": user["username"],
|
||||||
|
"avatar": user.get("avatar"),
|
||||||
|
}
|
||||||
|
if "avatar" in user:
|
||||||
|
reply["authorUser"]["avatarUrl"] = uploads.get_presigned_url(
|
||||||
|
"users/{0}/{1}".format(user["_id"], user["avatar"])
|
||||||
|
)
|
||||||
|
util.send_moderation_request(user, "groupEntries", entry)
|
||||||
|
return reply
|
||||||
|
|
||||||
|
|
||||||
|
def send_entry_reply_notification(id):
|
||||||
|
db = database.get_db()
|
||||||
|
reply = db.groupEntries.find_one({"_id": ObjectId(id)})
|
||||||
|
user = db.users.find_one({"_id": reply["user"]})
|
||||||
|
original_entry = db.groupEntries.find_one({"_id": reply["inReplyTo"]})
|
||||||
|
group = db.groups.find_one({"_id": original_entry["group"]})
|
||||||
|
op = db.users.find_one(
|
||||||
|
{
|
||||||
|
"$and": [
|
||||||
|
{"_id": original_entry.get("user")},
|
||||||
|
{"_id": {"$ne": user["_id"]}},
|
||||||
|
],
|
||||||
|
"subscriptions.email": "messages.replied",
|
||||||
|
}
|
||||||
|
)
|
||||||
|
if op:
|
||||||
|
mail.send(
|
||||||
|
{
|
||||||
|
"to_user": op,
|
||||||
|
"subject": user["username"] + " replied to your post",
|
||||||
|
"text": "Dear {0},\n\n{1} replied to your message in the Notice Board of {2} on {5}:\n\n{3}\n\nFollow the link below to visit the group:\n\n{4}".format(
|
||||||
|
op["username"],
|
||||||
|
user["username"],
|
||||||
|
group["name"],
|
||||||
|
reply["content"],
|
||||||
|
"{}/groups/{}".format(APP_URL, str(group["_id"])),
|
||||||
|
APP_NAME,
|
||||||
|
),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def delete_entry_reply(user, id, entry_id, reply_id):
|
||||||
|
db = database.get_db()
|
||||||
|
id = ObjectId(id)
|
||||||
|
entry_id = ObjectId(entry_id)
|
||||||
|
reply_id = ObjectId(reply_id)
|
||||||
|
group = db.groups.find_one({"_id": id}, {"admins": 1})
|
||||||
|
if not group:
|
||||||
|
raise util.errors.NotFound("Group not found")
|
||||||
|
entry = db.groupEntries.find_one(entry_id, {"user": 1, "group": 1})
|
||||||
|
if not entry or entry["group"] != id:
|
||||||
|
raise util.errors.NotFound("Entry not found")
|
||||||
|
reply = db.groupEntries.find_one(reply_id)
|
||||||
|
if not reply or reply.get("inReplyTo") != entry_id:
|
||||||
|
raise util.errors.NotFound("Reply not found")
|
||||||
|
if (
|
||||||
|
entry["user"] != user["_id"]
|
||||||
|
and reply["user"] != user["_id"]
|
||||||
|
and user["_id"] not in group.get("admins", [])
|
||||||
|
):
|
||||||
|
raise util.errors.Forbidden(
|
||||||
|
"You must own the reply or entry or be an admin of the group"
|
||||||
|
)
|
||||||
|
db.groupEntries.delete_one({"_id": entry_id})
|
||||||
|
return {"deletedEntry": entry_id}
|
||||||
|
|
||||||
|
|
||||||
|
def create_member(user, id, user_id, invited=False):
|
||||||
|
db = database.get_db()
|
||||||
|
id = ObjectId(id)
|
||||||
|
user_id = ObjectId(user_id)
|
||||||
|
group = db.groups.find_one({"_id": id}, {"admins": 1, "name": 1, "closed": 1})
|
||||||
|
if not group:
|
||||||
|
raise util.errors.NotFound("Group not found")
|
||||||
|
if user_id != user["_id"]:
|
||||||
|
raise util.errors.Forbidden("Not allowed to add someone else to the group")
|
||||||
|
if (
|
||||||
|
group.get("closed")
|
||||||
|
and not invited
|
||||||
|
and user["_id"] not in group.get("admins", [])
|
||||||
|
):
|
||||||
|
raise util.errors.Forbidden("Not allowed to join a closed group")
|
||||||
|
db.users.update_one(
|
||||||
|
{"_id": user_id},
|
||||||
|
{"$addToSet": {"groups": id, "subscriptions.email": "groupFeed-" + str(id)}},
|
||||||
|
)
|
||||||
|
db.invitations.delete_many({"type": "group", "typeId": id, "recipient": user_id})
|
||||||
|
for admin in db.users.find(
|
||||||
|
{
|
||||||
|
"_id": {"$in": group.get("admins", []), "$ne": user_id},
|
||||||
|
"subscriptions.email": "groups.joined",
|
||||||
|
},
|
||||||
|
{"email": 1, "username": 1},
|
||||||
|
):
|
||||||
|
mail.send(
|
||||||
|
{
|
||||||
|
"to_user": admin,
|
||||||
|
"subject": "Someone joined your group",
|
||||||
|
"text": "Dear {0},\n\n{1} recently joined your group {2} on {4}!\n\nFollow the link below to manage your group:\n\n{3}".format(
|
||||||
|
admin["username"],
|
||||||
|
user["username"],
|
||||||
|
group["name"],
|
||||||
|
"{}/groups/{}".format(APP_URL, str(id)),
|
||||||
|
APP_NAME,
|
||||||
|
),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
return {"newMember": user_id}
|
||||||
|
|
||||||
|
|
||||||
|
def get_members(user, id):
|
||||||
|
db = database.get_db()
|
||||||
|
id = ObjectId(id)
|
||||||
|
group = db.groups.find_one({"_id": id})
|
||||||
|
if not group:
|
||||||
|
raise util.errors.NotFound("Group not found")
|
||||||
|
if id not in user.get("groups", []) and "root" not in user.get("roles", []):
|
||||||
|
raise util.errors.Forbidden("You need to be a member to see the member list")
|
||||||
|
if not has_group_permission(user, group, "viewMembers"):
|
||||||
|
raise util.errors.Forbidden("You don't have permission to view the member list")
|
||||||
|
members = list(
|
||||||
|
db.users.find(
|
||||||
|
{"groups": id}, {"username": 1, "avatar": 1, "bio": 1, "groups": 1}
|
||||||
|
)
|
||||||
|
)
|
||||||
|
for m in members:
|
||||||
|
if "avatar" in m:
|
||||||
|
m["avatarUrl"] = uploads.get_presigned_url(
|
||||||
|
"users/{0}/{1}".format(m["_id"], m["avatar"])
|
||||||
|
)
|
||||||
|
return {"members": members}
|
||||||
|
|
||||||
|
|
||||||
|
def delete_member(user, id, user_id):
|
||||||
|
id = ObjectId(id)
|
||||||
|
user_id = ObjectId(user_id)
|
||||||
|
db = database.get_db()
|
||||||
|
group = db.groups.find_one({"_id": id}, {"admins": 1})
|
||||||
|
if not group:
|
||||||
|
raise util.errors.NotFound("Group not found")
|
||||||
|
if user_id != user["_id"] and user["_id"] not in group.get("admins", []):
|
||||||
|
raise util.errors.Forbidden("You can't remove this user")
|
||||||
|
if user_id in group.get("admins", []) and len(group["admins"]) == 1:
|
||||||
|
raise util.errors.Forbidden(
|
||||||
|
"There needs to be at least one admin in this group"
|
||||||
|
)
|
||||||
|
db.users.update_one(
|
||||||
|
{"_id": user_id},
|
||||||
|
{"$pull": {"groups": id, "subscriptions.email": "groupFeed-" + str(id)}},
|
||||||
|
)
|
||||||
|
db.groups.update_one({"_id": id}, {"$pull": {"admins": user_id}})
|
||||||
|
return {"deletedMember": user_id}
|
||||||
|
|
||||||
|
|
||||||
|
def create_admin(user, id, user_id):
|
||||||
|
id = ObjectId(id)
|
||||||
|
user_id = ObjectId(user_id)
|
||||||
|
db = database.get_db()
|
||||||
|
group = db.groups.find_one({"_id": id}, {"admins": 1})
|
||||||
|
if not group:
|
||||||
|
raise util.errors.NotFound("Group not found")
|
||||||
|
if user["_id"] not in group.get("admins", []):
|
||||||
|
raise util.errors.Forbidden("You can't add this admin")
|
||||||
|
if user_id in group.get("admins", []):
|
||||||
|
raise util.errors.Forbidden("This user is already an admin")
|
||||||
|
db.groups.update_one({"_id": id}, {"$addToSet": {"admins": user_id}})
|
||||||
|
return {"createdAdmin": user_id}
|
||||||
|
|
||||||
|
|
||||||
|
def delete_admin(user, id, user_id):
|
||||||
|
id = ObjectId(id)
|
||||||
|
user_id = ObjectId(user_id)
|
||||||
|
db = database.get_db()
|
||||||
|
group = db.groups.find_one({"_id": id}, {"admins": 1})
|
||||||
|
if not group:
|
||||||
|
raise util.errors.NotFound("Group not found")
|
||||||
|
if user_id != user["_id"] and user["_id"] not in group.get("admins", []):
|
||||||
|
raise util.errors.Forbidden("You can't remove this admin")
|
||||||
|
if user_id not in group.get("admins", []):
|
||||||
|
raise util.errors.Forbidden("This user is not an admin")
|
||||||
|
if len(group["admins"]) == 1:
|
||||||
|
raise util.errors.Forbidden(
|
||||||
|
"There needs to be at least one admin in this group"
|
||||||
|
)
|
||||||
|
db.groups.update_one({"_id": id}, {"$pull": {"admins": user_id}})
|
||||||
|
return {"deletedAdmin": user_id}
|
||||||
|
|
||||||
|
|
||||||
|
def get_projects(user, id):
|
||||||
|
db = database.get_db()
|
||||||
|
id = ObjectId(id)
|
||||||
|
group = db.groups.find_one({"_id": id})
|
||||||
|
if not group:
|
||||||
|
raise util.errors.NotFound("Group not found")
|
||||||
|
if id not in user.get("groups", []):
|
||||||
|
raise util.errors.Forbidden("You need to be a member to see the project list")
|
||||||
|
if not has_group_permission(user, group, "viewProjects"):
|
||||||
|
raise util.errors.Forbidden(
|
||||||
|
"You don't have permission to view the project list"
|
||||||
|
)
|
||||||
|
projects = list(
|
||||||
|
db.projects.find(
|
||||||
|
{"groupVisibility": id},
|
||||||
|
{"name": 1, "path": 1, "user": 1, "description": 1, "visibility": 1},
|
||||||
|
)
|
||||||
|
)
|
||||||
|
authors = list(
|
||||||
|
db.users.find(
|
||||||
|
{"groups": id, "_id": {"$in": list(map(lambda p: p["user"], projects))}},
|
||||||
|
{"username": 1, "avatar": 1, "bio": 1},
|
||||||
|
)
|
||||||
|
)
|
||||||
|
for a in authors:
|
||||||
|
if "avatar" in a:
|
||||||
|
a["avatarUrl"] = uploads.get_presigned_url(
|
||||||
|
"users/{0}/{1}".format(a["_id"], a["avatar"])
|
||||||
|
)
|
||||||
|
for project in projects:
|
||||||
|
for a in authors:
|
||||||
|
if project["user"] == a["_id"]:
|
||||||
|
project["owner"] = a
|
||||||
|
project["fullName"] = a["username"] + "/" + project["path"]
|
||||||
|
break
|
||||||
|
return {"projects": projects}
|
||||||
|
|
||||||
|
|
||||||
|
def create_forum_topic(user, id, data):
|
||||||
|
db = database.get_db()
|
||||||
|
id = ObjectId(id)
|
||||||
|
group = db.groups.find_one({"_id": id})
|
||||||
|
if not group:
|
||||||
|
raise util.errors.NotFound("Group not found")
|
||||||
|
if not has_group_permission(user, group, "postForumTopics"):
|
||||||
|
raise util.errors.Forbidden("You don't have permission to create a topic")
|
||||||
|
topic = {
|
||||||
|
"createdAt": datetime.datetime.now(),
|
||||||
|
"group": id,
|
||||||
|
"user": user["_id"],
|
||||||
|
"title": data["title"],
|
||||||
|
"description": data.get("description", ""),
|
||||||
|
}
|
||||||
|
result = db.groupForumTopics.insert_one(topic)
|
||||||
|
topic["_id"] = result.inserted_id
|
||||||
|
return topic
|
||||||
|
|
||||||
|
|
||||||
|
def update_forum_topic(user, id, topic_id, data):
|
||||||
|
db = database.get_db()
|
||||||
|
id = ObjectId(id)
|
||||||
|
topic_id = ObjectId(topic_id)
|
||||||
|
group = db.groups.find_one({"_id": id})
|
||||||
|
if not group:
|
||||||
|
raise util.errors.NotFound("Group not found")
|
||||||
|
topic = db.groupForumTopics.find_one({"_id": topic_id})
|
||||||
|
if not topic or topic.get("group") != id:
|
||||||
|
raise util.errors.NotFound("Topic not found")
|
||||||
|
if not (user["_id"] in group.get("admins", []) or user["_id"] == topic.get("user")):
|
||||||
|
raise util.errors.Forbidden("You don't have permission to edit the topic")
|
||||||
|
allowed_keys = ["title", "description"]
|
||||||
|
updater = util.build_updater(data, allowed_keys)
|
||||||
|
if updater:
|
||||||
|
db.groupForumTopics.update_one({"_id": topic_id}, updater)
|
||||||
|
return db.groupForumTopics.find_one({"_id": topic_id})
|
||||||
|
|
||||||
|
|
||||||
|
def delete_forum_topic(user, id, topic_id):
|
||||||
|
db = database.get_db()
|
||||||
|
id = ObjectId(id)
|
||||||
|
topic_id = ObjectId(topic_id)
|
||||||
|
group = db.groups.find_one({"_id": id})
|
||||||
|
if not group:
|
||||||
|
raise util.errors.NotFound("Group not found")
|
||||||
|
topic = db.groupForumTopics.find_one({"_id": topic_id})
|
||||||
|
if not topic or topic.get("group") != id:
|
||||||
|
raise util.errors.NotFound("Topic not found")
|
||||||
|
if not (user["_id"] in group.get("admins", []) or user["_id"] == topic.get("user")):
|
||||||
|
raise util.errors.Forbidden("You don't have permission to delete the topic")
|
||||||
|
db.groupForumTopics.delete_one({"_id": topic_id})
|
||||||
|
db.groupForumTopicReplies.delete_many({"topic": topic_id})
|
||||||
|
return {"deletedTopic": topic_id}
|
||||||
|
|
||||||
|
|
||||||
|
def get_forum_topics(user, id):
|
||||||
|
db = database.get_db()
|
||||||
|
id = ObjectId(id)
|
||||||
|
group = db.groups.find_one({"_id": id})
|
||||||
|
if not group:
|
||||||
|
raise util.errors.NotFound("Group not found")
|
||||||
|
if not has_group_permission(user, group, "viewForumTopics"):
|
||||||
|
raise util.errors.Forbidden(
|
||||||
|
"You don't have permission to view the forum topics"
|
||||||
|
)
|
||||||
|
return {
|
||||||
|
"topics": list(
|
||||||
|
db.groupForumTopics.find({"group": id}).sort(
|
||||||
|
"createdAt", pymongo.DESCENDING
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def create_forum_topic_reply(user, id, topic_id, data):
|
||||||
|
db = database.get_db()
|
||||||
|
id = ObjectId(id)
|
||||||
|
topic_id = ObjectId(topic_id)
|
||||||
|
group = db.groups.find_one({"_id": id})
|
||||||
|
if not group:
|
||||||
|
raise util.errors.NotFound("Group not found")
|
||||||
|
topic = db.groupForumTopics.find_one({"_id": topic_id})
|
||||||
|
if not topic or topic.get("group") != id:
|
||||||
|
raise util.errors.NotFound("Topic not found")
|
||||||
|
if not has_group_permission(user, group, "postForumTopicReplies"):
|
||||||
|
raise util.errors.Forbidden("You don't have permission to create a reply")
|
||||||
|
reply = {
|
||||||
|
"createdAt": datetime.datetime.now(),
|
||||||
|
"group": id,
|
||||||
|
"topic": topic_id,
|
||||||
|
"user": user["_id"],
|
||||||
|
"content": data["content"],
|
||||||
|
"attachments": data.get("attachments", []),
|
||||||
|
"moderationRequired": True,
|
||||||
|
}
|
||||||
|
result = db.groupForumTopicReplies.insert_one(reply)
|
||||||
|
db.groupForumTopics.update_one(
|
||||||
|
{"_id": topic_id},
|
||||||
|
{
|
||||||
|
"$set": {
|
||||||
|
"lastReplyAt": reply["createdAt"],
|
||||||
|
"totalReplies": db.groupForumTopicReplies.count_documents(
|
||||||
|
{"topic": topic_id}
|
||||||
|
),
|
||||||
|
"lastReply": result.inserted_id,
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
reply["_id"] = result.inserted_id
|
||||||
|
reply["author"] = {
|
||||||
|
"_id": user["_id"],
|
||||||
|
"username": user["username"],
|
||||||
|
"avatar": user.get("avatar"),
|
||||||
|
}
|
||||||
|
if "avatar" in user:
|
||||||
|
reply["author"]["avatarUrl"] = uploads.get_presigned_url(
|
||||||
|
"users/{0}/{1}".format(user["_id"], user["avatar"])
|
||||||
|
)
|
||||||
|
for attachment in reply["attachments"]:
|
||||||
|
if re.search(
|
||||||
|
r"(.jpg)|(.png)|(.jpeg)|(.gif)$", attachment["storedName"].lower()
|
||||||
|
):
|
||||||
|
attachment["isImage"] = True
|
||||||
|
if attachment["type"] == "file":
|
||||||
|
attachment["url"] = uploads.get_presigned_url(
|
||||||
|
"groups/{0}/topics/{1}/{2}".format(
|
||||||
|
id, topic_id, attachment["storedName"]
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
util.send_moderation_request(user, "groupForumTopicReplies", reply)
|
||||||
|
return reply
|
||||||
|
|
||||||
|
|
||||||
|
def send_forum_topic_reply_notification(id):
|
||||||
|
db = database.get_db()
|
||||||
|
reply = db.groupForumTopicReplies.find_one({"_id": ObjectId(id)})
|
||||||
|
user = db.users.find_one({"_id": reply["user"]})
|
||||||
|
topic = db.groupForumTopics.find_one({"_id": reply["topic"]})
|
||||||
|
group = db.groups.find_one({"_id": topic["group"]})
|
||||||
|
for u in db.users.find(
|
||||||
|
{
|
||||||
|
"_id": {"$ne": reply["user"]},
|
||||||
|
"groups": topic["group"],
|
||||||
|
"subscriptions.email": "groupForumTopic-" + str(topic["_id"]),
|
||||||
|
},
|
||||||
|
{"email": 1, "username": 1},
|
||||||
|
):
|
||||||
|
mail.send(
|
||||||
|
{
|
||||||
|
"to_user": u,
|
||||||
|
"subject": "A new reply was posted to " + topic["title"],
|
||||||
|
"text": "Dear {0},\n\n{1} posted a new reply in {2} (in the group {3}) on {6}:\n\n{4}\n\nFollow the link below to visit the group:\n\n{5}".format(
|
||||||
|
u["username"],
|
||||||
|
user["username"],
|
||||||
|
topic["title"],
|
||||||
|
group["name"],
|
||||||
|
reply["content"],
|
||||||
|
"{}/groups/{}/forum/topics/{}".format(
|
||||||
|
APP_URL, str(group["_id"]), str(topic["_id"])
|
||||||
|
),
|
||||||
|
APP_NAME,
|
||||||
|
),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def get_forum_topic_replies(user, id, topic_id, data):
|
||||||
|
REPLIES_PER_PAGE = 20
|
||||||
|
page = int(data.get("page", 1))
|
||||||
|
db = database.get_db()
|
||||||
|
id = ObjectId(id)
|
||||||
|
topic_id = ObjectId(topic_id)
|
||||||
|
group = db.groups.find_one({"_id": id})
|
||||||
|
if not group:
|
||||||
|
raise util.errors.NotFound("Group not found")
|
||||||
|
topic = db.groupForumTopics.find_one({"_id": topic_id})
|
||||||
|
if not topic or topic.get("group") != id:
|
||||||
|
raise util.errors.NotFound("Topic not found")
|
||||||
|
if not has_group_permission(user, group, "viewForumTopics"):
|
||||||
|
raise util.errors.Forbidden(
|
||||||
|
"You don't have permission to view the forum topics"
|
||||||
|
)
|
||||||
|
total_replies = db.groupForumTopicReplies.count_documents({"topic": topic_id})
|
||||||
|
replies = list(
|
||||||
|
db.groupForumTopicReplies.find(
|
||||||
|
{
|
||||||
|
"topic": topic_id,
|
||||||
|
"$or": [{"moderationRequired": {"$ne": True}}, {"user": user["_id"]}],
|
||||||
|
}
|
||||||
|
)
|
||||||
|
.sort("createdAt", pymongo.ASCENDING)
|
||||||
|
.skip((page - 1) * REPLIES_PER_PAGE)
|
||||||
|
.limit(REPLIES_PER_PAGE)
|
||||||
|
)
|
||||||
|
authors = list(
|
||||||
|
db.users.find(
|
||||||
|
{"_id": {"$in": [r["user"] for r in replies]}}, {"username": 1, "avatar": 1}
|
||||||
|
)
|
||||||
|
)
|
||||||
|
for reply in replies:
|
||||||
|
author = next((a for a in authors if a["_id"] == reply["user"]), None)
|
||||||
|
if author:
|
||||||
|
reply["author"] = author
|
||||||
|
if "avatar" in author:
|
||||||
|
reply["author"]["avatarUrl"] = uploads.get_presigned_url(
|
||||||
|
"users/{0}/{1}".format(author["_id"], author["avatar"])
|
||||||
|
)
|
||||||
|
if "attachments" in reply:
|
||||||
|
for attachment in reply["attachments"]:
|
||||||
|
if attachment["type"] == "file":
|
||||||
|
attachment["isImage"] = False
|
||||||
|
if re.search(
|
||||||
|
r"(.jpg)|(.png)|(.jpeg)|(.gif)$",
|
||||||
|
attachment["storedName"].lower(),
|
||||||
|
):
|
||||||
|
attachment["isImage"] = True
|
||||||
|
attachment["url"] = uploads.get_presigned_url(
|
||||||
|
"groups/{0}/topics/{1}/{2}".format(
|
||||||
|
id, topic_id, attachment["storedName"]
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
return {
|
||||||
|
"topic": topic,
|
||||||
|
"replies": replies,
|
||||||
|
"totalReplies": total_replies,
|
||||||
|
"page": page,
|
||||||
|
"totalPages": math.ceil(total_replies / REPLIES_PER_PAGE),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def delete_forum_topic_reply(user, id, topic_id, reply_id):
|
||||||
|
db = database.get_db()
|
||||||
|
id = ObjectId(id)
|
||||||
|
topic_id = ObjectId(topic_id)
|
||||||
|
reply_id = ObjectId(reply_id)
|
||||||
|
group = db.groups.find_one({"_id": id})
|
||||||
|
if not group:
|
||||||
|
raise util.errors.NotFound("Group not found")
|
||||||
|
topic = db.groupForumTopics.find_one({"_id": topic_id})
|
||||||
|
if not topic or topic.get("group") != id:
|
||||||
|
raise util.errors.NotFound("Topic not found")
|
||||||
|
reply = db.groupForumTopicReplies.find_one({"_id": reply_id})
|
||||||
|
if not reply or reply.get("topic") != topic_id:
|
||||||
|
raise util.errors.NotFound("Reply not found")
|
||||||
|
if not (user["_id"] in group.get("admins", []) or user["_id"] == reply.get("user")):
|
||||||
|
raise util.errors.Forbidden("You don't have permission to delete the reply")
|
||||||
|
db.groupForumTopicReplies.delete_one({"_id": reply_id})
|
||||||
|
last_reply = db.groupForumTopicReplies.find_one(
|
||||||
|
{"topic": topic_id}, sort=[("createdAt", pymongo.DESCENDING)]
|
||||||
|
)
|
||||||
|
db.groupForumTopics.update_one(
|
||||||
|
{"_id": topic_id},
|
||||||
|
{
|
||||||
|
"$set": {
|
||||||
|
"totalReplies": db.groupForumTopicReplies.count_documents(
|
||||||
|
{"topic": topic_id}
|
||||||
|
),
|
||||||
|
"lastReply": last_reply["_id"] if last_reply else None,
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
|
return {"deletedReply": reply_id}
|
252
api/api/invitations.py
Normal file
@ -0,0 +1,252 @@
|
|||||||
|
import datetime
|
||||||
|
import os
|
||||||
|
from bson.objectid import ObjectId
|
||||||
|
from util import database, util, mail
|
||||||
|
from api import uploads, groups
|
||||||
|
|
||||||
|
APP_NAME = os.environ.get("APP_NAME")
|
||||||
|
APP_URL = os.environ.get("APP_URL")
|
||||||
|
|
||||||
|
|
||||||
|
def get(user):
|
||||||
|
db = database.get_db()
|
||||||
|
admin_groups = list(db.groups.find({"admins": user["_id"]}))
|
||||||
|
invites = list(
|
||||||
|
db.invitations.find(
|
||||||
|
{
|
||||||
|
"$or": [
|
||||||
|
{"recipient": user["_id"]},
|
||||||
|
{
|
||||||
|
"recipientGroup": {
|
||||||
|
"$in": list(map(lambda g: g["_id"], admin_groups))
|
||||||
|
}
|
||||||
|
},
|
||||||
|
]
|
||||||
|
}
|
||||||
|
)
|
||||||
|
)
|
||||||
|
inviters = list(
|
||||||
|
db.users.find(
|
||||||
|
{"_id": {"$in": [i["user"] for i in invites]}}, {"username": 1, "avatar": 1}
|
||||||
|
)
|
||||||
|
)
|
||||||
|
for invite in invites:
|
||||||
|
invite["recipient"] = user["_id"]
|
||||||
|
if invite["type"] in ["group", "groupJoinRequest"]:
|
||||||
|
invite["group"] = db.groups.find_one({"_id": invite["typeId"]}, {"name": 1})
|
||||||
|
inviter = next((u for u in inviters if u["_id"] == invite["user"]), None)
|
||||||
|
if inviter:
|
||||||
|
if "avatar" in inviter:
|
||||||
|
inviter["avatarUrl"] = uploads.get_presigned_url(
|
||||||
|
"users/{0}/{1}".format(inviter["_id"], inviter["avatar"])
|
||||||
|
)
|
||||||
|
invite["invitedBy"] = inviter
|
||||||
|
sent_invites = list(db.invitations.find({"user": user["_id"]}))
|
||||||
|
recipients = list(
|
||||||
|
db.users.find(
|
||||||
|
{"_id": {"$in": list(map(lambda i: i.get("recipient"), sent_invites))}},
|
||||||
|
{"username": 1, "avatar": 1},
|
||||||
|
)
|
||||||
|
)
|
||||||
|
for invite in sent_invites:
|
||||||
|
if invite["type"] in ["group", "groupJoinRequest"]:
|
||||||
|
invite["group"] = db.groups.find_one({"_id": invite["typeId"]}, {"name": 1})
|
||||||
|
recipient = next(
|
||||||
|
(u for u in recipients if u["_id"] == invite.get("recipient")), None
|
||||||
|
)
|
||||||
|
if recipient:
|
||||||
|
if "avatar" in recipient:
|
||||||
|
recipient["avatarUrl"] = uploads.get_presigned_url(
|
||||||
|
"users/{0}/{1}".format(recipient["_id"], recipient["avatar"])
|
||||||
|
)
|
||||||
|
invite["invitedBy"] = recipient
|
||||||
|
return {"invitations": invites, "sentInvitations": sent_invites}
|
||||||
|
|
||||||
|
|
||||||
|
def accept(user, id):
|
||||||
|
db = database.get_db()
|
||||||
|
id = ObjectId(id)
|
||||||
|
invite = db.invitations.find_one({"_id": id})
|
||||||
|
if not invite:
|
||||||
|
raise util.errors.NotFound("Invitation not found")
|
||||||
|
if invite["type"] == "group":
|
||||||
|
if invite["recipient"] != user["_id"]:
|
||||||
|
raise util.errors.Forbidden("This invitation is not yours to accept")
|
||||||
|
group = db.groups.find_one({"_id": invite["typeId"]}, {"name": 1})
|
||||||
|
if not group:
|
||||||
|
db.invitations.delete_one({"_id": id})
|
||||||
|
return {"acceptedInvitation": id}
|
||||||
|
groups.create_member(user, group["_id"], user["_id"], invited=True)
|
||||||
|
db.invitations.delete_one({"_id": id})
|
||||||
|
return {"acceptedInvitation": id, "group": group}
|
||||||
|
if invite["type"] == "groupJoinRequest":
|
||||||
|
group = db.groups.find_one({"_id": invite["typeId"]})
|
||||||
|
if user["_id"] not in group.get("admins", []):
|
||||||
|
raise util.errors.Forbidden(
|
||||||
|
"You need to be an admin of this group to accept this request"
|
||||||
|
)
|
||||||
|
requester = db.users.find_one({"_id": invite["user"]})
|
||||||
|
if not group or not requester:
|
||||||
|
db.invitations.delete_one({"_id": id})
|
||||||
|
return {"acceptedInvitation": id}
|
||||||
|
groups.create_member(requester, group["_id"], requester["_id"], invited=True)
|
||||||
|
db.invitations.delete_one({"_id": id})
|
||||||
|
return {"acceptedInvitation": id, "group": group}
|
||||||
|
|
||||||
|
|
||||||
|
def delete(user, id):
|
||||||
|
db = database.get_db()
|
||||||
|
id = ObjectId(id)
|
||||||
|
invite = db.invitations.find_one({"_id": id})
|
||||||
|
if not invite:
|
||||||
|
raise util.errors.NotFound("Invitation not found")
|
||||||
|
if invite["type"] == "group":
|
||||||
|
if invite["recipient"] != user["_id"]:
|
||||||
|
raise util.errors.Forbidden("This invitation is not yours to decline")
|
||||||
|
if invite["type"] == "groupJoinRequest":
|
||||||
|
group = db.groups.find_one({"_id": invite["typeId"]})
|
||||||
|
if user["_id"] not in group.get("admins", []):
|
||||||
|
raise util.errors.Forbidden(
|
||||||
|
"You need to be an admin of this group to manage this request"
|
||||||
|
)
|
||||||
|
db.invitations.delete_one({"_id": id})
|
||||||
|
return {"deletedInvitation": id}
|
||||||
|
|
||||||
|
|
||||||
|
def create_group_invitation(user, group_id, data):
|
||||||
|
if not data or "user" not in data:
|
||||||
|
raise util.errors.BadRequest("Invalid request")
|
||||||
|
db = database.get_db()
|
||||||
|
recipient_id = ObjectId(data["user"])
|
||||||
|
group_id = ObjectId(group_id)
|
||||||
|
group = db.groups.find_one({"_id": group_id}, {"admins": 1, "name": 1})
|
||||||
|
if not group:
|
||||||
|
raise util.errors.NotFound("Group not found")
|
||||||
|
if user["_id"] not in group.get("admins", []):
|
||||||
|
raise util.errors.Forbidden("You need to be a group admin to invite users")
|
||||||
|
recipient = db.users.find_one(
|
||||||
|
{"_id": recipient_id},
|
||||||
|
{"groups": 1, "username": 1, "email": 1, "subscriptions": 1},
|
||||||
|
)
|
||||||
|
if not recipient:
|
||||||
|
raise util.errors.NotFound("User not found")
|
||||||
|
if group_id in recipient.get("groups", []):
|
||||||
|
raise util.errors.BadRequest("This user is already in this group")
|
||||||
|
if db.invitations.find_one(
|
||||||
|
{"recipient": recipient_id, "typeId": group_id, "type": "group"}
|
||||||
|
):
|
||||||
|
raise util.errors.BadRequest("This user has already been invited to this group")
|
||||||
|
invite = {
|
||||||
|
"createdAt": datetime.datetime.now(),
|
||||||
|
"user": user["_id"],
|
||||||
|
"recipient": recipient_id,
|
||||||
|
"type": "group",
|
||||||
|
"typeId": group_id,
|
||||||
|
}
|
||||||
|
result = db.invitations.insert_one(invite)
|
||||||
|
if "groups.invited" in recipient.get("subscriptions", {}).get("email", []):
|
||||||
|
mail.send(
|
||||||
|
{
|
||||||
|
"to_user": recipient,
|
||||||
|
"subject": "You've been invited to a group on {}!".format(APP_NAME),
|
||||||
|
"text": "Dear {0},\n\nYou have been invited to join the group {1} on {3}!\n\nLogin by visting {2} to find your invitation.".format(
|
||||||
|
recipient["username"],
|
||||||
|
group["name"],
|
||||||
|
APP_URL,
|
||||||
|
APP_NAME,
|
||||||
|
),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
invite["_id"] = result.inserted_id
|
||||||
|
return invite
|
||||||
|
|
||||||
|
|
||||||
|
def create_group_request(user, group_id):
|
||||||
|
db = database.get_db()
|
||||||
|
group_id = ObjectId(group_id)
|
||||||
|
group = db.groups.find_one({"_id": group_id}, {"admins": 1, "name": 1})
|
||||||
|
if not group:
|
||||||
|
raise util.errors.NotFound("Group not found")
|
||||||
|
if group_id in user.get("groups", []):
|
||||||
|
raise util.errors.BadRequest("You are already a member of this group")
|
||||||
|
admin = db.users.find_one(
|
||||||
|
{"_id": {"$in": group.get("admins", [])}},
|
||||||
|
{"groups": 1, "username": 1, "email": 1, "subscriptions": 1},
|
||||||
|
)
|
||||||
|
if not admin:
|
||||||
|
raise util.errors.NotFound("No users can approve you to join this group")
|
||||||
|
if db.invitations.find_one(
|
||||||
|
{"recipient": user["_id"], "typeId": group_id, "type": "group"}
|
||||||
|
):
|
||||||
|
raise util.errors.BadRequest("You have already been invited to this group")
|
||||||
|
if db.invitations.find_one(
|
||||||
|
{"user": user["_id"], "typeId": group_id, "type": "groupJoinRequest"}
|
||||||
|
):
|
||||||
|
raise util.errors.BadRequest("You have already requested access to this group")
|
||||||
|
invite = {
|
||||||
|
"createdAt": datetime.datetime.now(),
|
||||||
|
"user": user["_id"],
|
||||||
|
"recipientGroup": group["_id"],
|
||||||
|
"type": "groupJoinRequest",
|
||||||
|
"typeId": group_id,
|
||||||
|
}
|
||||||
|
result = db.invitations.insert_one(invite)
|
||||||
|
if "groups.joinRequested" in admin.get("subscriptions", {}).get("email", []):
|
||||||
|
mail.send(
|
||||||
|
{
|
||||||
|
"to_user": admin,
|
||||||
|
"subject": "Someone wants to join your group",
|
||||||
|
"text": "Dear {0},\n\{1} has requested to join your group {2} on {4}!\n\nLogin by visting {3} to find and approve your requests.".format(
|
||||||
|
admin["username"],
|
||||||
|
user["username"],
|
||||||
|
group["name"],
|
||||||
|
APP_URL,
|
||||||
|
APP_NAME,
|
||||||
|
),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
invite["_id"] = result.inserted_id
|
||||||
|
return invite
|
||||||
|
|
||||||
|
|
||||||
|
def get_group_invitations(user, id):
|
||||||
|
db = database.get_db()
|
||||||
|
group_id = ObjectId(id)
|
||||||
|
group = db.groups.find_one({"_id": group_id}, {"admins": 1})
|
||||||
|
if not group:
|
||||||
|
raise util.errors.NotFound("Group not found")
|
||||||
|
if user["_id"] not in group.get("admins", []):
|
||||||
|
raise util.errors.Forbidden("You need to be a group admin to see invitations")
|
||||||
|
invites = list(db.invitations.find({"type": "group", "typeId": group_id}))
|
||||||
|
recipients = list(
|
||||||
|
db.users.find(
|
||||||
|
{"_id": {"$in": [i["recipient"] for i in invites]}},
|
||||||
|
{"username": 1, "avatar": 1},
|
||||||
|
)
|
||||||
|
)
|
||||||
|
for invite in invites:
|
||||||
|
for recipient in recipients:
|
||||||
|
if invite["recipient"] == recipient["_id"]:
|
||||||
|
if "avatar" in recipient:
|
||||||
|
recipient["avatarUrl"] = uploads.get_presigned_url(
|
||||||
|
"users/{0}/{1}".format(recipient["_id"], recipient["avatar"])
|
||||||
|
)
|
||||||
|
invite["recipientUser"] = recipient
|
||||||
|
break
|
||||||
|
return {"invitations": invites}
|
||||||
|
|
||||||
|
|
||||||
|
def delete_group_invitation(user, id, invite_id):
|
||||||
|
db = database.get_db()
|
||||||
|
group_id = ObjectId(id)
|
||||||
|
invite_id = ObjectId(invite_id)
|
||||||
|
group = db.groups.find_one({"_id": group_id}, {"admins": 1})
|
||||||
|
if not group:
|
||||||
|
raise util.errors.NotFound("Group not found")
|
||||||
|
if user["_id"] not in group.get("admins", []):
|
||||||
|
raise util.errors.Forbidden("You need to be a group admin to see invitations")
|
||||||
|
invite = db.invitations.find_one({"_id": invite_id})
|
||||||
|
if not invite or invite["typeId"] != group_id:
|
||||||
|
raise util.errors.NotFound("This invite could not be found")
|
||||||
|
db.invitations.delete_one({"_id": invite_id})
|
||||||
|
return {"deletedInvite": invite_id}
|
272
api/api/objects.py
Normal file
@ -0,0 +1,272 @@
|
|||||||
|
import datetime
|
||||||
|
import base64
|
||||||
|
import os
|
||||||
|
from bson.objectid import ObjectId
|
||||||
|
import requests
|
||||||
|
from util import database, wif, util, mail
|
||||||
|
from api import uploads
|
||||||
|
|
||||||
|
APP_NAME = os.environ.get("APP_NAME")
|
||||||
|
APP_URL = os.environ.get("APP_URL")
|
||||||
|
|
||||||
|
|
||||||
|
def delete(user, id):
|
||||||
|
db = database.get_db()
|
||||||
|
obj = db.objects.find_one(ObjectId(id), {"project": 1})
|
||||||
|
if not obj:
|
||||||
|
raise util.errors.NotFound("Object not found")
|
||||||
|
project = db.projects.find_one(obj.get("project"), {"user": 1})
|
||||||
|
if not project:
|
||||||
|
raise util.errors.NotFound("Project not found")
|
||||||
|
if not util.can_edit_project(user, project):
|
||||||
|
raise util.errors.Forbidden("Forbidden", 403)
|
||||||
|
db.objects.delete_one({"_id": ObjectId(id)})
|
||||||
|
return {"deletedObject": id}
|
||||||
|
|
||||||
|
|
||||||
|
def get(user, id):
|
||||||
|
db = database.get_db()
|
||||||
|
obj = db.objects.find_one({"_id": ObjectId(id)})
|
||||||
|
if not obj:
|
||||||
|
raise util.errors.NotFound("Object not found")
|
||||||
|
proj = db.projects.find_one({"_id": obj["project"]})
|
||||||
|
if not proj:
|
||||||
|
raise util.errors.NotFound("Project not found")
|
||||||
|
is_owner = user and (user.get("_id") == proj["user"])
|
||||||
|
if not is_owner and proj["visibility"] != "public":
|
||||||
|
raise util.errors.Forbidden("Forbidden")
|
||||||
|
if not util.can_edit_project(user, proj) and obj.get("moderationRequired"):
|
||||||
|
raise util.errors.Forbidden("Awaiting moderation")
|
||||||
|
owner = db.users.find_one({"_id": proj["user"]}, {"username": 1, "avatar": 1})
|
||||||
|
if obj["type"] == "file" and "storedName" in obj:
|
||||||
|
obj["url"] = uploads.get_presigned_url(
|
||||||
|
"projects/{0}/{1}".format(proj["_id"], obj["storedName"])
|
||||||
|
)
|
||||||
|
if obj["type"] == "pattern" and "preview" in obj and ".png" in obj["preview"]:
|
||||||
|
obj["previewUrl"] = uploads.get_presigned_url(
|
||||||
|
"projects/{0}/{1}".format(proj["_id"], obj["preview"])
|
||||||
|
)
|
||||||
|
del obj["preview"]
|
||||||
|
if obj.get("fullPreview"):
|
||||||
|
obj["fullPreviewUrl"] = uploads.get_presigned_url(
|
||||||
|
"projects/{0}/{1}".format(proj["_id"], obj["fullPreview"])
|
||||||
|
)
|
||||||
|
obj["projectObject"] = proj
|
||||||
|
if owner:
|
||||||
|
if "avatar" in owner:
|
||||||
|
owner["avatarUrl"] = uploads.get_presigned_url(
|
||||||
|
"users/{0}/{1}".format(str(owner["_id"]), owner["avatar"])
|
||||||
|
)
|
||||||
|
obj["projectObject"]["owner"] = owner
|
||||||
|
return obj
|
||||||
|
|
||||||
|
|
||||||
|
def copy_to_project(user, id, project_id):
|
||||||
|
db = database.get_db()
|
||||||
|
obj = db.objects.find_one(ObjectId(id))
|
||||||
|
if not obj:
|
||||||
|
raise util.errors.NotFound("This object could not be found")
|
||||||
|
original_project = db.projects.find_one(obj["project"])
|
||||||
|
if not original_project:
|
||||||
|
raise util.errors.NotFound("Project not found")
|
||||||
|
if not original_project.get("openSource") and not util.can_edit_project(
|
||||||
|
user, original_project
|
||||||
|
):
|
||||||
|
raise util.errors.Forbidden("This project is not open-source")
|
||||||
|
if original_project.get("visibility") != "public" and not util.can_edit_project(
|
||||||
|
user, original_project
|
||||||
|
):
|
||||||
|
raise util.errors.Forbidden("This project is not public")
|
||||||
|
target_project = db.projects.find_one(ObjectId(project_id))
|
||||||
|
if not target_project or not util.can_edit_project(user, target_project):
|
||||||
|
raise util.errors.Forbidden("You don't own the target project")
|
||||||
|
|
||||||
|
obj["_id"] = ObjectId()
|
||||||
|
obj["project"] = target_project["_id"]
|
||||||
|
obj["createdAt"] = datetime.datetime.now()
|
||||||
|
obj["commentCount"] = 0
|
||||||
|
if "preview" in obj:
|
||||||
|
del obj["preview"]
|
||||||
|
if obj.get("pattern"):
|
||||||
|
images = wif.generate_images(obj)
|
||||||
|
if images:
|
||||||
|
obj.update(images)
|
||||||
|
db.objects.insert_one(obj)
|
||||||
|
return obj
|
||||||
|
|
||||||
|
|
||||||
|
def get_wif(user, id):
|
||||||
|
db = database.get_db()
|
||||||
|
obj = db.objects.find_one(ObjectId(id))
|
||||||
|
if not obj:
|
||||||
|
raise util.errors.NotFound("Object not found")
|
||||||
|
project = db.projects.find_one(obj["project"])
|
||||||
|
if not project.get("openSource") and not util.can_edit_project(user, project):
|
||||||
|
raise util.errors.Forbidden("This project is not open-source")
|
||||||
|
if project.get("visibility") != "public" and not util.can_edit_project(
|
||||||
|
user, project
|
||||||
|
):
|
||||||
|
raise util.errors.Forbidden("This project is not public")
|
||||||
|
try:
|
||||||
|
output = wif.dumps(obj).replace("\n", "\\n")
|
||||||
|
return {"wif": output}
|
||||||
|
except Exception:
|
||||||
|
raise util.errors.BadRequest("Unable to create WIF file")
|
||||||
|
|
||||||
|
|
||||||
|
def get_pdf(user, id):
|
||||||
|
db = database.get_db()
|
||||||
|
obj = db.objects.find_one(ObjectId(id))
|
||||||
|
if not obj:
|
||||||
|
raise util.errors.NotFound("Object not found")
|
||||||
|
project = db.projects.find_one(obj["project"])
|
||||||
|
if not project.get("openSource") and not util.can_edit_project(user, project):
|
||||||
|
raise util.errors.Forbidden("This project is not open-source")
|
||||||
|
if project.get("visibility") != "public" and not util.can_edit_project(
|
||||||
|
user, project
|
||||||
|
):
|
||||||
|
raise util.errors.Forbidden("This project is not public")
|
||||||
|
try:
|
||||||
|
response = requests.get(
|
||||||
|
"https://h2io6k3ovg.execute-api.eu-west-1.amazonaws.com/prod/pdf?object="
|
||||||
|
+ id
|
||||||
|
+ "&landscape=true&paperWidth=23.39&paperHeight=33.11"
|
||||||
|
)
|
||||||
|
response.raise_for_status()
|
||||||
|
pdf = uploads.get_file("objects/" + id + "/export.pdf")
|
||||||
|
body64 = base64.b64encode(pdf["Body"].read())
|
||||||
|
return {"pdf": body64.decode("ascii")}
|
||||||
|
except Exception as e:
|
||||||
|
print(e)
|
||||||
|
raise util.errors.BadRequest("Unable to export PDF")
|
||||||
|
|
||||||
|
|
||||||
|
def update(user, id, data):
|
||||||
|
db = database.get_db()
|
||||||
|
obj = db.objects.find_one(ObjectId(id), {"project": 1})
|
||||||
|
if not obj:
|
||||||
|
raise util.errors.NotFound("Object not found")
|
||||||
|
project = db.projects.find_one(obj.get("project"), {"user": 1})
|
||||||
|
if not project:
|
||||||
|
raise util.errors.NotFound("Project not found")
|
||||||
|
if not util.can_edit_project(user, project):
|
||||||
|
raise util.errors.Forbidden("Forbidden")
|
||||||
|
allowed_keys = ["name", "description", "pattern"]
|
||||||
|
|
||||||
|
updater = util.build_updater(data, allowed_keys)
|
||||||
|
if updater:
|
||||||
|
db.objects.update_one({"_id": ObjectId(id)}, updater)
|
||||||
|
|
||||||
|
if data.get("pattern"):
|
||||||
|
obj.update(data)
|
||||||
|
wif.generate_images(obj)
|
||||||
|
|
||||||
|
return get(user, id)
|
||||||
|
|
||||||
|
|
||||||
|
def create_comment(user, id, data):
|
||||||
|
if not data or not data.get("content"):
|
||||||
|
raise util.errors.BadRequest("Comment data is required")
|
||||||
|
db = database.get_db()
|
||||||
|
obj = db.objects.find_one({"_id": ObjectId(id)})
|
||||||
|
if not obj:
|
||||||
|
raise util.errors.NotFound("We could not find the specified object")
|
||||||
|
comment = {
|
||||||
|
"content": data.get("content", ""),
|
||||||
|
"object": ObjectId(id),
|
||||||
|
"user": user["_id"],
|
||||||
|
"createdAt": datetime.datetime.now(),
|
||||||
|
"moderationRequired": True,
|
||||||
|
}
|
||||||
|
result = db.comments.insert_one(comment)
|
||||||
|
db.objects.update_one({"_id": ObjectId(id)}, {"$inc": {"commentCount": 1}})
|
||||||
|
comment["_id"] = result.inserted_id
|
||||||
|
comment["authorUser"] = {
|
||||||
|
"username": user["username"],
|
||||||
|
"avatar": user.get("avatar"),
|
||||||
|
"avatarUrl": uploads.get_presigned_url(
|
||||||
|
"users/{0}/{1}".format(user["_id"], user.get("avatar"))
|
||||||
|
),
|
||||||
|
}
|
||||||
|
util.send_moderation_request(user, "comments", comment)
|
||||||
|
return comment
|
||||||
|
|
||||||
|
|
||||||
|
def send_comment_notification(id):
|
||||||
|
db = database.get_db()
|
||||||
|
comment = db.comments.find_one({"_id": ObjectId(id)})
|
||||||
|
user = db.users.find_one({"_id": comment["user"]})
|
||||||
|
obj = db.objects.find_one({"_id": comment["object"]})
|
||||||
|
project = db.projects.find_one({"_id": obj["project"]})
|
||||||
|
project_owner = db.users.find_one(
|
||||||
|
{"_id": project["user"], "subscriptions.email": "projects.commented"}
|
||||||
|
)
|
||||||
|
if project_owner and project_owner["_id"] != user["_id"]:
|
||||||
|
mail.send(
|
||||||
|
{
|
||||||
|
"to_user": project_owner,
|
||||||
|
"subject": "{} commented on {}".format(
|
||||||
|
user["username"], project["name"]
|
||||||
|
),
|
||||||
|
"text": "Dear {0},\n\n{1} commented on {2} in your project {3} on {6}:\n\n{4}\n\nFollow the link below to see the comment:\n\n{5}".format(
|
||||||
|
project_owner["username"],
|
||||||
|
user["username"],
|
||||||
|
obj["name"],
|
||||||
|
project["name"],
|
||||||
|
comment["content"],
|
||||||
|
"{}/{}/{}/{}".format(
|
||||||
|
APP_URL, project_owner["username"], project["path"], str(id)
|
||||||
|
),
|
||||||
|
APP_NAME,
|
||||||
|
),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def get_comments(user, id):
|
||||||
|
id = ObjectId(id)
|
||||||
|
db = database.get_db()
|
||||||
|
obj = db.objects.find_one({"_id": id}, {"project": 1})
|
||||||
|
if not obj:
|
||||||
|
raise util.errors.NotFound("Object not found")
|
||||||
|
proj = db.projects.find_one({"_id": obj["project"]}, {"user": 1, "visibility": 1})
|
||||||
|
if not proj:
|
||||||
|
raise util.errors.NotFound("Project not found")
|
||||||
|
is_owner = user and (user.get("_id") == proj["user"])
|
||||||
|
if not is_owner and proj["visibility"] != "public":
|
||||||
|
raise util.errors.Forbidden("This project is private")
|
||||||
|
query = {
|
||||||
|
"object": id,
|
||||||
|
"$or": [
|
||||||
|
{"moderationRequired": {"$ne": True}},
|
||||||
|
{"user": user["_id"] if user else None},
|
||||||
|
],
|
||||||
|
}
|
||||||
|
comments = list(db.comments.find(query))
|
||||||
|
user_ids = list(map(lambda c: c["user"], comments))
|
||||||
|
users = list(
|
||||||
|
db.users.find({"_id": {"$in": user_ids}}, {"username": 1, "avatar": 1})
|
||||||
|
)
|
||||||
|
for comment in comments:
|
||||||
|
for u in users:
|
||||||
|
if comment["user"] == u["_id"]:
|
||||||
|
comment["authorUser"] = u
|
||||||
|
if "avatar" in u:
|
||||||
|
comment["authorUser"]["avatarUrl"] = uploads.get_presigned_url(
|
||||||
|
"users/{0}/{1}".format(u["_id"], u["avatar"])
|
||||||
|
)
|
||||||
|
return {"comments": comments}
|
||||||
|
|
||||||
|
|
||||||
|
def delete_comment(user, id, comment_id):
|
||||||
|
db = database.get_db()
|
||||||
|
comment = db.comments.find_one({"_id": ObjectId(comment_id)})
|
||||||
|
obj = db.objects.find_one({"_id": ObjectId(id)})
|
||||||
|
if not comment or not obj or obj["_id"] != comment["object"]:
|
||||||
|
raise util.errors.NotFound("Comment not found")
|
||||||
|
project = db.projects.find_one({"_id": obj["project"]})
|
||||||
|
if comment["user"] != user["_id"] and not util.can_edit_project(user, project):
|
||||||
|
raise util.errors.Forbidden("You can't delete this comment")
|
||||||
|
db.comments.delete_one({"_id": comment["_id"]})
|
||||||
|
db.objects.update_one({"_id": ObjectId(id)}, {"$inc": {"commentCount": -1}})
|
||||||
|
return {"deletedComment": comment["_id"]}
|
362
api/api/projects.py
Normal file
@ -0,0 +1,362 @@
|
|||||||
|
import datetime
|
||||||
|
import re
|
||||||
|
import os
|
||||||
|
from bson.objectid import ObjectId
|
||||||
|
from util import database, wif, util, mail
|
||||||
|
from api import uploads, objects
|
||||||
|
|
||||||
|
default_pattern = {
|
||||||
|
"warp": {
|
||||||
|
"shafts": 8,
|
||||||
|
"threading": [{"shaft": 0}] * 100,
|
||||||
|
"defaultColour": "178,53,111",
|
||||||
|
"defaultSpacing": 1,
|
||||||
|
"defaultThickness": 1,
|
||||||
|
"guideFrequency": 8,
|
||||||
|
},
|
||||||
|
"weft": {
|
||||||
|
"treadles": 8,
|
||||||
|
"treadling": [{"treadle": 0}] * 50,
|
||||||
|
"defaultColour": "53,69,178",
|
||||||
|
"defaultSpacing": 1,
|
||||||
|
"defaultThickness": 1,
|
||||||
|
"guideFrequency": 8,
|
||||||
|
},
|
||||||
|
"tieups": [[]] * 8,
|
||||||
|
"colours": [
|
||||||
|
"256,256,256",
|
||||||
|
"0,0,0",
|
||||||
|
"50,0,256",
|
||||||
|
"0,68,256",
|
||||||
|
"0,256,256",
|
||||||
|
"0,256,0",
|
||||||
|
"119,256,0",
|
||||||
|
"256,256,0",
|
||||||
|
"256,136,0",
|
||||||
|
"256,0,0",
|
||||||
|
"256,0,153",
|
||||||
|
"204,0,256",
|
||||||
|
"132,102,256",
|
||||||
|
"102,155,256",
|
||||||
|
"102,256,256",
|
||||||
|
"102,256,102",
|
||||||
|
"201,256,102",
|
||||||
|
"256,256,102",
|
||||||
|
"256,173,102",
|
||||||
|
"256,102,102",
|
||||||
|
"256,102,194",
|
||||||
|
"224,102,256",
|
||||||
|
"31,0,153",
|
||||||
|
"0,41,153",
|
||||||
|
"0,153,153",
|
||||||
|
"0,153,0",
|
||||||
|
"71,153,0",
|
||||||
|
"153,153,0",
|
||||||
|
"153,82,0",
|
||||||
|
"153,0,0",
|
||||||
|
"153,0,92",
|
||||||
|
"122,0,153",
|
||||||
|
"94,68,204",
|
||||||
|
"68,102,204",
|
||||||
|
"68,204,204",
|
||||||
|
"68,204,68",
|
||||||
|
"153,204,68",
|
||||||
|
"204,204,68",
|
||||||
|
"204,136,68",
|
||||||
|
"204,68,68",
|
||||||
|
"204,68,153",
|
||||||
|
"170,68,204",
|
||||||
|
"37,0,204",
|
||||||
|
"0,50,204",
|
||||||
|
"0,204,204",
|
||||||
|
"0,204,0",
|
||||||
|
"89,204,0",
|
||||||
|
"204,204,0",
|
||||||
|
"204,102,0",
|
||||||
|
"204,0,0",
|
||||||
|
"204,0,115",
|
||||||
|
"153,0,204",
|
||||||
|
"168,136,256",
|
||||||
|
"136,170,256",
|
||||||
|
"136,256,256",
|
||||||
|
"136,256,136",
|
||||||
|
"230,256,136",
|
||||||
|
"256,256,136",
|
||||||
|
"256,178,136",
|
||||||
|
"256,136,136",
|
||||||
|
"256,136,204",
|
||||||
|
"240,136,256",
|
||||||
|
"49,34,238",
|
||||||
|
"34,68,238",
|
||||||
|
"34,238,238",
|
||||||
|
"34,238,34",
|
||||||
|
"71,238,34",
|
||||||
|
"238,238,34",
|
||||||
|
"238,82,34",
|
||||||
|
"238,34,34",
|
||||||
|
"238,34,92",
|
||||||
|
"122,34,238",
|
||||||
|
"128,102,238",
|
||||||
|
"102,136,238",
|
||||||
|
"102,238,238",
|
||||||
|
"102,238,102",
|
||||||
|
"187,238,102",
|
||||||
|
"238,238,102",
|
||||||
|
"238,170,102",
|
||||||
|
"238,102,102",
|
||||||
|
"238,102,187",
|
||||||
|
"204,102,238",
|
||||||
|
"178,53,111",
|
||||||
|
"53,69,178",
|
||||||
|
],
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def derive_path(name):
|
||||||
|
path = name.replace(" ", "-").lower()
|
||||||
|
return re.sub("[^0-9a-z\-]+", "", path)
|
||||||
|
|
||||||
|
|
||||||
|
def get_by_username(username, project_path):
|
||||||
|
db = database.get_db()
|
||||||
|
owner = db.users.find_one({"username": username}, {"_id": 1, "username": 1})
|
||||||
|
if not owner:
|
||||||
|
raise util.errors.BadRequest("User not found")
|
||||||
|
project = db.projects.find_one({"user": owner["_id"], "path": project_path})
|
||||||
|
if not project:
|
||||||
|
raise util.errors.NotFound("Project not found")
|
||||||
|
project["owner"] = owner
|
||||||
|
project["fullName"] = owner["username"] + "/" + project["path"]
|
||||||
|
return project
|
||||||
|
|
||||||
|
|
||||||
|
def create(user, data):
|
||||||
|
if not data:
|
||||||
|
raise util.errors.BadRequest("Invalid request")
|
||||||
|
name = data.get("name", "")
|
||||||
|
if len(name) < 3:
|
||||||
|
raise util.errors.BadRequest("A longer name is required")
|
||||||
|
db = database.get_db()
|
||||||
|
|
||||||
|
path = derive_path(name)
|
||||||
|
if db.projects.find_one({"user": user["_id"], "path": path}, {"_id": 1}):
|
||||||
|
raise util.errors.BadRequest("Bad Name")
|
||||||
|
groups = data.get("groupVisibility", [])
|
||||||
|
group_visibility = []
|
||||||
|
for group in groups:
|
||||||
|
group_visibility.append(ObjectId(group))
|
||||||
|
proj = {
|
||||||
|
"name": name,
|
||||||
|
"description": data.get("description", ""),
|
||||||
|
"visibility": data.get("visibility", "public"),
|
||||||
|
"openSource": data.get("openSource", True),
|
||||||
|
"groupVisibility": group_visibility,
|
||||||
|
"path": path,
|
||||||
|
"user": user["_id"],
|
||||||
|
"createdAt": datetime.datetime.now(),
|
||||||
|
}
|
||||||
|
result = db.projects.insert_one(proj)
|
||||||
|
proj["_id"] = result.inserted_id
|
||||||
|
proj["owner"] = {"_id": user["_id"], "username": user["username"]}
|
||||||
|
proj["fullName"] = user["username"] + "/" + proj["path"]
|
||||||
|
return proj
|
||||||
|
|
||||||
|
|
||||||
|
def get(user, username, path):
|
||||||
|
db = database.get_db()
|
||||||
|
owner = db.users.find_one(
|
||||||
|
{"username": username},
|
||||||
|
{
|
||||||
|
"_id": 1,
|
||||||
|
"username": 1,
|
||||||
|
"avatar": 1,
|
||||||
|
"isSilverSupporter": 1,
|
||||||
|
"isGoldSupporter": 1,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
if not owner:
|
||||||
|
raise util.errors.NotFound("User not found")
|
||||||
|
project = db.projects.find_one({"user": owner["_id"], "path": path})
|
||||||
|
if not project:
|
||||||
|
raise util.errors.NotFound("Project not found")
|
||||||
|
if not util.can_view_project(user, project):
|
||||||
|
raise util.errors.Forbidden("This project is private")
|
||||||
|
|
||||||
|
if "avatar" in owner:
|
||||||
|
owner["avatarUrl"] = uploads.get_presigned_url(
|
||||||
|
"users/{0}/{1}".format(owner["_id"], owner["avatar"])
|
||||||
|
)
|
||||||
|
project["owner"] = owner
|
||||||
|
project["fullName"] = owner["username"] + "/" + project["path"]
|
||||||
|
return project
|
||||||
|
|
||||||
|
|
||||||
|
def update(user, username, project_path, update):
|
||||||
|
db = database.get_db()
|
||||||
|
project = get_by_username(username, project_path)
|
||||||
|
if not util.can_edit_project(user, project):
|
||||||
|
raise util.errors.Forbidden("Forbidden")
|
||||||
|
|
||||||
|
current_path = project_path
|
||||||
|
if "name" in update:
|
||||||
|
if len(update["name"]) < 3:
|
||||||
|
raise util.errors.BadRequest("The name is too short.")
|
||||||
|
path = derive_path(update["name"])
|
||||||
|
if db.projects.find_one({"user": user["_id"], "path": path}, {"_id": 1}):
|
||||||
|
raise util.errors.BadRequest(
|
||||||
|
"You already have a project with a similar name"
|
||||||
|
)
|
||||||
|
update["path"] = path
|
||||||
|
current_path = path
|
||||||
|
update["groupVisibility"] = list(
|
||||||
|
map(lambda g: ObjectId(g), update.get("groupVisibility", []))
|
||||||
|
)
|
||||||
|
allowed_keys = [
|
||||||
|
"name",
|
||||||
|
"description",
|
||||||
|
"path",
|
||||||
|
"visibility",
|
||||||
|
"openSource",
|
||||||
|
"groupVisibility",
|
||||||
|
]
|
||||||
|
updater = util.build_updater(update, allowed_keys)
|
||||||
|
if updater:
|
||||||
|
db.projects.update_one({"_id": project["_id"]}, updater)
|
||||||
|
return get(user, username, current_path)
|
||||||
|
|
||||||
|
|
||||||
|
def delete(user, username, project_path):
|
||||||
|
db = database.get_db()
|
||||||
|
project = get_by_username(username, project_path)
|
||||||
|
if not util.can_edit_project(user, project):
|
||||||
|
raise util.errors.Forbidden("Forbidden")
|
||||||
|
db.projects.delete_one({"_id": project["_id"]})
|
||||||
|
db.objects.delete_many({"project": project["_id"]})
|
||||||
|
return {"deletedProject": project["_id"]}
|
||||||
|
|
||||||
|
|
||||||
|
def get_objects(user, username, path):
|
||||||
|
db = database.get_db()
|
||||||
|
project = get_by_username(username, path)
|
||||||
|
if not project:
|
||||||
|
raise util.errors.NotFound("Project not found")
|
||||||
|
if not util.can_view_project(user, project):
|
||||||
|
raise util.errors.Forbidden("This project is private")
|
||||||
|
|
||||||
|
query = {"project": project["_id"]}
|
||||||
|
if not util.can_edit_project(user, project):
|
||||||
|
query["moderationRequired"] = {"$ne": True}
|
||||||
|
objs = list(
|
||||||
|
db.objects.find(
|
||||||
|
query,
|
||||||
|
{
|
||||||
|
"createdAt": 1,
|
||||||
|
"name": 1,
|
||||||
|
"description": 1,
|
||||||
|
"project": 1,
|
||||||
|
"preview": 1,
|
||||||
|
"fullPreview": 1,
|
||||||
|
"type": 1,
|
||||||
|
"storedName": 1,
|
||||||
|
"isImage": 1,
|
||||||
|
"imageBlurHash": 1,
|
||||||
|
"commentCount": 1,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
)
|
||||||
|
for obj in objs:
|
||||||
|
if obj["type"] == "file" and "storedName" in obj:
|
||||||
|
obj["url"] = uploads.get_presigned_url(
|
||||||
|
"projects/{0}/{1}".format(project["_id"], obj["storedName"])
|
||||||
|
)
|
||||||
|
if obj["type"] == "pattern" and "preview" in obj and ".png" in obj["preview"]:
|
||||||
|
obj["previewUrl"] = uploads.get_presigned_url(
|
||||||
|
"projects/{0}/{1}".format(project["_id"], obj["preview"])
|
||||||
|
)
|
||||||
|
del obj["preview"]
|
||||||
|
if obj.get("fullPreview"):
|
||||||
|
obj["fullPreviewUrl"] = uploads.get_presigned_url(
|
||||||
|
"projects/{0}/{1}".format(project["_id"], obj["fullPreview"])
|
||||||
|
)
|
||||||
|
return objs
|
||||||
|
|
||||||
|
|
||||||
|
def create_object(user, username, path, data):
|
||||||
|
if not data and not data.get("type"):
|
||||||
|
raise util.errors.BadRequest("Invalid request")
|
||||||
|
if not data.get("type"):
|
||||||
|
raise util.errors.BadRequest("Object type is required.")
|
||||||
|
db = database.get_db()
|
||||||
|
project = get_by_username(username, path)
|
||||||
|
if not util.can_edit_project(user, project):
|
||||||
|
raise util.errors.Forbidden("Forbidden")
|
||||||
|
|
||||||
|
if data["type"] == "file":
|
||||||
|
if "storedName" not in data:
|
||||||
|
raise util.errors.BadRequest("File stored name must be included")
|
||||||
|
obj = {
|
||||||
|
"project": project["_id"],
|
||||||
|
"name": data.get("name", "Untitled file"),
|
||||||
|
"storedName": data["storedName"],
|
||||||
|
"createdAt": datetime.datetime.now(),
|
||||||
|
"type": "file",
|
||||||
|
"moderationRequired": True,
|
||||||
|
}
|
||||||
|
if re.search(r"(.jpg)|(.png)|(.jpeg)|(.gif)$", data["storedName"].lower()):
|
||||||
|
obj["isImage"] = True
|
||||||
|
result = db.objects.insert_one(obj)
|
||||||
|
obj["_id"] = result.inserted_id
|
||||||
|
obj["url"] = uploads.get_presigned_url(
|
||||||
|
"projects/{0}/{1}".format(project["_id"], obj["storedName"])
|
||||||
|
)
|
||||||
|
if obj.get("isImage"):
|
||||||
|
|
||||||
|
def handle_cb(h):
|
||||||
|
db.objects.update_one(
|
||||||
|
{"_id": obj["_id"]}, {"$set": {"imageBlurHash": h}}
|
||||||
|
)
|
||||||
|
|
||||||
|
uploads.blur_image(
|
||||||
|
"projects/" + str(project["_id"]) + "/" + data["storedName"], handle_cb
|
||||||
|
)
|
||||||
|
util.send_moderation_request(user, "object", obj)
|
||||||
|
return obj
|
||||||
|
if data["type"] == "pattern":
|
||||||
|
obj = {
|
||||||
|
"project": project["_id"],
|
||||||
|
"createdAt": datetime.datetime.now(),
|
||||||
|
"type": "pattern",
|
||||||
|
}
|
||||||
|
if data.get("wif"):
|
||||||
|
try:
|
||||||
|
pattern = wif.loads(data["wif"])
|
||||||
|
if pattern:
|
||||||
|
obj["name"] = pattern["name"]
|
||||||
|
obj["pattern"] = pattern
|
||||||
|
except Exception as e:
|
||||||
|
mail.send(
|
||||||
|
{
|
||||||
|
"to": os.environ.get("ADMIN_EMAIL"),
|
||||||
|
"subject": "Error loading WIF file",
|
||||||
|
"text": "A WIF file failed to parse with error: {}. The contents are below:\n\n{}".format(
|
||||||
|
e, data["wif"]
|
||||||
|
),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
raise util.errors.BadRequest(
|
||||||
|
"Unable to load WIF file. It is either invalid or in a format we cannot understand."
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
pattern = default_pattern.copy()
|
||||||
|
pattern["warp"].update({"shafts": data.get("shafts", 8)})
|
||||||
|
pattern["weft"].update({"treadles": data.get("treadles", 8)})
|
||||||
|
obj["name"] = data.get("name") or "Untitled Pattern"
|
||||||
|
obj["pattern"] = pattern
|
||||||
|
result = db.objects.insert_one(obj)
|
||||||
|
obj["_id"] = result.inserted_id
|
||||||
|
images = wif.generate_images(obj)
|
||||||
|
if images:
|
||||||
|
db.objects.update_one({"_id": obj["_id"]}, {"$set": images})
|
||||||
|
|
||||||
|
return objects.get(user, obj["_id"])
|
||||||
|
raise util.errors.BadRequest("Unable to create object")
|
135
api/api/root.py
Normal file
@ -0,0 +1,135 @@
|
|||||||
|
import datetime
|
||||||
|
from bson.objectid import ObjectId
|
||||||
|
from util import database, util
|
||||||
|
from api import uploads, objects, groups
|
||||||
|
|
||||||
|
|
||||||
|
def get_users(user):
|
||||||
|
db = database.get_db()
|
||||||
|
if not util.is_root(user):
|
||||||
|
raise util.errors.Forbidden("Not allowed")
|
||||||
|
users = list(
|
||||||
|
db.users.find(
|
||||||
|
{},
|
||||||
|
{
|
||||||
|
"username": 1,
|
||||||
|
"avatar": 1,
|
||||||
|
"email": 1,
|
||||||
|
"createdAt": 1,
|
||||||
|
"lastSeenAt": 1,
|
||||||
|
"roles": 1,
|
||||||
|
"groups": 1,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.sort("lastSeenAt", -1)
|
||||||
|
.limit(200)
|
||||||
|
)
|
||||||
|
group_ids = []
|
||||||
|
for u in users:
|
||||||
|
group_ids += u.get("groups", [])
|
||||||
|
groups = list(db.groups.find({"_id": {"$in": group_ids}}, {"name": 1}))
|
||||||
|
projects = list(db.projects.find({}, {"name": 1, "path": 1, "user": 1}))
|
||||||
|
for u in users:
|
||||||
|
if "avatar" in u:
|
||||||
|
u["avatarUrl"] = uploads.get_presigned_url(
|
||||||
|
"users/{0}/{1}".format(str(u["_id"]), u["avatar"])
|
||||||
|
)
|
||||||
|
u["projects"] = []
|
||||||
|
for p in projects:
|
||||||
|
if p["user"] == u["_id"]:
|
||||||
|
u["projects"].append(p)
|
||||||
|
u["groupMemberships"] = []
|
||||||
|
if u.get("groups"):
|
||||||
|
for g in groups:
|
||||||
|
if g["_id"] in u.get("groups", []):
|
||||||
|
u["groupMemberships"].append(g)
|
||||||
|
return {"users": users}
|
||||||
|
|
||||||
|
|
||||||
|
def get_groups(user):
|
||||||
|
db = database.get_db()
|
||||||
|
if not util.is_root(user):
|
||||||
|
raise util.errors.Forbidden("Not allowed")
|
||||||
|
groups = list(db.groups.find({}))
|
||||||
|
for group in groups:
|
||||||
|
group["memberCount"] = db.users.count_documents({"groups": group["_id"]})
|
||||||
|
return {"groups": groups}
|
||||||
|
|
||||||
|
|
||||||
|
def get_moderation(user):
|
||||||
|
db = database.get_db()
|
||||||
|
if not util.is_root(user):
|
||||||
|
raise util.errors.Forbidden("Not allowed")
|
||||||
|
object_list = list(db.objects.find({"moderationRequired": True}))
|
||||||
|
for obj in object_list:
|
||||||
|
if obj["type"] == "file" and "storedName" in obj:
|
||||||
|
obj["url"] = uploads.get_presigned_url(
|
||||||
|
"projects/{0}/{1}".format(obj["project"], obj["storedName"])
|
||||||
|
)
|
||||||
|
comment_list = list(db.comments.find({"moderationRequired": True}))
|
||||||
|
user_list = list(db.users.find({"moderationRequired": True}, {"username": 1}))
|
||||||
|
group_list = list(db.groups.find({"moderationRequired": True}, {"name": 1}))
|
||||||
|
group_entry_list = list(db.groupEntries.find({"moderationRequired": True}))
|
||||||
|
for entry in group_entry_list:
|
||||||
|
for a in entry.get("attachments", []):
|
||||||
|
if a["type"] == "file" and "storedName" in a:
|
||||||
|
a["url"] = uploads.get_presigned_url(
|
||||||
|
"groups/{0}/{1}".format(entry["group"], a["storedName"])
|
||||||
|
)
|
||||||
|
group_topic_reply_list = list(
|
||||||
|
db.groupForumTopicReplies.find({"moderationRequired": True})
|
||||||
|
)
|
||||||
|
for reply in group_topic_reply_list:
|
||||||
|
for a in reply.get("attachments", []):
|
||||||
|
if a["type"] == "file" and "storedName" in a:
|
||||||
|
a["url"] = uploads.get_presigned_url(
|
||||||
|
"groups/{0}/topics/{1}/{2}".format(
|
||||||
|
reply["group"], reply["topic"], a["storedName"]
|
||||||
|
)
|
||||||
|
)
|
||||||
|
return {
|
||||||
|
"objects": object_list,
|
||||||
|
"comments": comment_list,
|
||||||
|
"users": user_list,
|
||||||
|
"groups": group_list,
|
||||||
|
"groupEntries": group_entry_list,
|
||||||
|
"groupForumTopicReplies": group_topic_reply_list,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def moderate(user, item_type, item_id, allowed):
|
||||||
|
db = database.get_db()
|
||||||
|
if not util.is_root(user):
|
||||||
|
raise util.errors.Forbidden("Not allowed")
|
||||||
|
if item_type not in [
|
||||||
|
"objects",
|
||||||
|
"comments",
|
||||||
|
"users",
|
||||||
|
"groups",
|
||||||
|
"groupEntries",
|
||||||
|
"groupForumTopicReplies",
|
||||||
|
]:
|
||||||
|
raise util.errors.BadRequest("Invalid item type")
|
||||||
|
item_id = ObjectId(item_id)
|
||||||
|
item = db[item_type].find_one({"_id": item_id})
|
||||||
|
# For now, handle only allowed moderations.
|
||||||
|
# Disallowed will be manually managed.
|
||||||
|
if item and allowed:
|
||||||
|
db[item_type].update_one(
|
||||||
|
{"_id": item_id},
|
||||||
|
{
|
||||||
|
"$set": {
|
||||||
|
"moderationRequired": False,
|
||||||
|
"moderated": True,
|
||||||
|
"moderatedAt": datetime.datetime.now(),
|
||||||
|
"moderatedBy": user["_id"],
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
|
if item_type == "comments":
|
||||||
|
objects.send_comment_notification(item_id)
|
||||||
|
if item_type == "groupEntries":
|
||||||
|
groups.send_entry_notification(item_id)
|
||||||
|
if item_type == "groupForumTopicReplies":
|
||||||
|
groups.send_forum_topic_reply_notification(item_id)
|
||||||
|
return {"success": True}
|
253
api/api/search.py
Normal file
@ -0,0 +1,253 @@
|
|||||||
|
import re
|
||||||
|
import random
|
||||||
|
import pymongo
|
||||||
|
from util import database, util
|
||||||
|
from api import uploads
|
||||||
|
|
||||||
|
|
||||||
|
def all(user, params):
|
||||||
|
if not params or "query" not in params:
|
||||||
|
raise util.errors.BadRequest("Query parameter needed")
|
||||||
|
expression = re.compile(params["query"], re.IGNORECASE)
|
||||||
|
db = database.get_db()
|
||||||
|
|
||||||
|
users = list(
|
||||||
|
db.users.find(
|
||||||
|
{"username": expression},
|
||||||
|
{"username": 1, "avatar": 1, "isSilverSupporter": 1, "isGoldSupporter": 1},
|
||||||
|
)
|
||||||
|
.limit(10)
|
||||||
|
.sort("username", pymongo.ASCENDING)
|
||||||
|
)
|
||||||
|
for u in users:
|
||||||
|
if "avatar" in u:
|
||||||
|
u["avatarUrl"] = uploads.get_presigned_url(
|
||||||
|
"users/{0}/{1}".format(u["_id"], u["avatar"])
|
||||||
|
)
|
||||||
|
|
||||||
|
my_projects = list(db.projects.find({"user": user["_id"]}, {"name": 1, "path": 1}))
|
||||||
|
objects = list(
|
||||||
|
db.objects.find(
|
||||||
|
{
|
||||||
|
"project": {"$in": list(map(lambda p: p["_id"], my_projects))},
|
||||||
|
"name": expression,
|
||||||
|
},
|
||||||
|
{"name": 1, "type": 1, "isImage": 1, "project": 1},
|
||||||
|
)
|
||||||
|
)
|
||||||
|
for o in objects:
|
||||||
|
proj = next(p for p in my_projects if p["_id"] == o["project"])
|
||||||
|
if proj:
|
||||||
|
o["path"] = user["username"] + "/" + proj["path"] + "/" + str(o["_id"])
|
||||||
|
|
||||||
|
projects = list(
|
||||||
|
db.projects.find(
|
||||||
|
{
|
||||||
|
"name": expression,
|
||||||
|
"$or": [
|
||||||
|
{"user": user["_id"]},
|
||||||
|
{"groupVisibility": {"$in": user.get("groups", [])}},
|
||||||
|
{"visibility": "public"},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{"name": 1, "path": 1, "user": 1},
|
||||||
|
).limit(10)
|
||||||
|
)
|
||||||
|
proj_users = list(
|
||||||
|
db.users.find(
|
||||||
|
{"_id": {"$in": list(map(lambda p: p["user"], projects))}},
|
||||||
|
{"username": 1, "avatar": 1},
|
||||||
|
)
|
||||||
|
)
|
||||||
|
for proj in projects:
|
||||||
|
for proj_user in proj_users:
|
||||||
|
if proj["user"] == proj_user["_id"]:
|
||||||
|
proj["owner"] = proj_user
|
||||||
|
proj["fullName"] = proj_user["username"] + "/" + proj["path"]
|
||||||
|
if "avatar" in proj_user:
|
||||||
|
proj["owner"]["avatarUrl"] = uploads.get_presigned_url(
|
||||||
|
"users/{0}/{1}".format(proj_user["_id"], proj_user["avatar"])
|
||||||
|
)
|
||||||
|
|
||||||
|
groups = list(
|
||||||
|
db.groups.find(
|
||||||
|
{"name": expression, "unlisted": {"$ne": True}}, {"name": 1, "closed": 1}
|
||||||
|
).limit(5)
|
||||||
|
)
|
||||||
|
|
||||||
|
return {"users": users, "projects": projects, "groups": groups, "objects": objects}
|
||||||
|
|
||||||
|
|
||||||
|
def users(user, params):
|
||||||
|
if not user:
|
||||||
|
raise util.errors.Forbidden("You need to be logged in")
|
||||||
|
if not params or "username" not in params:
|
||||||
|
raise util.errors.BadRequest("Username parameter needed")
|
||||||
|
expression = re.compile(params["username"], re.IGNORECASE)
|
||||||
|
db = database.get_db()
|
||||||
|
users = list(
|
||||||
|
db.users.find(
|
||||||
|
{"username": expression},
|
||||||
|
{"username": 1, "avatar": 1, "isSilverSupporter": 1, "isGoldSupporter": 1},
|
||||||
|
)
|
||||||
|
.limit(5)
|
||||||
|
.sort("username", pymongo.ASCENDING)
|
||||||
|
)
|
||||||
|
for u in users:
|
||||||
|
if "avatar" in u:
|
||||||
|
u["avatarUrl"] = uploads.get_presigned_url(
|
||||||
|
"users/{0}/{1}".format(u["_id"], u["avatar"])
|
||||||
|
)
|
||||||
|
return {"users": users}
|
||||||
|
|
||||||
|
|
||||||
|
def discover(user, count=3):
|
||||||
|
db = database.get_db()
|
||||||
|
projects = []
|
||||||
|
users = []
|
||||||
|
groups = []
|
||||||
|
|
||||||
|
all_projects_query = {
|
||||||
|
"name": {"$not": re.compile("my new project", re.IGNORECASE)},
|
||||||
|
"visibility": "public",
|
||||||
|
}
|
||||||
|
if user and user.get("_id"):
|
||||||
|
all_projects_query["user"] = {"$ne": user["_id"]}
|
||||||
|
all_projects = list(
|
||||||
|
db.projects.find(all_projects_query, {"name": 1, "path": 1, "user": 1})
|
||||||
|
)
|
||||||
|
random.shuffle(all_projects)
|
||||||
|
for p in all_projects:
|
||||||
|
if db.objects.find_one(
|
||||||
|
{"project": p["_id"], "name": {"$ne": "Untitled pattern"}}
|
||||||
|
):
|
||||||
|
owner = db.users.find_one({"_id": p["user"]}, {"username": 1, "avatar": 1})
|
||||||
|
p["fullName"] = owner["username"] + "/" + p["path"]
|
||||||
|
p["owner"] = owner
|
||||||
|
if "avatar" in p["owner"]:
|
||||||
|
p["owner"]["avatarUrl"] = uploads.get_presigned_url(
|
||||||
|
"users/{0}/{1}".format(p["owner"]["_id"], p["owner"]["avatar"])
|
||||||
|
)
|
||||||
|
projects.append(p)
|
||||||
|
if len(projects) >= count:
|
||||||
|
break
|
||||||
|
|
||||||
|
interest_fields = [
|
||||||
|
"bio",
|
||||||
|
"avatar",
|
||||||
|
"website",
|
||||||
|
"facebook",
|
||||||
|
"twitter",
|
||||||
|
"instagram",
|
||||||
|
"location",
|
||||||
|
]
|
||||||
|
all_users_query = {
|
||||||
|
"$or": list(map(lambda f: {f: {"$exists": True}}, interest_fields))
|
||||||
|
}
|
||||||
|
if user and user.get("_id"):
|
||||||
|
all_users_query["_id"] = {"$ne": user["_id"]}
|
||||||
|
all_users = list(
|
||||||
|
db.users.find(
|
||||||
|
all_users_query,
|
||||||
|
{"username": 1, "avatar": 1, "isSilverSupporter": 1, "isGoldSupporter": 1},
|
||||||
|
)
|
||||||
|
)
|
||||||
|
random.shuffle(all_users)
|
||||||
|
for u in all_users:
|
||||||
|
if "avatar" in u:
|
||||||
|
u["avatarUrl"] = uploads.get_presigned_url(
|
||||||
|
"users/{0}/{1}".format(u["_id"], u["avatar"])
|
||||||
|
)
|
||||||
|
if user:
|
||||||
|
u["following"] = u["_id"] in list(
|
||||||
|
map(lambda f: f["user"], user.get("following", []))
|
||||||
|
)
|
||||||
|
users.append(u)
|
||||||
|
if len(users) >= count:
|
||||||
|
break
|
||||||
|
|
||||||
|
all_groups = list(
|
||||||
|
db.groups.find(
|
||||||
|
{"advertised": True, "name": {"$ne": "My group"}}, {"name": 1, "image": 1}
|
||||||
|
)
|
||||||
|
)
|
||||||
|
random.shuffle(all_groups)
|
||||||
|
for g in all_groups:
|
||||||
|
if "image" in g:
|
||||||
|
g["imageUrl"] = uploads.get_presigned_url(
|
||||||
|
"groups/{0}/{1}".format(g["_id"], g["image"])
|
||||||
|
)
|
||||||
|
groups.append(g)
|
||||||
|
if len(groups) >= count:
|
||||||
|
break
|
||||||
|
|
||||||
|
return {
|
||||||
|
"highlightProjects": projects,
|
||||||
|
"highlightUsers": users,
|
||||||
|
"highlightGroups": groups,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def explore(page=1):
|
||||||
|
db = database.get_db()
|
||||||
|
per_page = 10
|
||||||
|
|
||||||
|
project_map = {}
|
||||||
|
user_map = {}
|
||||||
|
all_public_projects = list(
|
||||||
|
db.projects.find(
|
||||||
|
{
|
||||||
|
"name": {"$not": re.compile("my new project", re.IGNORECASE)},
|
||||||
|
"visibility": "public",
|
||||||
|
},
|
||||||
|
{"name": 1, "path": 1, "user": 1},
|
||||||
|
)
|
||||||
|
)
|
||||||
|
all_public_project_ids = list(map(lambda p: p["_id"], all_public_projects))
|
||||||
|
for project in all_public_projects:
|
||||||
|
project_map[project["_id"]] = project
|
||||||
|
objects = list(
|
||||||
|
db.objects.find(
|
||||||
|
{
|
||||||
|
"project": {"$in": all_public_project_ids},
|
||||||
|
"name": {"$not": re.compile("untitled pattern", re.IGNORECASE)},
|
||||||
|
"preview": {"$exists": True},
|
||||||
|
},
|
||||||
|
{"project": 1, "name": 1, "createdAt": 1, "type": 1, "preview": 1},
|
||||||
|
)
|
||||||
|
.sort("createdAt", pymongo.DESCENDING)
|
||||||
|
.skip((page - 1) * per_page)
|
||||||
|
.limit(per_page)
|
||||||
|
)
|
||||||
|
for object in objects:
|
||||||
|
object["projectObject"] = project_map.get(object["project"])
|
||||||
|
if "preview" in object and ".png" in object["preview"]:
|
||||||
|
object["previewUrl"] = uploads.get_presigned_url(
|
||||||
|
"projects/{0}/{1}".format(object["project"], object["preview"])
|
||||||
|
)
|
||||||
|
del object["preview"]
|
||||||
|
authors = list(
|
||||||
|
db.users.find(
|
||||||
|
{
|
||||||
|
"_id": {
|
||||||
|
"$in": list(
|
||||||
|
map(lambda o: o.get("projectObject", {}).get("user"), objects)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{"username": 1, "avatar": 1, "isSilverSupporter": 1, "isGoldSupporter": 1},
|
||||||
|
)
|
||||||
|
)
|
||||||
|
for a in authors:
|
||||||
|
if "avatar" in a:
|
||||||
|
a["avatarUrl"] = uploads.get_presigned_url(
|
||||||
|
"users/{0}/{1}".format(a["_id"], a["avatar"])
|
||||||
|
)
|
||||||
|
user_map[a["_id"]] = a
|
||||||
|
for object in objects:
|
||||||
|
object["userObject"] = user_map.get(object.get("projectObject", {}).get("user"))
|
||||||
|
object["projectObject"]["owner"] = user_map.get(
|
||||||
|
object.get("projectObject", {}).get("user")
|
||||||
|
)
|
||||||
|
|
||||||
|
return {"objects": objects}
|
41
api/api/snippets.py
Normal file
@ -0,0 +1,41 @@
|
|||||||
|
import datetime
|
||||||
|
from bson.objectid import ObjectId
|
||||||
|
from util import database, util
|
||||||
|
|
||||||
|
|
||||||
|
def list_for_user(user):
|
||||||
|
db = database.get_db()
|
||||||
|
snippets = db.snippets.find({"user": user["_id"]}).sort("createdAt", -1)
|
||||||
|
return {"snippets": list(snippets)}
|
||||||
|
|
||||||
|
|
||||||
|
def create(user, data):
|
||||||
|
if not data:
|
||||||
|
raise util.errors.BadRequest("Invalid request")
|
||||||
|
name = data.get("name", "")
|
||||||
|
snippet_type = data.get("type", "")
|
||||||
|
if len(name) < 3:
|
||||||
|
raise util.errors.BadRequest("A longer name is required")
|
||||||
|
if snippet_type not in ["warp", "weft"]:
|
||||||
|
raise util.errors.BadRequest("Invalid snippet type")
|
||||||
|
db = database.get_db()
|
||||||
|
snippet = {
|
||||||
|
"name": name,
|
||||||
|
"user": user["_id"],
|
||||||
|
"createdAt": datetime.datetime.utcnow(),
|
||||||
|
"type": snippet_type,
|
||||||
|
"threading": data.get("threading", []),
|
||||||
|
"treadling": data.get("treadling", []),
|
||||||
|
}
|
||||||
|
result = db.snippets.insert_one(snippet)
|
||||||
|
snippet["_id"] = result.inserted_id
|
||||||
|
return snippet
|
||||||
|
|
||||||
|
|
||||||
|
def delete(user, id):
|
||||||
|
db = database.get_db()
|
||||||
|
snippet = db.snippets.find_one({"_id": ObjectId(id), "user": user["_id"]})
|
||||||
|
if not snippet:
|
||||||
|
raise util.errors.NotFound("Snippet not found")
|
||||||
|
db.snippets.delete_one({"_id": snippet["_id"]})
|
||||||
|
return {"deletedSnippet": snippet["_id"]}
|
108
api/api/uploads.py
Normal file
@ -0,0 +1,108 @@
|
|||||||
|
import os
|
||||||
|
import time
|
||||||
|
import re
|
||||||
|
from threading import Thread
|
||||||
|
from bson.objectid import ObjectId
|
||||||
|
import boto3
|
||||||
|
import blurhash
|
||||||
|
from util import database, util
|
||||||
|
from api.groups import has_group_permission
|
||||||
|
|
||||||
|
|
||||||
|
def sanitise_filename(s):
|
||||||
|
bad_chars = re.compile("[^a-zA-Z0-9_.]")
|
||||||
|
s = bad_chars.sub("_", s)
|
||||||
|
return s
|
||||||
|
|
||||||
|
|
||||||
|
def get_s3():
|
||||||
|
session = boto3.session.Session()
|
||||||
|
|
||||||
|
s3_client = session.client(
|
||||||
|
service_name="s3",
|
||||||
|
aws_access_key_id=os.environ["AWS_ACCESS_KEY_ID"],
|
||||||
|
aws_secret_access_key=os.environ["AWS_SECRET_ACCESS_KEY"],
|
||||||
|
endpoint_url=os.environ["AWS_S3_ENDPOINT"],
|
||||||
|
)
|
||||||
|
return s3_client
|
||||||
|
|
||||||
|
|
||||||
|
def get_presigned_url(path):
|
||||||
|
return os.environ["AWS_S3_ENDPOINT"] + os.environ["AWS_S3_BUCKET"] + "/" + path
|
||||||
|
s3 = get_s3()
|
||||||
|
return s3.generate_presigned_url(
|
||||||
|
"get_object", Params={"Bucket": os.environ["AWS_S3_BUCKET"], "Key": path}
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def upload_file(path, data):
|
||||||
|
s3 = get_s3()
|
||||||
|
s3.upload_fileobj(
|
||||||
|
data,
|
||||||
|
os.environ["AWS_S3_BUCKET"],
|
||||||
|
path,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def get_file(key):
|
||||||
|
s3 = get_s3()
|
||||||
|
return s3.get_object(Bucket=os.environ["AWS_S3_BUCKET"], Key=key)
|
||||||
|
|
||||||
|
|
||||||
|
def generate_file_upload_request(
|
||||||
|
user, file_name, file_size, file_type, for_type, for_id
|
||||||
|
):
|
||||||
|
if int(file_size) > (1024 * 1024 * 30): # 30MB
|
||||||
|
raise util.errors.BadRequest("File size is too big")
|
||||||
|
db = database.get_db()
|
||||||
|
allowed = False
|
||||||
|
path = ""
|
||||||
|
if for_type == "project":
|
||||||
|
project = db.projects.find_one(ObjectId(for_id))
|
||||||
|
allowed = project and util.can_edit_project(user, project)
|
||||||
|
path = "projects/" + for_id + "/"
|
||||||
|
if for_type == "user":
|
||||||
|
allowed = for_id == str(user["_id"])
|
||||||
|
path = "users/" + for_id + "/"
|
||||||
|
if for_type == "group":
|
||||||
|
allowed = ObjectId(for_id) in user.get("groups", [])
|
||||||
|
path = "groups/" + for_id + "/"
|
||||||
|
if for_type == "groupForum":
|
||||||
|
topic = db.groupForumTopics.find_one(ObjectId(for_id))
|
||||||
|
if not topic:
|
||||||
|
raise util.errors.NotFound("Topic not found")
|
||||||
|
group = db.groups.find_one(topic["group"])
|
||||||
|
if not group:
|
||||||
|
raise util.errors.NotFound("Group not found")
|
||||||
|
allowed = has_group_permission(user, group, "postForumTopicReplies")
|
||||||
|
path = "groups/" + str(group["_id"]) + "/topics/" + for_id + "/"
|
||||||
|
if not allowed:
|
||||||
|
raise util.errors.Forbidden("You're not allowed to upload this file")
|
||||||
|
|
||||||
|
file_body, file_extension = os.path.splitext(file_name)
|
||||||
|
new_name = sanitise_filename(
|
||||||
|
"{0}_{1}{2}".format(
|
||||||
|
file_body or file_name, int(time.time()), file_extension or ""
|
||||||
|
)
|
||||||
|
)
|
||||||
|
s3 = get_s3()
|
||||||
|
signed_url = s3.generate_presigned_url(
|
||||||
|
"put_object",
|
||||||
|
Params={
|
||||||
|
"Bucket": os.environ["AWS_S3_BUCKET"],
|
||||||
|
"Key": path + new_name,
|
||||||
|
"ContentType": file_type,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
return {"signedRequest": signed_url, "fileName": new_name}
|
||||||
|
|
||||||
|
|
||||||
|
def handle_blur_image(key, func):
|
||||||
|
f = get_file(key)["Body"]
|
||||||
|
bhash = blurhash.encode(f, x_components=4, y_components=3)
|
||||||
|
func(bhash)
|
||||||
|
|
||||||
|
|
||||||
|
def blur_image(key, func):
|
||||||
|
thr = Thread(target=handle_blur_image, args=[key, func])
|
||||||
|
thr.start()
|
355
api/api/users.py
Normal file
@ -0,0 +1,355 @@
|
|||||||
|
import datetime
|
||||||
|
import re
|
||||||
|
from bson.objectid import ObjectId
|
||||||
|
from util import database, util
|
||||||
|
from api import uploads
|
||||||
|
|
||||||
|
|
||||||
|
def me(user):
|
||||||
|
db = database.get_db()
|
||||||
|
return {
|
||||||
|
"_id": user["_id"],
|
||||||
|
"username": user["username"],
|
||||||
|
"bio": user.get("bio"),
|
||||||
|
"email": user.get("email"),
|
||||||
|
"avatar": user.get("avatar"),
|
||||||
|
"avatarUrl": user.get("avatar")
|
||||||
|
and uploads.get_presigned_url(
|
||||||
|
"users/{0}/{1}".format(user["_id"], user["avatar"])
|
||||||
|
),
|
||||||
|
"roles": user.get("roles", []),
|
||||||
|
"groups": user.get("groups", []),
|
||||||
|
"subscriptions": user.get("subscriptions"),
|
||||||
|
"finishedTours": user.get("completedTours", []) + user.get("skippedTours", []),
|
||||||
|
"isSilverSupporter": user.get("isSilverSupporter"),
|
||||||
|
"isGoldSupporter": user.get("isGoldSupporter"),
|
||||||
|
"followerCount": db.users.count_documents({"following.user": user["_id"]}),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def get(user, username):
|
||||||
|
db = database.get_db()
|
||||||
|
fetch_user = db.users.find_one(
|
||||||
|
{"username": username},
|
||||||
|
{
|
||||||
|
"username": 1,
|
||||||
|
"createdAt": 1,
|
||||||
|
"avatar": 1,
|
||||||
|
"avatarBlurHash": 1,
|
||||||
|
"bio": 1,
|
||||||
|
"location": 1,
|
||||||
|
"website": 1,
|
||||||
|
"twitter": 1,
|
||||||
|
"facebook": 1,
|
||||||
|
"linkedIn": 1,
|
||||||
|
"instagram": 1,
|
||||||
|
"isSilverSupporter": 1,
|
||||||
|
"isGoldSupporter": 1,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
if not fetch_user:
|
||||||
|
raise util.errors.NotFound("User not found")
|
||||||
|
project_query = {"user": fetch_user["_id"]}
|
||||||
|
if not user or not user["_id"] == fetch_user["_id"]:
|
||||||
|
project_query["visibility"] = "public"
|
||||||
|
|
||||||
|
if "avatar" in fetch_user:
|
||||||
|
fetch_user["avatarUrl"] = uploads.get_presigned_url(
|
||||||
|
"users/{0}/{1}".format(str(fetch_user["_id"]), fetch_user["avatar"])
|
||||||
|
)
|
||||||
|
if user:
|
||||||
|
fetch_user["following"] = fetch_user["_id"] in list(
|
||||||
|
map(lambda f: f["user"], user.get("following", []))
|
||||||
|
)
|
||||||
|
|
||||||
|
user_projects = list(
|
||||||
|
db.projects.find(
|
||||||
|
project_query, {"name": 1, "path": 1, "description": 1, "visibility": 1}
|
||||||
|
).limit(15)
|
||||||
|
)
|
||||||
|
for project in user_projects:
|
||||||
|
project["fullName"] = fetch_user["username"] + "/" + project["path"]
|
||||||
|
project["owner"] = {
|
||||||
|
"_id": fetch_user["_id"],
|
||||||
|
"username": fetch_user["username"],
|
||||||
|
"avatar": fetch_user.get("avatar"),
|
||||||
|
"avatarUrl": fetch_user.get("avatarUrl"),
|
||||||
|
}
|
||||||
|
fetch_user["projects"] = user_projects
|
||||||
|
|
||||||
|
return fetch_user
|
||||||
|
|
||||||
|
|
||||||
|
def update(user, username, data):
|
||||||
|
if not data:
|
||||||
|
raise util.errors.BadRequest("Invalid request")
|
||||||
|
db = database.get_db()
|
||||||
|
if user["username"] != username:
|
||||||
|
raise util.errors.Forbidden("Not allowed")
|
||||||
|
allowed_keys = [
|
||||||
|
"username",
|
||||||
|
"avatar",
|
||||||
|
"bio",
|
||||||
|
"location",
|
||||||
|
"website",
|
||||||
|
"twitter",
|
||||||
|
"facebook",
|
||||||
|
"linkedIn",
|
||||||
|
"instagram",
|
||||||
|
]
|
||||||
|
if "username" in data:
|
||||||
|
if not data.get("username") or len(data["username"]) < 3:
|
||||||
|
raise util.errors.BadRequest("New username is not valid")
|
||||||
|
if not re.match("^[a-z0-9_]+$", data["username"]):
|
||||||
|
raise util.errors.BadRequest(
|
||||||
|
"Usernames can only contain letters, numbers, and underscores"
|
||||||
|
)
|
||||||
|
if db.users.count_documents({"username": data["username"].lower()}):
|
||||||
|
raise util.errors.BadRequest("A user with this username already exists")
|
||||||
|
data["username"] = data["username"].lower()
|
||||||
|
if data.get("avatar") and len(data["avatar"]) > 3: # Not a default avatar
|
||||||
|
|
||||||
|
def handle_cb(h):
|
||||||
|
db.users.update_one({"_id": user["_id"]}, {"$set": {"avatarBlurHash": h}})
|
||||||
|
|
||||||
|
uploads.blur_image(
|
||||||
|
"users/" + str(user["_id"]) + "/" + data["avatar"], handle_cb
|
||||||
|
)
|
||||||
|
updater = util.build_updater(data, allowed_keys)
|
||||||
|
if updater:
|
||||||
|
if "avatar" in updater.get(
|
||||||
|
"$unset", {}
|
||||||
|
): # Also unset blurhash if removing avatar
|
||||||
|
updater["$unset"]["avatarBlurHash"] = ""
|
||||||
|
if "$set" in updater and (
|
||||||
|
"avatar" in data or "bio" in data or "website" in data or "username" in data
|
||||||
|
):
|
||||||
|
updater["$set"]["moderationRequired"] = True
|
||||||
|
util.send_moderation_request(user, "users", user)
|
||||||
|
db.users.update_one({"username": username}, updater)
|
||||||
|
return get(user, data.get("username", username))
|
||||||
|
|
||||||
|
|
||||||
|
def finish_tour(user, username, tour, status):
|
||||||
|
db = database.get_db()
|
||||||
|
if user["username"] != username:
|
||||||
|
raise util.errors.Forbidden("Not allowed")
|
||||||
|
key = "completedTours" if status == "completed" else "skippedTours"
|
||||||
|
db.users.update_one({"_id": user["_id"]}, {"$addToSet": {key: tour}})
|
||||||
|
return {"finishedTour": tour}
|
||||||
|
|
||||||
|
|
||||||
|
def get_projects(user, id):
|
||||||
|
db = database.get_db()
|
||||||
|
u = db.users.find_one(id, {"username": 1, "avatar": 1})
|
||||||
|
if not u:
|
||||||
|
raise util.errors.NotFound("User not found")
|
||||||
|
if "avatar" in u:
|
||||||
|
u["avatarUrl"] = uploads.get_presigned_url(
|
||||||
|
"users/{0}/{1}".format(str(u["_id"]), u["avatar"])
|
||||||
|
)
|
||||||
|
projects = []
|
||||||
|
project_query = {"user": ObjectId(id)}
|
||||||
|
if not user or not user["_id"] == ObjectId(id):
|
||||||
|
project_query["visibility"] = "public"
|
||||||
|
for project in db.projects.find(project_query):
|
||||||
|
project["owner"] = u
|
||||||
|
project["fullName"] = u["username"] + "/" + project["path"]
|
||||||
|
projects.append(project)
|
||||||
|
return projects
|
||||||
|
|
||||||
|
|
||||||
|
def create_email_subscription(user, username, subscription):
|
||||||
|
db = database.get_db()
|
||||||
|
if user["username"] != username:
|
||||||
|
raise util.errors.Forbidden("Forbidden")
|
||||||
|
u = db.users.find_one({"username": username})
|
||||||
|
db.users.update_one(
|
||||||
|
{"_id": u["_id"]}, {"$addToSet": {"subscriptions.email": subscription}}
|
||||||
|
)
|
||||||
|
subs = db.users.find_one(u["_id"], {"subscriptions": 1})
|
||||||
|
return {"subscriptions": subs.get("subscriptions", {})}
|
||||||
|
|
||||||
|
|
||||||
|
def delete_email_subscription(user, username, subscription):
|
||||||
|
db = database.get_db()
|
||||||
|
if user["username"] != username:
|
||||||
|
raise util.errors.Forbidden("Forbidden")
|
||||||
|
u = db.users.find_one({"username": username})
|
||||||
|
db.users.update_one(
|
||||||
|
{"_id": u["_id"]}, {"$pull": {"subscriptions.email": subscription}}
|
||||||
|
)
|
||||||
|
subs = db.users.find_one(u["_id"], {"subscriptions": 1})
|
||||||
|
return {"subscriptions": subs.get("subscriptions", {})}
|
||||||
|
|
||||||
|
|
||||||
|
def create_follower(user, username):
|
||||||
|
db = database.get_db()
|
||||||
|
target_user = db.users.find_one({"username": username.lower()})
|
||||||
|
if not target_user:
|
||||||
|
raise util.errors.NotFound("User not found")
|
||||||
|
if target_user["_id"] == user["_id"]:
|
||||||
|
raise util.errors.BadRequest("Cannot follow yourself")
|
||||||
|
follow_object = {
|
||||||
|
"user": target_user["_id"],
|
||||||
|
"followedAt": datetime.datetime.utcnow(),
|
||||||
|
}
|
||||||
|
db.users.update_one(
|
||||||
|
{"_id": user["_id"]}, {"$addToSet": {"following": follow_object}}
|
||||||
|
)
|
||||||
|
return follow_object
|
||||||
|
|
||||||
|
|
||||||
|
def delete_follower(user, username):
|
||||||
|
db = database.get_db()
|
||||||
|
target_user = db.users.find_one({"username": username.lower()})
|
||||||
|
if not target_user:
|
||||||
|
raise util.errors.NotFound("User not found")
|
||||||
|
db.users.update_one(
|
||||||
|
{"_id": user["_id"]}, {"$pull": {"following": {"user": target_user["_id"]}}}
|
||||||
|
)
|
||||||
|
return {"unfollowed": True}
|
||||||
|
|
||||||
|
|
||||||
|
def get_feed(user, username):
|
||||||
|
db = database.get_db()
|
||||||
|
if user["username"] != username:
|
||||||
|
raise util.errors.Forbidden("Forbidden")
|
||||||
|
following_user_ids = list(map(lambda f: f["user"], user.get("following", [])))
|
||||||
|
following_project_ids = list(
|
||||||
|
map(
|
||||||
|
lambda p: p["_id"],
|
||||||
|
db.projects.find(
|
||||||
|
{"user": {"$in": following_user_ids}, "visibility": "public"},
|
||||||
|
{"_id": 1},
|
||||||
|
),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
one_year_ago = datetime.datetime.utcnow() - datetime.timedelta(days=365)
|
||||||
|
|
||||||
|
# Fetch the items for the feed
|
||||||
|
recent_projects = list(
|
||||||
|
db.projects.find(
|
||||||
|
{
|
||||||
|
"_id": {"$in": following_project_ids},
|
||||||
|
"createdAt": {"$gt": one_year_ago},
|
||||||
|
"visibility": "public",
|
||||||
|
},
|
||||||
|
{"user": 1, "createdAt": 1, "name": 1, "path": 1, "visibility": 1},
|
||||||
|
)
|
||||||
|
.sort("createdAt", -1)
|
||||||
|
.limit(20)
|
||||||
|
)
|
||||||
|
recent_objects = list(
|
||||||
|
db.objects.find(
|
||||||
|
{
|
||||||
|
"project": {"$in": following_project_ids},
|
||||||
|
"createdAt": {"$gt": one_year_ago},
|
||||||
|
},
|
||||||
|
{"project": 1, "createdAt": 1, "name": 1},
|
||||||
|
)
|
||||||
|
.sort("createdAt", -1)
|
||||||
|
.limit(30)
|
||||||
|
)
|
||||||
|
recent_comments = list(
|
||||||
|
db.comments.find(
|
||||||
|
{"user": {"$in": following_user_ids}, "createdAt": {"$gt": one_year_ago}},
|
||||||
|
{"user": 1, "createdAt": 1, "object": 1, "content": 1},
|
||||||
|
)
|
||||||
|
.sort("createdAt", -1)
|
||||||
|
.limit(30)
|
||||||
|
)
|
||||||
|
|
||||||
|
# Process objects (as don't know the user)
|
||||||
|
object_project_ids = list(map(lambda o: o["project"], recent_objects))
|
||||||
|
object_projects = list(
|
||||||
|
db.projects.find(
|
||||||
|
{"_id": {"$in": object_project_ids}, "visibility": "public"}, {"user": 1}
|
||||||
|
)
|
||||||
|
)
|
||||||
|
for obj in recent_objects:
|
||||||
|
for proj in object_projects:
|
||||||
|
if obj["project"] == proj["_id"]:
|
||||||
|
obj["user"] = proj.get("user")
|
||||||
|
|
||||||
|
# Process comments as don't know the project
|
||||||
|
comment_object_ids = list(map(lambda c: c["object"], recent_comments))
|
||||||
|
comment_objects = list(
|
||||||
|
db.objects.find({"_id": {"$in": comment_object_ids}}, {"project": 1})
|
||||||
|
)
|
||||||
|
for com in recent_comments:
|
||||||
|
for obj in comment_objects:
|
||||||
|
if com["object"] == obj["_id"]:
|
||||||
|
com["project"] = obj.get("project")
|
||||||
|
|
||||||
|
# Prepare the feed items, and sort it
|
||||||
|
feed_items = []
|
||||||
|
for p in recent_projects:
|
||||||
|
p["feedType"] = "project"
|
||||||
|
feed_items.append(p)
|
||||||
|
for o in recent_objects:
|
||||||
|
o["feedType"] = "object"
|
||||||
|
feed_items.append(o)
|
||||||
|
for c in recent_comments:
|
||||||
|
c["feedType"] = "comment"
|
||||||
|
feed_items.append(c)
|
||||||
|
feed_items.sort(key=lambda d: d["createdAt"], reverse=True)
|
||||||
|
feed_items = feed_items[:20]
|
||||||
|
|
||||||
|
# Post-process the feed, adding user/project objects
|
||||||
|
feed_user_ids = set()
|
||||||
|
feed_project_ids = set()
|
||||||
|
for f in feed_items:
|
||||||
|
feed_user_ids.add(f.get("user"))
|
||||||
|
feed_project_ids.add(f.get("project"))
|
||||||
|
feed_projects = list(
|
||||||
|
db.projects.find(
|
||||||
|
{"_id": {"$in": list(feed_project_ids)}, "visibility": "public"},
|
||||||
|
{"name": 1, "path": 1, "user": 1, "visibility": 1},
|
||||||
|
)
|
||||||
|
)
|
||||||
|
feed_users = list(
|
||||||
|
db.users.find(
|
||||||
|
{
|
||||||
|
"$or": [
|
||||||
|
{"_id": {"$in": list(feed_user_ids)}},
|
||||||
|
{"_id": {"$in": list(map(lambda p: p["user"], feed_projects))}},
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{"username": 1, "avatar": 1, "isSilverSupporter": 1, "isGoldSupporter": 1},
|
||||||
|
)
|
||||||
|
)
|
||||||
|
for u in feed_users:
|
||||||
|
if "avatar" in u:
|
||||||
|
u["avatarUrl"] = uploads.get_presigned_url(
|
||||||
|
"users/{0}/{1}".format(str(u["_id"]), u["avatar"])
|
||||||
|
)
|
||||||
|
feed_user_map = {}
|
||||||
|
feed_project_map = {}
|
||||||
|
for u in feed_users:
|
||||||
|
feed_user_map[str(u["_id"])] = u
|
||||||
|
for p in feed_projects:
|
||||||
|
feed_project_map[str(p["_id"])] = p
|
||||||
|
for f in feed_items:
|
||||||
|
if f.get("user") and feed_user_map.get(str(f["user"])):
|
||||||
|
f["userObject"] = feed_user_map.get(str(f["user"]))
|
||||||
|
if f.get("project") and feed_project_map.get(str(f["project"])):
|
||||||
|
f["projectObject"] = feed_project_map.get(str(f["project"]))
|
||||||
|
if f.get("projectObject", {}).get("user") and feed_user_map.get(
|
||||||
|
str(f["projectObject"]["user"])
|
||||||
|
):
|
||||||
|
f["projectObject"]["userObject"] = feed_user_map.get(
|
||||||
|
str(f["projectObject"]["user"])
|
||||||
|
)
|
||||||
|
|
||||||
|
# Filter out orphaned or non-public comments/objects
|
||||||
|
def filter_func(f):
|
||||||
|
if f["feedType"] == "comment" and not f.get("projectObject"):
|
||||||
|
return False
|
||||||
|
if f["feedType"] == "object" and not f.get("projectObject"):
|
||||||
|
return False
|
||||||
|
return True
|
||||||
|
|
||||||
|
feed_items = list(filter(filter_func, feed_items))
|
||||||
|
|
||||||
|
return {"feed": feed_items}
|
946
api/app.py
18
api/bucket-policy-dev.json
Normal file
@ -0,0 +1,18 @@
|
|||||||
|
{
|
||||||
|
"Statement": [
|
||||||
|
{
|
||||||
|
"Effect": "Allow",
|
||||||
|
"Principal": {
|
||||||
|
"AWS": [
|
||||||
|
"*"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"Action": [
|
||||||
|
"s3:GetObject"
|
||||||
|
],
|
||||||
|
"Resource": [
|
||||||
|
"arn:aws:s3:::treadl-dev/*"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
@ -11,7 +11,7 @@
|
|||||||
"s3:GetObject"
|
"s3:GetObject"
|
||||||
],
|
],
|
||||||
"Resource": [
|
"Resource": [
|
||||||
"arn:aws:s3::treadl-files/*"
|
"arn:aws:s3:::treadl/*"
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
|
@ -1,188 +0,0 @@
|
|||||||
import datetime, jwt, bcrypt, re, os
|
|
||||||
from bson.objectid import ObjectId
|
|
||||||
from chalicelib.util import database, mail, util
|
|
||||||
from chalicelib.api import uploads
|
|
||||||
|
|
||||||
jwt_secret = os.environ['JWT_SECRET']
|
|
||||||
|
|
||||||
def register(username, email, password):
|
|
||||||
if not username or len(username) < 4 or not email or len(email) < 6:
|
|
||||||
raise util.errors.BadRequest('Your username or email is too short or invalid.')
|
|
||||||
username = username.lower()
|
|
||||||
email = email.lower()
|
|
||||||
if not re.match("^[a-z0-9_]+$", username):
|
|
||||||
raise util.errors.BadRequest('Usernames can only contain letters, numbers, and underscores')
|
|
||||||
if not password or len(password) < 6:
|
|
||||||
raise util.errors.BadRequest('Your password should be longer.')
|
|
||||||
db = database.get_db()
|
|
||||||
existingUser = db.users.find_one({'$or': [{'username': username}, {'email': email}]})
|
|
||||||
if existingUser:
|
|
||||||
raise util.errors.BadRequest('An account with this username or email already exists.')
|
|
||||||
|
|
||||||
try:
|
|
||||||
hashed_password = bcrypt.hashpw(password.encode("utf-8"), bcrypt.gensalt())
|
|
||||||
result = db.users.insert_one({ 'username': username, 'email': email, 'password': hashed_password, 'createdAt': datetime.datetime.now(), 'subscriptions': {'email': ['groups.invited', 'groups.joinRequested', 'groups.joined', 'messages.replied', 'projects.commented']}})
|
|
||||||
mail.send({
|
|
||||||
'to': 'will@seastorm.co',
|
|
||||||
'subject': 'Treadl signup',
|
|
||||||
'text': 'A new user signed up with username {0} and email {1}'.format(username, email)
|
|
||||||
})
|
|
||||||
mail.send({
|
|
||||||
'to': email,
|
|
||||||
'subject': 'Welcome to Treadl!',
|
|
||||||
'text': '''Dear {},
|
|
||||||
|
|
||||||
Welcome to Treadl! We won't send you many emails but we just want to introduce ourselves and to give you some tips to help you get started.
|
|
||||||
|
|
||||||
LOGGING-IN
|
|
||||||
|
|
||||||
To login to your account please visit https://treadl.com and click Login. Use your username ({}) and password to get back into your account.
|
|
||||||
|
|
||||||
INTRODUCTION
|
|
||||||
|
|
||||||
Treadl has been designed as a resource for weavers – not only for those working alone as individuals, but also for groups who wish to share ideas, design inspirations and weaving patterns. It is ideal for those looking for a depository to store their individual work, and also for groups such as guilds, teaching groups, or any other collaborative working partnerships.
|
|
||||||
Projects can be created within Treadl using the integral WIF-compatible draft editor, or alternatively files can be imported from other design software along with supporting images and other information you may wish to be saved within the project file. Once complete, projects may be stored privately, shared within a closed group, or made public for other Treadl users to see. The choice is yours!
|
|
||||||
|
|
||||||
Treadl is free to use. For more information please visit our website at https://treadl.com.
|
|
||||||
|
|
||||||
GETTING STARTED
|
|
||||||
|
|
||||||
Creating a profile: You can add a picture, links to a personal website, and other social media accounts to tell others more about yourself.
|
|
||||||
|
|
||||||
Creating a group: You have the option to do things alone, or create a group. By clicking on the ‘Create a group’ button, you can name your group, and then invite members via email or directly through Treadl if they are existing Treadl users.
|
|
||||||
|
|
||||||
Creating a new project: When you are ready to create/store a project on the system, you are invited to give the project a name, and a brief description. You will then be taken to a ‘Welcome to your project’ screen, where if you click on ‘add something’, you have the option of creating a new weaving pattern directly inside Treadl or you can simply import a WIF file from your preferred weaving software. Once imported, you can perform further editing within Treadl, or you can add supporting picture files and any other additional information you wish to keep (eg weaving notes, yarn details etc).
|
|
||||||
|
|
||||||
Once complete you then have the option of saving the file privately, shared within a group, or made public for other Treadl users to see.
|
|
||||||
|
|
||||||
We hope you enjoy using Treadl and if you have any comments or feedback please tell us by emailing hello@treadl.com!
|
|
||||||
|
|
||||||
Best wishes,
|
|
||||||
|
|
||||||
The Treadl Team
|
|
||||||
'''.format(username, username)
|
|
||||||
})
|
|
||||||
return {'token': generate_access_token(result.inserted_id)}
|
|
||||||
except Exception as e:
|
|
||||||
print(e)
|
|
||||||
raise util.errors.BadRequest('Unable to register your account. Please try again later')
|
|
||||||
|
|
||||||
def login(email, password):
|
|
||||||
db = database.get_db()
|
|
||||||
user = db.users.find_one({'$or': [{'username': email.lower()}, {'email': email}]})
|
|
||||||
try:
|
|
||||||
if user and bcrypt.checkpw(password.encode("utf-8"), user['password']):
|
|
||||||
return {'token': generate_access_token(user['_id'])}
|
|
||||||
else:
|
|
||||||
raise util.errors.BadRequest('Your username or password is incorrect.')
|
|
||||||
except Exception as e:
|
|
||||||
raise util.errors.BadRequest('Your username or password is incorrect.')
|
|
||||||
|
|
||||||
def logout(user):
|
|
||||||
db = database.get_db()
|
|
||||||
db.users.update({'_id': user['_id']}, {'$pull': {'tokens.login': user['currentToken']}})
|
|
||||||
return {'loggedOut': True}
|
|
||||||
|
|
||||||
def update_email(user, data):
|
|
||||||
if not data: raise util.errors.BadRequest('Invalid request')
|
|
||||||
if 'email' not in data: raise util.errors.BadRequest('Invalid request')
|
|
||||||
if len(data['email']) < 4: raise util.errors.BadRequest('New email is too short')
|
|
||||||
db = database.get_db()
|
|
||||||
db.users.update_one({'_id': user['_id']}, {'$set': {'email': data['email']}})
|
|
||||||
mail.send({
|
|
||||||
'to': user['email'],
|
|
||||||
'subject': 'Your email address has changed on Treadl',
|
|
||||||
'text': 'Dear {},\n\nThis email is to let you know that we recently received a request to change your account email address on Treadl. We have now made this change.\n\nThe new email address for your account is {}.\n\nIf you think this is a mistake then please get in touch with us as soon as possible.'.format(user['username'], data['email'])
|
|
||||||
})
|
|
||||||
mail.send({
|
|
||||||
'to': data['email'],
|
|
||||||
'subject': 'Your email address has changed on Treadl',
|
|
||||||
'text': 'Dear {},\n\nThis email is to let you know that we recently received a request to change your account email address on Treadl. We have now made this change.\n\nThe new email address for your account is {}.\n\nIf you think this is a mistake then please get in touch with us as soon as possible.'.format(user['username'], data['email'])
|
|
||||||
})
|
|
||||||
return {'email': data['email']}
|
|
||||||
|
|
||||||
def update_password(user, data):
|
|
||||||
if not data: raise util.errors.BadRequest('Invalid request')
|
|
||||||
if 'newPassword' not in data: raise util.errors.BadRequest('Invalid request')
|
|
||||||
if len(data['newPassword']) < 6: raise util.errors.BadRequest('New password is too short')
|
|
||||||
|
|
||||||
db = database.get_db()
|
|
||||||
if 'currentPassword' in data:
|
|
||||||
if not bcrypt.checkpw(data['currentPassword'].encode('utf-8'), user['password']):
|
|
||||||
raise util.errors.BadRequest('Incorrect password')
|
|
||||||
elif 'token' in data:
|
|
||||||
try:
|
|
||||||
id = jwt.decode(data['token'], jwt_secret)['sub']
|
|
||||||
user = db.users.find_one({'_id': ObjectId(id), 'tokens.passwordReset': data['token']})
|
|
||||||
if not user: raise Exception
|
|
||||||
except Exception as e:
|
|
||||||
raise util.errors.BadRequest('There was a problem updating your password. Your token may be invalid or out of date')
|
|
||||||
else:
|
|
||||||
raise util.errors.BadRequest('Current password or reset token is required')
|
|
||||||
if not user: raise util.errors.BadRequest('Unable to change your password')
|
|
||||||
|
|
||||||
hashed_password = bcrypt.hashpw(data['newPassword'].encode("utf-8"), bcrypt.gensalt())
|
|
||||||
db.users.update({'_id': user['_id']}, {'$set': {'password': hashed_password}, '$unset': {'tokens.passwordReset': ''}})
|
|
||||||
return {'passwordUpdated': True}
|
|
||||||
|
|
||||||
def delete(user, password):
|
|
||||||
if not password or not bcrypt.checkpw(password.encode('utf-8'), user['password']):
|
|
||||||
raise util.errors.BadRequest('Incorrect password')
|
|
||||||
db = database.get_db()
|
|
||||||
for project in db.projects.find({'user': user['_id']}):
|
|
||||||
db.objects.remove({'project': project['_id']})
|
|
||||||
db.projects.remove({'_id': project['_id']})
|
|
||||||
db.users.remove({'_id': user['_id']})
|
|
||||||
return {'deletedUser': user['_id']}
|
|
||||||
|
|
||||||
def generate_access_token(user_id):
|
|
||||||
payload = {
|
|
||||||
'exp': datetime.datetime.utcnow() + datetime.timedelta(days=30),
|
|
||||||
'iat': datetime.datetime.utcnow(),
|
|
||||||
'sub': str(user_id)
|
|
||||||
}
|
|
||||||
token = jwt.encode(payload, jwt_secret, algorithm='HS256').decode("utf-8")
|
|
||||||
db = database.get_db()
|
|
||||||
db.users.update({'_id': user_id}, {'$addToSet': {'tokens.login': token}})
|
|
||||||
return token
|
|
||||||
|
|
||||||
def get_user_context(token):
|
|
||||||
if not token: return None
|
|
||||||
try:
|
|
||||||
payload = jwt.decode(token, jwt_secret)
|
|
||||||
id = payload['sub']
|
|
||||||
if id:
|
|
||||||
db = database.get_db()
|
|
||||||
user = db.users.find_one({'_id': ObjectId(id), 'tokens.login': token})
|
|
||||||
db.users.update({'_id': user['_id']}, {'$set': {'lastSeenAt': datetime.datetime.now()}})
|
|
||||||
user['currentToken'] = token
|
|
||||||
return user
|
|
||||||
except Exception as e:
|
|
||||||
print(e)
|
|
||||||
return None
|
|
||||||
|
|
||||||
def reset_password(data):
|
|
||||||
if not data or not 'email' in data: raise util.errors.BadRequest('Invalid request')
|
|
||||||
if len(data['email']) < 5: raise util.errors.BadRequest('Your email is too short')
|
|
||||||
db = database.get_db()
|
|
||||||
user = db.users.find_one({'email': data['email'].lower()})
|
|
||||||
if user:
|
|
||||||
payload = {
|
|
||||||
'exp': datetime.datetime.utcnow() + datetime.timedelta(days=1),
|
|
||||||
'iat': datetime.datetime.utcnow(),
|
|
||||||
'sub': str(user['_id'])
|
|
||||||
}
|
|
||||||
token = jwt.encode(payload, jwt_secret, algorithm='HS256').decode('utf-8')
|
|
||||||
mail.send({
|
|
||||||
'to_user': user,
|
|
||||||
'subject': 'Reset your password',
|
|
||||||
'text': 'Dear {0},\n\nA password reset email was recently requested for your Treadl account. If this was you and you want to continue, please follow the link below:\n\n{1}\n\nThis link will expire after 24 hours.\n\nIf this was not you, then someone may be trying to gain access to your account. We recommend using a strong and unique password for your account.'.format(user['username'], 'https://treadl.com/password/reset?token=' + token)
|
|
||||||
})
|
|
||||||
db.users.update({'_id': user['_id']}, {'$set': {'tokens.passwordReset': token}})
|
|
||||||
return {'passwordResetEmailSent': True}
|
|
||||||
|
|
||||||
def update_push_token(user, data):
|
|
||||||
if not data or 'pushToken' not in data: raise util.errors.BadRequest('Push token is required')
|
|
||||||
db = database.get_db()
|
|
||||||
db.users.update_one({'_id': user['_id']}, {'$set': {'pushToken': data['pushToken']}})
|
|
||||||
return {'addedPushToken': data['pushToken']}
|
|
@ -1,131 +0,0 @@
|
|||||||
from chalicelib.util import database, util
|
|
||||||
from chalicelib.api import uploads
|
|
||||||
|
|
||||||
def webfinger_user(username, servername):
|
|
||||||
db = database.get_db()
|
|
||||||
fetch_user = db.users.find_one({'username': username}, {'username': 1, 'createdAt': 1})
|
|
||||||
if not fetch_user:
|
|
||||||
raise util.errors.NotFound('User not found')
|
|
||||||
return {
|
|
||||||
'subject': f"acct:{fetch_user['username']}@{servername}",
|
|
||||||
'links': [
|
|
||||||
{
|
|
||||||
'rel': 'http://webfinger.net/rel/profile-page',
|
|
||||||
'type': 'text/html',
|
|
||||||
'href': f"https://treadl.com/{fetch_user['username']}"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
'rel': 'self',
|
|
||||||
'type': 'application/activity+json',
|
|
||||||
'href': f"https://treadl.com/users/{fetch_user['username']}"
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
|
|
||||||
def user(username):
|
|
||||||
db = database.get_db()
|
|
||||||
fetch_user = db.users.find_one({'username': username}, {'username': 1, 'createdAt': 1, 'avatar': 1, 'avatarBlurHash': 1, 'bio': 1, 'location': 1, 'website': 1, 'twitter': 1, 'facebook': 1, 'linkedIn': 1, 'instagram': 1})
|
|
||||||
if not fetch_user:
|
|
||||||
raise util.errors.NotFound('User not found')
|
|
||||||
if 'avatar' in fetch_user:
|
|
||||||
fetch_user['avatarUrl'] = uploads.get_presigned_url('users/{0}/{1}'.format(str(fetch_user['_id']), fetch_user['avatar']))
|
|
||||||
username = fetch_user['username']
|
|
||||||
return {
|
|
||||||
"@context": [
|
|
||||||
"https://www.w3.org/ns/activitystreams",
|
|
||||||
"https://w3id.org/security/v1",
|
|
||||||
{
|
|
||||||
"manuallyApprovesFollowers": "as:manuallyApprovesFollowers",
|
|
||||||
"PropertyValue": "schema:PropertyValue",
|
|
||||||
"schema": "http://schema.org#",
|
|
||||||
"value": "schema:value"
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"id": f"https://treadl.com/users/{username}",
|
|
||||||
"type": "Person",
|
|
||||||
"following": f"https://treadl.com/users/{username}/following",
|
|
||||||
"followers": f"https://treadl.com/users/{username}/followers",
|
|
||||||
"inbox": f"https://treadl.com/users/{username}/inbox",
|
|
||||||
"outbox": f"https://treadl.com/users/{username}/outbox",
|
|
||||||
"preferredUsername": username,
|
|
||||||
"name": username,
|
|
||||||
"summary": fetch_user.get('bio'),
|
|
||||||
"url": f"https://treadl.com/{username}",
|
|
||||||
"discoverable": True,
|
|
||||||
"manuallyApprovesFollowers": False,
|
|
||||||
"publicKey": {
|
|
||||||
"id": f"https://treadl.com/users/{username}#main-key",
|
|
||||||
"owner": f"https://treadl.com/users/{username}",
|
|
||||||
"publicKeyPem": ""
|
|
||||||
},
|
|
||||||
"icon": {
|
|
||||||
"type": "Image",
|
|
||||||
"mediaType": "image/jpeg",
|
|
||||||
"url": fetch_user['avatarUrl']
|
|
||||||
},
|
|
||||||
"image": {
|
|
||||||
"type": "Image",
|
|
||||||
"mediaType": "image/jpeg",
|
|
||||||
"url": fetch_user['avatarUrl']
|
|
||||||
},
|
|
||||||
"endpoints": {
|
|
||||||
"sharedInbox": "https://treadl.com/f/inbox"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
def outbox(username):
|
|
||||||
db = database.get_db()
|
|
||||||
fetch_user = db.users.find_one({'username': username}, {'username': 1})
|
|
||||||
if not fetch_user:
|
|
||||||
raise util.errors.NotFound('User not found')
|
|
||||||
|
|
||||||
username = fetch_user['username']
|
|
||||||
feed = []
|
|
||||||
|
|
||||||
feed.append({
|
|
||||||
"id": f"https://treadl.com/users/{username}/statuses/107197029153980712/activity",
|
|
||||||
"type": "Create",
|
|
||||||
"actor": f"https://treadl.com/users/{username}",
|
|
||||||
"published": "2021-11-09T18:17:15Z",
|
|
||||||
"to": [
|
|
||||||
"https://www.w3.org/ns/activitystreams#Public"
|
|
||||||
],
|
|
||||||
"cc": [
|
|
||||||
f"https://treadl.com/users/{username}/followers"
|
|
||||||
],
|
|
||||||
"object": {
|
|
||||||
"id": f"https://treadl.com/users/{username}/statuses/107197029153980712",
|
|
||||||
"type": "Note",
|
|
||||||
"summary": None,
|
|
||||||
"inReplyTo": None,
|
|
||||||
"published": "2021-11-09T18:17:15Z",
|
|
||||||
"url": f"https://treadl.com/{username}", # URL to object
|
|
||||||
"attributedTo": f"https://treadl.com/users/{username}",
|
|
||||||
"to": [
|
|
||||||
"https://www.w3.org/ns/activitystreams#Public"
|
|
||||||
],
|
|
||||||
"cc": [
|
|
||||||
f"https://treadl.com/users/{username}/followers"
|
|
||||||
],
|
|
||||||
"content": "<p>This is a test!</p>",
|
|
||||||
"attachment": [],
|
|
||||||
"tag": [],
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
return {
|
|
||||||
"@context": [
|
|
||||||
"https://www.w3.org/ns/activitystreams",
|
|
||||||
{
|
|
||||||
"ostatus": "http://ostatus.org#",
|
|
||||||
"atomUri": "ostatus:atomUri",
|
|
||||||
"inReplyToAtomUri": "ostatus:inReplyToAtomUri",
|
|
||||||
"conversation": "ostatus:conversation",
|
|
||||||
"sensitive": "as:sensitive",
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"id": f"https://treadl.com/users/{username}/outbox?page=true",
|
|
||||||
"type": "OrderedCollectionPage",
|
|
||||||
"orderedItems": feed
|
|
||||||
}
|
|
@ -1,112 +0,0 @@
|
|||||||
import datetime, os
|
|
||||||
from bson.objectid import ObjectId
|
|
||||||
from chalicelib.util import database, util
|
|
||||||
import stripe
|
|
||||||
|
|
||||||
stripe.api_key = os.environ.get('STRIPE_KEY')
|
|
||||||
|
|
||||||
plans = [{
|
|
||||||
'id': 'free',
|
|
||||||
'key': 'free',
|
|
||||||
'name': 'Free',
|
|
||||||
'description': 'Free to beta users. No credit card needed.',
|
|
||||||
'price': '$0.00',
|
|
||||||
'features': [
|
|
||||||
'Use our weaving pattern creator, editor, & tools',
|
|
||||||
'Unlimited public projects',
|
|
||||||
'2 private projects',
|
|
||||||
'Up to 10 items per project',
|
|
||||||
'Import patterns in WIF format',
|
|
||||||
'Export patterns to WIF format',
|
|
||||||
'Share your projects and work with the world',
|
|
||||||
'Create and manage groups and community pages'
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
'id': os.environ.get('STRIPE_PLAN_WEAVER'),
|
|
||||||
'key': 'weaver',
|
|
||||||
'name': 'Weaver',
|
|
||||||
'description': '🍺 A pint a month.',
|
|
||||||
'price': '$7.00',
|
|
||||||
'features': [
|
|
||||||
'Everything in the "Free" plan',
|
|
||||||
'Unlimited private projects',
|
|
||||||
'Unlimited closed-source projects',
|
|
||||||
'Up to 20 items per project',
|
|
||||||
'Get access to new features first'
|
|
||||||
]
|
|
||||||
}
|
|
||||||
]
|
|
||||||
|
|
||||||
def get_plans(user):
|
|
||||||
return {'plans': plans}
|
|
||||||
|
|
||||||
def get(user):
|
|
||||||
db = database.get_db()
|
|
||||||
return db.users.find_one(user['_id'], {'billing.planId': 1, 'billing.card': 1}).get('billing', {})
|
|
||||||
|
|
||||||
def update_card(user, data):
|
|
||||||
if not data: raise util.errors.BadRequest('Invalid request')
|
|
||||||
if not data.get('token'): raise util.errors.BadRequest('Invalid request')
|
|
||||||
token = data['token']
|
|
||||||
card = token.get('card')
|
|
||||||
if not card: raise util.errors.BadRequest('Invalid request')
|
|
||||||
card['createdAt'] = datetime.datetime.now()
|
|
||||||
db = database.get_db()
|
|
||||||
|
|
||||||
if user.get('billing', {}).get('customerId'):
|
|
||||||
customer = stripe.Customer.retrieve(user['billing']['customerId'])
|
|
||||||
customer.source = token.get('id')
|
|
||||||
customer.save()
|
|
||||||
else:
|
|
||||||
customer = stripe.Customer.create(
|
|
||||||
source = token.get('id'),
|
|
||||||
email = user['email'],
|
|
||||||
)
|
|
||||||
db.users.update({'_id': user['_id']}, {'$set': {'billing.customerId': customer.id}})
|
|
||||||
|
|
||||||
db.users.update({'_id': user['_id']}, {'$set': {'billing.card': card}})
|
|
||||||
return get(user)
|
|
||||||
|
|
||||||
def delete_card(user):
|
|
||||||
card_id = user.get('billing', {}).get('card', {}).get('id')
|
|
||||||
customer_id = user.get('billing').get('customerId')
|
|
||||||
if not customer_id or not card_id: raise util.errors.NotFound('Card not found')
|
|
||||||
try:
|
|
||||||
customer = stripe.Customer.retrieve(customer_id)
|
|
||||||
customer.sources.retrieve(card_id).delete()
|
|
||||||
except:
|
|
||||||
raise util.errors.BadRequest('Unable to delete your card at this time')
|
|
||||||
db = database.get_db()
|
|
||||||
db.users.update({'_id': user['_id']}, {'$unset': {'billing.card': ''}})
|
|
||||||
return {'deletedCard': card_id}
|
|
||||||
|
|
||||||
def select_plan(user, plan_id):
|
|
||||||
db = database.get_db()
|
|
||||||
billing = user.get('billing', {})
|
|
||||||
if plan_id == 'free' and billing.get('subscriptionId'):
|
|
||||||
subscription = stripe.Subscription.retrieve(billing['subscriptionId'])
|
|
||||||
subscription.delete()
|
|
||||||
db.users.update({'_id': user['_id']}, {'$unset': {'billing.subscriptionId': '', 'billing.planId': ''}})
|
|
||||||
|
|
||||||
if plan_id != 'free' and plan_id != billing.get('planId'):
|
|
||||||
if not billing or not billing.get('customerId') or not billing.get('card'):
|
|
||||||
raise util.errors.BadRequest('A payment card has not been added to this account')
|
|
||||||
if 'subscriptionId' in billing:
|
|
||||||
subscription = stripe.Subscription.retrieve(billing['subscriptionId'])
|
|
||||||
stripe.Subscription.modify(billing['subscriptionId'],
|
|
||||||
cancel_at_period_end=False,
|
|
||||||
items=[{
|
|
||||||
'id': subscription['items']['data'][0].id,
|
|
||||||
'plan': plan_id,
|
|
||||||
}]
|
|
||||||
)
|
|
||||||
else:
|
|
||||||
subscription = stripe.Subscription.create(
|
|
||||||
customer = billing['customerId'],
|
|
||||||
items = [{'plan': plan_id}]
|
|
||||||
)
|
|
||||||
db.users.update({'_id': user['_id']}, {'$set': {'billing.subscriptionId': subscription.id}})
|
|
||||||
db.users.update({'_id': user['_id']}, {'$set': {'billing.planId': plan_id}})
|
|
||||||
|
|
||||||
return get(user)
|
|
@ -1,245 +0,0 @@
|
|||||||
import datetime, re
|
|
||||||
import pymongo
|
|
||||||
from bson.objectid import ObjectId
|
|
||||||
from chalicelib.util import database, util, mail, push
|
|
||||||
from chalicelib.api import uploads
|
|
||||||
|
|
||||||
def create(user, data):
|
|
||||||
if not data: raise util.errors.BadRequest('Invalid request')
|
|
||||||
if len(data.get('name')) < 3: raise util.errors.BadRequest('A longer name is required')
|
|
||||||
db = database.get_db()
|
|
||||||
|
|
||||||
group = {
|
|
||||||
'createdAt': datetime.datetime.now(),
|
|
||||||
'user': user['_id'],
|
|
||||||
'admins': [user['_id']],
|
|
||||||
'name': data['name'],
|
|
||||||
'description': data.get('description', ''),
|
|
||||||
'closed': data.get('closed', False),
|
|
||||||
}
|
|
||||||
result = db.groups.insert_one(group)
|
|
||||||
group['_id'] = result.inserted_id
|
|
||||||
create_member(user, group['_id'], user['_id'])
|
|
||||||
return group
|
|
||||||
|
|
||||||
def get(user):
|
|
||||||
db = database.get_db()
|
|
||||||
groups = list(db.groups.find({'_id': {'$in': user.get('groups', [])}}))
|
|
||||||
return {'groups': groups}
|
|
||||||
|
|
||||||
def get_one(user, id):
|
|
||||||
db = database.get_db()
|
|
||||||
id = ObjectId(id)
|
|
||||||
group = db.groups.find_one({'_id': id})
|
|
||||||
if not group: raise util.errors.NotFound('Group not found')
|
|
||||||
group['adminUsers'] = list(db.users.find({'_id': {'$in': group.get('admins', [])}}, {'username': 1, 'avatar': 1}))
|
|
||||||
for u in group['adminUsers']:
|
|
||||||
if 'avatar' in u:
|
|
||||||
u['avatarUrl'] = uploads.get_presigned_url('users/{0}/{1}'.format(u['_id'], u['avatar']))
|
|
||||||
return group
|
|
||||||
|
|
||||||
def update(user, id, update):
|
|
||||||
db = database.get_db()
|
|
||||||
id = ObjectId(id)
|
|
||||||
group = db.groups.find_one({'_id': id}, {'admins': 1})
|
|
||||||
if not group: raise util.errors.NotFound('Group not found')
|
|
||||||
if user['_id'] not in group.get('admins', []): raise util.errors.Forbidden('You\'re not a group admin')
|
|
||||||
allowed_keys = ['name', 'description', 'closed']
|
|
||||||
updater = util.build_updater(update, allowed_keys)
|
|
||||||
if updater: db.groups.update({'_id': id}, updater)
|
|
||||||
return get_one(user, id)
|
|
||||||
|
|
||||||
def delete(user, id):
|
|
||||||
db = database.get_db()
|
|
||||||
id = ObjectId(id)
|
|
||||||
group = db.groups.find_one({'_id': id}, {'admins': 1})
|
|
||||||
if not group: raise util.errors.NotFound('Group not found')
|
|
||||||
if user['_id'] not in group.get('admins', []): raise util.errors.Forbidden('You\'re not a group admin')
|
|
||||||
db.groups.remove({'_id': id})
|
|
||||||
db.groupEntries.remove({'group': id})
|
|
||||||
db.users.update({'groups': id}, {'$pull': {'groups': id}}, multi = True)
|
|
||||||
return {'deletedGroup': id}
|
|
||||||
|
|
||||||
def create_entry(user, id, data):
|
|
||||||
if not data or 'content' not in data: raise util.errors.BadRequest('Invalid request')
|
|
||||||
db = database.get_db()
|
|
||||||
id = ObjectId(id)
|
|
||||||
group = db.groups.find_one({'_id': id}, {'admins': 1, 'name': 1})
|
|
||||||
if not group: raise util.errors.NotFound('Group not found')
|
|
||||||
if group['_id'] not in user.get('groups', []): raise util.errors.Forbidden('You must be a member to write in the feed')
|
|
||||||
entry = {
|
|
||||||
'createdAt': datetime.datetime.now(),
|
|
||||||
'group': id,
|
|
||||||
'user': user['_id'],
|
|
||||||
'content': data['content'],
|
|
||||||
}
|
|
||||||
if 'attachments' in data:
|
|
||||||
entry['attachments'] = data['attachments']
|
|
||||||
for attachment in entry['attachments']:
|
|
||||||
if re.search(r'(.jpg)|(.png)|(.jpeg)|(.gif)$', attachment['storedName'].lower()):
|
|
||||||
attachment['isImage'] = True
|
|
||||||
if attachment['type'] == 'file':
|
|
||||||
attachment['url'] = uploads.get_presigned_url('groups/{0}/{1}'.format(id, attachment['storedName']))
|
|
||||||
|
|
||||||
result = db.groupEntries.insert_one(entry)
|
|
||||||
entry['_id'] = result.inserted_id
|
|
||||||
entry['authorUser'] = {'_id': user['_id'], 'username': user['username'], 'avatar': user.get('avatar')}
|
|
||||||
if 'avatar' in user:
|
|
||||||
entry['authorUser']['avatarUrl'] = uploads.get_presigned_url('users/{0}/{1}'.format(user['_id'], user['avatar']))
|
|
||||||
|
|
||||||
for u in db.users.find({'_id': {'$ne': user['_id']}, 'groups': id, 'subscriptions.email': 'groupFeed-' + str(id)}, {'email': 1, 'username': 1}):
|
|
||||||
mail.send({
|
|
||||||
'to_user': u,
|
|
||||||
'subject': 'New message in ' + group['name'],
|
|
||||||
'text': 'Dear {0},\n\n{1} posted a message in the Notice Board of {2} on Treadl:\n\n{3}\n\nFollow the link below to visit the group:\n\n{4}'.format(u['username'], user['username'], group['name'], data['content'], 'https://treadl.com/groups/' + str(id))
|
|
||||||
})
|
|
||||||
push.send_multiple(list(db.users.find({'_id': {'$ne': user['_id']}, 'groups': id})), '{} posted in {}'.format(user['username'], group['name']), data['content'][:30] + '...')
|
|
||||||
return entry
|
|
||||||
|
|
||||||
def get_entries(user, id):
|
|
||||||
db = database.get_db()
|
|
||||||
id = ObjectId(id)
|
|
||||||
group = db.groups.find_one({'_id': id}, {'admins': 1})
|
|
||||||
if not group: raise util.errors.NotFound('Group not found')
|
|
||||||
if id not in user.get('groups', []): raise util.errors.BadRequest('You\'re not a member of this group')
|
|
||||||
entries = list(db.groupEntries.find({'group': id}).sort('createdAt', pymongo.DESCENDING))
|
|
||||||
authors = list(db.users.find({'_id': {'$in': [e['user'] for e in entries]}}, {'username': 1, 'avatar': 1}))
|
|
||||||
for entry in entries:
|
|
||||||
if 'attachments' in entry:
|
|
||||||
for attachment in entry['attachments']:
|
|
||||||
attachment['url'] = uploads.get_presigned_url('groups/{0}/{1}'.format(id, attachment['storedName']))
|
|
||||||
for author in authors:
|
|
||||||
if entry['user'] == author['_id']:
|
|
||||||
entry['authorUser'] = author
|
|
||||||
if 'avatar' in author:
|
|
||||||
entry['authorUser']['avatarUrl'] = uploads.get_presigned_url('users/{0}/{1}'.format(author['_id'], author['avatar']))
|
|
||||||
return {'entries': entries}
|
|
||||||
|
|
||||||
def delete_entry(user, id, entry_id):
|
|
||||||
db = database.get_db()
|
|
||||||
id = ObjectId(id)
|
|
||||||
entry_id = ObjectId(entry_id)
|
|
||||||
group = db.groups.find_one({'_id': id}, {'admins': 1})
|
|
||||||
if not group: raise util.errors.NotFound('Group not found')
|
|
||||||
entry = db.groupEntries.find_one(entry_id, {'user': 1, 'group': 1})
|
|
||||||
if not entry or entry['group'] != id: raise util.errors.NotFound('Entry not found')
|
|
||||||
if entry['user'] != user['_id'] and user['_id'] not in group.get('admins', []): raise util.errors.Forbidden('You must own the entry or be an admin of the group')
|
|
||||||
db.groupEntries.remove({'$or': [{'_id': entry_id}, {'inReplyTo': entry_id}]})
|
|
||||||
return {'deletedEntry': entry_id}
|
|
||||||
|
|
||||||
def create_entry_reply(user, id, entry_id, data):
|
|
||||||
if not data or 'content' not in data: raise util.errors.BadRequest('Invalid request')
|
|
||||||
db = database.get_db()
|
|
||||||
id = ObjectId(id)
|
|
||||||
entry_id = ObjectId(entry_id)
|
|
||||||
group = db.groups.find_one({'_id': id}, {'admins': 1, 'name': 1})
|
|
||||||
if not group: raise util.errors.NotFound('Group not found')
|
|
||||||
entry = db.groupEntries.find_one({'_id': entry_id})
|
|
||||||
if not entry or entry.get('group') != group['_id']: raise util.errors.NotFound('Entry to reply to not found')
|
|
||||||
if group['_id'] not in user.get('groups', []): raise util.errors.Forbidden('You must be a member to write in the feed')
|
|
||||||
reply = {
|
|
||||||
'createdAt': datetime.datetime.now(),
|
|
||||||
'group': id,
|
|
||||||
'inReplyTo': entry_id,
|
|
||||||
'user': user['_id'],
|
|
||||||
'content': data['content'],
|
|
||||||
}
|
|
||||||
if 'attachments' in data:
|
|
||||||
reply['attachments'] = data['attachments']
|
|
||||||
for attachment in reply['attachments']:
|
|
||||||
if re.search(r'(.jpg)|(.png)|(.jpeg)|(.gif)$', attachment['storedName'].lower()):
|
|
||||||
attachment['isImage'] = True
|
|
||||||
if attachment['type'] == 'file':
|
|
||||||
attachment['url'] = uploads.get_presigned_url('groups/{0}/{1}'.format(id, attachment['storedName']))
|
|
||||||
|
|
||||||
result = db.groupEntries.insert_one(reply)
|
|
||||||
reply['_id'] = result.inserted_id
|
|
||||||
reply['authorUser'] = {'_id': user['_id'], 'username': user['username'], 'avatar': user.get('avatar')}
|
|
||||||
if 'avatar' in user:
|
|
||||||
reply['authorUser']['avatarUrl'] = uploads.get_presigned_url('users/{0}/{1}'.format(user['_id'], user['avatar']))
|
|
||||||
op = db.users.find_one({'$and': [{'_id': entry.get('user')}, {'_id': {'$ne': user['_id']}}], 'subscriptions.email': 'messages.replied'})
|
|
||||||
if op:
|
|
||||||
mail.send({
|
|
||||||
'to_user': op,
|
|
||||||
'subject': user['username'] + ' replied to your post',
|
|
||||||
'text': 'Dear {0},\n\n{1} replied to your message in the Notice Board of {2} on Treadl:\n\n{3}\n\nFollow the link below to visit the group:\n\n{4}'.format(op['username'], user['username'], group['name'], data['content'], 'https://treadl.com/groups/' + str(id))
|
|
||||||
})
|
|
||||||
return reply
|
|
||||||
|
|
||||||
def delete_entry_reply(user, id, entry_id, reply_id):
|
|
||||||
db = database.get_db()
|
|
||||||
id = ObjectId(id)
|
|
||||||
entry_id = ObjectId(entry_id)
|
|
||||||
reply_id = ObjectId(reply_id)
|
|
||||||
group = db.groups.find_one({'_id': id}, {'admins': 1})
|
|
||||||
if not group: raise util.errors.NotFound('Group not found')
|
|
||||||
entry = db.groupEntries.find_one(entry_id, {'user': 1, 'group': 1})
|
|
||||||
if not entry or entry['group'] != id: raise util.errors.NotFound('Entry not found')
|
|
||||||
reply = db.groupEntries.find_one(reply_id)
|
|
||||||
if not reply or reply.get('inReplyTo') != entry_id: raise util.errors.NotFound('Reply not found')
|
|
||||||
if entry['user'] != user['_id'] and reply['user'] != user['_id'] and user['_id'] not in group.get('admins', []): raise util.errors.Forbidden('You must own the reply or entry or be an admin of the group')
|
|
||||||
db.groupEntries.remove({'_id': entry_id})
|
|
||||||
return {'deletedEntry': entry_id}
|
|
||||||
|
|
||||||
def create_member(user, id, user_id, invited = False):
|
|
||||||
db = database.get_db()
|
|
||||||
id = ObjectId(id)
|
|
||||||
user_id = ObjectId(user_id)
|
|
||||||
group = db.groups.find_one({'_id': id}, {'admins': 1, 'name': 1, 'closed': 1})
|
|
||||||
if not group: raise util.errors.NotFound('Group not found')
|
|
||||||
if user_id != user['_id']: raise util.errors.Forbidden('Not allowed to add someone else to the group')
|
|
||||||
if group.get('closed') and not invited and user['_id'] not in group.get('admins', []): raise util.errors.Forbidden('Not allowed to join a closed group')
|
|
||||||
db.users.update({'_id': user_id}, {'$addToSet': {'groups': id, 'subscriptions.email': 'groupFeed-' + str(id)}})
|
|
||||||
db.invitations.remove({'type': 'group', 'typeId': id, 'recipient': user_id})
|
|
||||||
for admin in db.users.find({'_id': {'$in': group.get('admins', []), '$ne': user_id}, 'subscriptions.email': 'groups.joined'}, {'email': 1, 'username': 1}):
|
|
||||||
mail.send({
|
|
||||||
'to_user': admin,
|
|
||||||
'subject': 'Someone joined your group',
|
|
||||||
'text': 'Dear {0},\n\n{1} recently joined your group {2} on Treadl!\n\nFollow the link below to manage your group:\n\n{2}'.format(admin['username'], user['username'], group['name'], 'https://treadl.com/groups/' + str(id))
|
|
||||||
})
|
|
||||||
|
|
||||||
return {'newMember': user_id}
|
|
||||||
|
|
||||||
def get_members(user, id):
|
|
||||||
db = database.get_db()
|
|
||||||
id = ObjectId(id)
|
|
||||||
group = db.groups.find_one({'_id': id}, {'admins': 1})
|
|
||||||
if not group: raise util.errors.NotFound('Group not found')
|
|
||||||
if id not in user.get('groups', []) and not 'root' in user.get('roles', []): raise util.errors.Forbidden('You need to be a member to see the member list')
|
|
||||||
members = list(db.users.find({'groups': id}, {'username': 1, 'avatar': 1, 'bio': 1, 'groups': 1}))
|
|
||||||
for m in members:
|
|
||||||
if 'avatar' in m:
|
|
||||||
m['avatarUrl'] = uploads.get_presigned_url('users/{0}/{1}'.format(m['_id'], m['avatar']))
|
|
||||||
return {'members': members}
|
|
||||||
|
|
||||||
def delete_member(user, id, user_id):
|
|
||||||
id = ObjectId(id)
|
|
||||||
user_id = ObjectId(user_id)
|
|
||||||
db = database.get_db()
|
|
||||||
group = db.groups.find_one({'_id': id}, {'admins': 1})
|
|
||||||
if not group: raise util.errors.NotFound('Group not found')
|
|
||||||
if user_id != user['_id'] and user['_id'] not in group.get('admins', []): raise util.errors.Forbidden('You can\'t remove this user')
|
|
||||||
if user_id in group.get('admins', []) and len(group['admins']) == 1:
|
|
||||||
raise util.errors.Forbidden('There needs to be at least one admin in this group')
|
|
||||||
db.users.update({'_id': user_id}, {'$pull': {'groups': id, 'subscriptions.email': 'groupFeed-' + str(id)}})
|
|
||||||
db.groups.update({'_id': id}, {'$pull': {'admins': user_id}})
|
|
||||||
return {'deletedMember': user_id}
|
|
||||||
|
|
||||||
def get_projects(user, id):
|
|
||||||
db = database.get_db()
|
|
||||||
id = ObjectId(id)
|
|
||||||
group = db.groups.find_one({'_id': id}, {'admins': 1})
|
|
||||||
if not group: raise util.errors.NotFound('Group not found')
|
|
||||||
if id not in user.get('groups', []): raise util.errors.Forbidden('You need to be a member to see the project list')
|
|
||||||
projects = list(db.projects.find({'groupVisibility': id}, {'name': 1, 'path': 1, 'user': 1, 'description': 1, 'visibility': 1}))
|
|
||||||
authors = list(db.users.find({'groups': id, '_id': {'$in': list(map(lambda p: p['user'], projects))}}, {'username': 1, 'avatar': 1, 'bio': 1}))
|
|
||||||
for a in authors:
|
|
||||||
if 'avatar' in a:
|
|
||||||
a['avatarUrl'] = uploads.get_presigned_url('users/{0}/{1}'.format(a['_id'], a['avatar']))
|
|
||||||
for project in projects:
|
|
||||||
for a in authors:
|
|
||||||
if project['user'] == a['_id']:
|
|
||||||
project['owner'] = a
|
|
||||||
project['fullName'] = a['username'] + '/' + project['path']
|
|
||||||
break
|
|
||||||
return {'projects': projects}
|
|
@ -1,157 +0,0 @@
|
|||||||
import re, datetime
|
|
||||||
import pymongo
|
|
||||||
from bson.objectid import ObjectId
|
|
||||||
from chalicelib.util import database, util, mail
|
|
||||||
from chalicelib.api import uploads, groups
|
|
||||||
|
|
||||||
def get(user):
|
|
||||||
db = database.get_db()
|
|
||||||
admin_groups = list(db.groups.find({'admins': user['_id']}))
|
|
||||||
invites = list(db.invitations.find({'$or': [{'recipient': user['_id']}, {'recipientGroup': {'$in': list(map(lambda g: g['_id'], admin_groups))}}]}))
|
|
||||||
inviters = list(db.users.find({'_id': {'$in': [i['user'] for i in invites]}}, {'username': 1, 'avatar': 1}))
|
|
||||||
for invite in invites:
|
|
||||||
invite['recipient'] = user['_id']
|
|
||||||
if invite['type'] in ['group', 'groupJoinRequest']: invite['group'] = db.groups.find_one({'_id': invite['typeId']}, {'name': 1})
|
|
||||||
for u in inviters:
|
|
||||||
if u['_id'] == invite['user']:
|
|
||||||
if 'avatar' in u:
|
|
||||||
u['avatarUrl'] = uploads.get_presigned_url('users/{0}/{1}'.format(u['_id'], u['avatar']))
|
|
||||||
invite['invitedBy'] = u
|
|
||||||
break
|
|
||||||
sent_invites = list(db.invitations.find({'user': user['_id']}))
|
|
||||||
recipients = list(db.users.find({'_id': {'$in': list(map(lambda i: i.get('recipient'), sent_invites))}}, {'username': 1, 'avatar': 1}))
|
|
||||||
for invite in sent_invites:
|
|
||||||
if invite['type'] in ['group', 'groupJoinRequest']: invite['group'] = db.groups.find_one({'_id': invite['typeId']}, {'name': 1})
|
|
||||||
for u in recipients:
|
|
||||||
if u['_id'] == invite.get('recipient'):
|
|
||||||
if 'avatar' in u:
|
|
||||||
u['avatarUrl'] = uploads.get_presigned_url('users/{0}/{1}'.format(u['_id'], u['avatar']))
|
|
||||||
invite['invitedBy'] = u
|
|
||||||
break
|
|
||||||
return {'invitations': invites, 'sentInvitations': sent_invites}
|
|
||||||
|
|
||||||
def accept(user, id):
|
|
||||||
db = database.get_db()
|
|
||||||
id = ObjectId(id)
|
|
||||||
invite = db.invitations.find_one({'_id': id})
|
|
||||||
if not invite: raise util.errors.NotFound('Invitation not found')
|
|
||||||
if invite['type'] == 'group':
|
|
||||||
if invite['recipient'] != user['_id']: raise util.errors.Forbidden('This invitation is not yours to accept')
|
|
||||||
group = db.groups.find_one({'_id': invite['typeId']}, {'name': 1})
|
|
||||||
if not group:
|
|
||||||
db.invitations.remove({'_id': id})
|
|
||||||
return {'acceptedInvitation': id}
|
|
||||||
groups.create_member(user, group['_id'], user['_id'], invited = True)
|
|
||||||
db.invitations.remove({'_id': id})
|
|
||||||
return {'acceptedInvitation': id, 'group': group}
|
|
||||||
if invite['type'] == 'groupJoinRequest':
|
|
||||||
group = db.groups.find_one({'_id': invite['typeId']})
|
|
||||||
if user['_id'] not in group.get('admins', []): raise util.errors.Forbidden('You need to be an admin of this group to accept this request')
|
|
||||||
requester = db.users.find_one({'_id': invite['user']})
|
|
||||||
if not group or not requester:
|
|
||||||
db.invitations.remove({'_id': id})
|
|
||||||
return {'acceptedInvitation': id}
|
|
||||||
groups.create_member(requester, group['_id'], requester['_id'], invited = True)
|
|
||||||
db.invitations.remove({'_id': id})
|
|
||||||
return {'acceptedInvitation': id, 'group': group}
|
|
||||||
|
|
||||||
def delete(user, id):
|
|
||||||
db = database.get_db()
|
|
||||||
id = ObjectId(id)
|
|
||||||
invite = db.invitations.find_one({'_id': id})
|
|
||||||
if not invite: raise util.errors.NotFound('Invitation not found')
|
|
||||||
if invite['type'] == 'group':
|
|
||||||
if invite['recipient'] != user['_id']: raise util.errors.Forbidden('This invitation is not yours to decline')
|
|
||||||
if invite['type'] == 'groupJoinRequest':
|
|
||||||
group = db.groups.find_one({'_id': invite['typeId']})
|
|
||||||
if user['_id'] not in group.get('admins', []): raise util.errors.Forbidden('You need to be an admin of this group to manage this request')
|
|
||||||
db.invitations.remove({'_id': id})
|
|
||||||
return {'deletedInvitation': id}
|
|
||||||
|
|
||||||
def create_group_invitation(user, group_id, data):
|
|
||||||
if not data or 'user' not in data: raise util.errors.BadRequest('Invalid request')
|
|
||||||
db = database.get_db()
|
|
||||||
recipient_id = ObjectId(data['user'])
|
|
||||||
group_id = ObjectId(group_id)
|
|
||||||
group = db.groups.find_one({'_id': group_id}, {'admins': 1, 'name': 1})
|
|
||||||
if not group: raise util.errors.NotFound('Group not found')
|
|
||||||
if user['_id'] not in group.get('admins', []): raise util.errors.Forbidden('You need to be a group admin to invite users')
|
|
||||||
recipient = db.users.find_one({'_id': recipient_id}, {'groups': 1, 'username': 1, 'email': 1, 'subscriptions': 1})
|
|
||||||
if not recipient: raise util.errors.NotFound('User not found')
|
|
||||||
if group_id in recipient.get('groups', []): raise util.errors.BadRequest('This user is already in this group')
|
|
||||||
if db.invitations.find_one({'recipient': recipient_id, 'typeId': group_id, 'type': 'group'}):
|
|
||||||
raise util.errors.BadRequest('This user has already been invited to this group')
|
|
||||||
invite = {
|
|
||||||
'createdAt': datetime.datetime.now(),
|
|
||||||
'user': user['_id'],
|
|
||||||
'recipient': recipient_id,
|
|
||||||
'type': 'group',
|
|
||||||
'typeId': group_id
|
|
||||||
}
|
|
||||||
result = db.invitations.insert_one(invite)
|
|
||||||
if 'groups.invited' in recipient.get('subscriptions', {}).get('email', []):
|
|
||||||
mail.send({
|
|
||||||
'to_user': recipient,
|
|
||||||
'subject': 'You\'ve been invited to a group on Treadl',
|
|
||||||
'text': 'Dear {0},\n\nYou have been invited to join the group {1} on Treadl!\n\nLogin by visting https://treadl.com to find your invitation.'.format(recipient['username'], group['name'])
|
|
||||||
})
|
|
||||||
invite['_id'] = result.inserted_id
|
|
||||||
return invite
|
|
||||||
|
|
||||||
def create_group_request(user, group_id):
|
|
||||||
db = database.get_db()
|
|
||||||
group_id = ObjectId(group_id)
|
|
||||||
group = db.groups.find_one({'_id': group_id}, {'admins': 1, 'name': 1})
|
|
||||||
if not group: raise util.errors.NotFound('Group not found')
|
|
||||||
if group_id in user.get('groups'): raise util.errors.BadRequest('You are already a member of this group')
|
|
||||||
admin = db.users.find_one({'_id': {'$in': group.get('admins', [])}}, {'groups': 1, 'username': 1, 'email': 1, 'subscriptions': 1})
|
|
||||||
if not admin: raise util.errors.NotFound('No users can approve you to join this group')
|
|
||||||
if db.invitations.find_one({'recipient': user['_id'], 'typeId': group_id, 'type': 'group'}):
|
|
||||||
raise util.errors.BadRequest('You have already been invited to this group')
|
|
||||||
if db.invitations.find_one({'user': user['_id'], 'typeId': group_id, 'type': 'groupJoinRequest'}):
|
|
||||||
raise util.errors.BadRequest('You have already requested access to this group')
|
|
||||||
invite = {
|
|
||||||
'createdAt': datetime.datetime.now(),
|
|
||||||
'user': user['_id'],
|
|
||||||
'recipientGroup': group['_id'],
|
|
||||||
'type': 'groupJoinRequest',
|
|
||||||
'typeId': group_id
|
|
||||||
}
|
|
||||||
result = db.invitations.insert_one(invite)
|
|
||||||
if 'groups.joinRequested' in admin.get('subscriptions', {}).get('email', []):
|
|
||||||
mail.send({
|
|
||||||
'to_user': admin,
|
|
||||||
'subject': 'Someone wants to join your group',
|
|
||||||
'text': 'Dear {0},\n\{1} has requested to join your group {2} on Treadl!\n\nLogin by visting https://treadl.com to find and approve your requests.'.format(admin['username'], user['username'], group['name'])
|
|
||||||
})
|
|
||||||
invite['_id'] = result.inserted_id
|
|
||||||
return invite
|
|
||||||
|
|
||||||
def get_group_invitations(user, id):
|
|
||||||
db = database.get_db()
|
|
||||||
group_id = ObjectId(id)
|
|
||||||
group = db.groups.find_one({'_id': group_id}, {'admins': 1})
|
|
||||||
if not group: raise util.errors.NotFound('Group not found')
|
|
||||||
if user['_id'] not in group.get('admins', []): raise util.errors.Forbidden('You need to be a group admin to see invitations')
|
|
||||||
invites = list(db.invitations.find({'type': 'group', 'typeId': group_id}))
|
|
||||||
recipients = list(db.users.find({'_id': {'$in': [i['recipient'] for i in invites]}}, {'username': 1, 'avatar': 1}))
|
|
||||||
for invite in invites:
|
|
||||||
for recipient in recipients:
|
|
||||||
if invite['recipient'] == recipient['_id']:
|
|
||||||
if 'avatar' in recipient:
|
|
||||||
recipient['avatarUrl'] = uploads.get_presigned_url('users/{0}/{1}'.format(recipient['_id'], recipient['avatar']))
|
|
||||||
invite['recipientUser'] = recipient
|
|
||||||
break
|
|
||||||
return {'invitations': invites}
|
|
||||||
|
|
||||||
def delete_group_invitation(user, id, invite_id):
|
|
||||||
db = database.get_db()
|
|
||||||
group_id = ObjectId(id)
|
|
||||||
invite_id = ObjectId(invite_id)
|
|
||||||
group = db.groups.find_one({'_id': group_id}, {'admins': 1})
|
|
||||||
if not group: raise util.errors.NotFound('Group not found')
|
|
||||||
if user['_id'] not in group.get('admins', []): raise util.errors.Forbidden('You need to be a group admin to see invitations')
|
|
||||||
invite = db.invitations.find_one({'_id': invite_id})
|
|
||||||
if not invite or invite['typeId'] != group_id: raise util.errors.NotFound('This invite could not be found')
|
|
||||||
db.invitations.remove({'_id': invite_id})
|
|
||||||
return {'deletedInvite': invite_id}
|
|
@ -1,139 +0,0 @@
|
|||||||
import datetime, base64
|
|
||||||
from bson.objectid import ObjectId
|
|
||||||
import requests
|
|
||||||
from chalicelib.util import database, wif, util, mail
|
|
||||||
from chalicelib.api import uploads
|
|
||||||
|
|
||||||
def delete(user, id):
|
|
||||||
db = database.get_db()
|
|
||||||
obj = db.objects.find_one(ObjectId(id), {'project': 1})
|
|
||||||
if not obj:
|
|
||||||
raise util.errors.NotFound('Object not found')
|
|
||||||
project = db.projects.find_one(obj.get('project'), {'user': 1})
|
|
||||||
if not project:
|
|
||||||
raise util.errors.NotFound('Project not found')
|
|
||||||
if project['user'] != user['_id']:
|
|
||||||
raise util.errors.Forbidden('Forbidden', 403)
|
|
||||||
db.objects.remove(ObjectId(id))
|
|
||||||
return {'deletedObject': id}
|
|
||||||
|
|
||||||
def get(user, id):
|
|
||||||
db = database.get_db()
|
|
||||||
return db.objects.find_one(ObjectId(id))
|
|
||||||
|
|
||||||
def copy_to_project(user, id, project_id):
|
|
||||||
db = database.get_db()
|
|
||||||
obj = db.objects.find_one(ObjectId(id))
|
|
||||||
if not obj: raise util.errors.NotFound('This object could not be found')
|
|
||||||
original_project = db.projects.find_one(obj['project'])
|
|
||||||
if not original_project:
|
|
||||||
raise util.errors.NotFound('Project not found')
|
|
||||||
if not original_project.get('openSource') and not (user and user['_id'] == original_project['user']):
|
|
||||||
raise util.errors.Forbidden('This project is not open-source')
|
|
||||||
target_project = db.projects.find_one(ObjectId(project_id))
|
|
||||||
if not target_project or target_project['user'] != user['_id']:
|
|
||||||
raise util.errors.Forbidden('You don\'t own the target project')
|
|
||||||
|
|
||||||
obj['_id'] = ObjectId()
|
|
||||||
obj['project'] = target_project['_id']
|
|
||||||
obj['createdAt'] = datetime.datetime.now()
|
|
||||||
obj['commentCount'] = 0
|
|
||||||
db.objects.insert_one(obj)
|
|
||||||
return obj
|
|
||||||
|
|
||||||
def get_wif(user, id):
|
|
||||||
db = database.get_db()
|
|
||||||
obj = db.objects.find_one(ObjectId(id))
|
|
||||||
if not obj: raise util.errors.NotFound('Object not found')
|
|
||||||
project = db.projects.find_one(obj['project'])
|
|
||||||
if not project.get('openSource') and not (user and user['_id'] == project['user']):
|
|
||||||
raise util.errors.Forbidden('This project is not open-source')
|
|
||||||
try:
|
|
||||||
output = wif.dumps(obj).replace('\n', '\\n')
|
|
||||||
return {'wif': output}
|
|
||||||
except Exception as e:
|
|
||||||
raise util.errors.BadRequest('Unable to create WIF file')
|
|
||||||
|
|
||||||
def get_pdf(user, id):
|
|
||||||
db = database.get_db()
|
|
||||||
obj = db.objects.find_one(ObjectId(id))
|
|
||||||
if not obj: raise util.errors.NotFound('Object not found')
|
|
||||||
project = db.projects.find_one(obj['project'])
|
|
||||||
if not project.get('openSource') and not (user and user['_id'] == project['user']):
|
|
||||||
raise util.errors.Forbidden('This project is not open-source')
|
|
||||||
try:
|
|
||||||
response = requests.get('https://h2io6k3ovg.execute-api.eu-west-1.amazonaws.com/prod/pdf?object=' + id + '&landscape=true&paperWidth=23.39&paperHeight=33.11')
|
|
||||||
response.raise_for_status()
|
|
||||||
pdf = uploads.get_file('objects/' + id + '/export.pdf')
|
|
||||||
body64 = base64.b64encode(pdf['Body'].read())
|
|
||||||
bytes_str = str(body64).replace("b'", '')[:-1]
|
|
||||||
return {'pdf': body64.decode('ascii')}
|
|
||||||
except Exception as e:
|
|
||||||
print(e)
|
|
||||||
raise util.errors.BadRequest('Unable to export PDF')
|
|
||||||
|
|
||||||
def update(user, id, data):
|
|
||||||
db = database.get_db()
|
|
||||||
obj = db.objects.find_one(ObjectId(id), {'project': 1})
|
|
||||||
if not obj: raise util.errors.NotFound('Object not found')
|
|
||||||
project = db.projects.find_one(obj.get('project'), {'user': 1})
|
|
||||||
if not project: raise util.errors.NotFound('Project not found')
|
|
||||||
if project['user'] != user['_id']: raise util.errors.Forbidden('Forbidden')
|
|
||||||
allowed_keys = ['name', 'description', 'pattern', 'preview']
|
|
||||||
updater = util.build_updater(data, allowed_keys)
|
|
||||||
if updater:
|
|
||||||
db.objects.update({'_id': ObjectId(id)}, updater)
|
|
||||||
return get(user, id)
|
|
||||||
|
|
||||||
def create_comment(user, id, data):
|
|
||||||
if not data or not data.get('content'): raise util.errors.BadRequest('Comment data is required')
|
|
||||||
db = database.get_db()
|
|
||||||
obj = db.objects.find_one({'_id': ObjectId(id)})
|
|
||||||
if not obj: raise util.errors.NotFound('We could not find the specified object')
|
|
||||||
project = db.projects.find_one({'_id': obj['project']})
|
|
||||||
comment = {
|
|
||||||
'content': data.get('content', ''),
|
|
||||||
'object': ObjectId(id),
|
|
||||||
'user': user['_id'],
|
|
||||||
'createdAt': datetime.datetime.now()
|
|
||||||
}
|
|
||||||
result = db.comments.insert_one(comment)
|
|
||||||
db.objects.update_one({'_id': ObjectId(id)}, {'$inc': {'commentCount': 1}})
|
|
||||||
comment['_id'] = result.inserted_id
|
|
||||||
comment['authorUser'] = {
|
|
||||||
'username': user['username'],
|
|
||||||
'avatar': user.get('avatar'),
|
|
||||||
'avatarUrl': uploads.get_presigned_url('users/{0}/{1}'.format(user['_id'], user.get('avatar')))
|
|
||||||
}
|
|
||||||
project_owner = db.users.find_one({'_id': project['user'], 'subscriptions.email': 'projects.commented'})
|
|
||||||
if project_owner and project_owner['_id'] != user['_id']:
|
|
||||||
mail.send({
|
|
||||||
'to_user': project_owner,
|
|
||||||
'subject': '{} commented on {}'.format(user['username'], project['name']),
|
|
||||||
'text': 'Dear {0},\n\n{1} commented on {2} in your project {3} on Treadl:\n\n{4}\n\nFollow the link below to see the comment:\n\n{5}'.format(project_owner['username'], user['username'], obj['name'], project['name'], comment['content'], 'https://treadl.com/{}/{}/{}'.format(project_owner['username'], project['path'], str(id)))
|
|
||||||
})
|
|
||||||
return comment
|
|
||||||
|
|
||||||
def get_comments(user, id):
|
|
||||||
db = database.get_db()
|
|
||||||
comments = list(db.comments.find({'object': ObjectId(id)}))
|
|
||||||
user_ids = list(map(lambda c:c['user'], comments))
|
|
||||||
users = list(db.users.find({'_id': {'$in': user_ids}}, {'username': 1, 'avatar': 1}))
|
|
||||||
for comment in comments:
|
|
||||||
for u in users:
|
|
||||||
if comment['user'] == u['_id']:
|
|
||||||
comment['authorUser'] = u
|
|
||||||
if 'avatar' in u:
|
|
||||||
comment['authorUser']['avatarUrl'] = uploads.get_presigned_url('users/{0}/{1}'.format(u['_id'], u['avatar']))
|
|
||||||
return {'comments': comments}
|
|
||||||
|
|
||||||
def delete_comment(user, id, comment_id):
|
|
||||||
db = database.get_db()
|
|
||||||
comment = db.comments.find_one({'_id': ObjectId(comment_id)})
|
|
||||||
obj = db.objects.find_one({'_id': ObjectId(id)})
|
|
||||||
if not comment or not obj or obj['_id'] != comment['object']: raise util.errors.NotFound('Comment not found')
|
|
||||||
project = db.projects.find_one({'_id': obj['project']})
|
|
||||||
if comment['user'] != user['_id'] and comment['user'] != project['user']: raise util.errors.Forbidden('You can\'t delete this comment')
|
|
||||||
db.comments.remove({'_id': comment['_id']})
|
|
||||||
db.objects.update_one({'_id': ObjectId(id)}, {'$inc': {'commentCount': -1}})
|
|
||||||
return {'deletedComment': comment['_id']}
|
|
@ -1,190 +0,0 @@
|
|||||||
import datetime, re
|
|
||||||
from bson.objectid import ObjectId
|
|
||||||
from chalicelib.util import database, wif, util
|
|
||||||
from chalicelib.api import uploads
|
|
||||||
|
|
||||||
default_pattern = {
|
|
||||||
'warp': {
|
|
||||||
'shafts': 8,
|
|
||||||
'threads': 100,
|
|
||||||
'threading': [{'shaft': 0}] * 100,
|
|
||||||
'defaultColour': '178,53,111',
|
|
||||||
'defaultSpacing': 1,
|
|
||||||
'defaultThickness': 1,
|
|
||||||
},
|
|
||||||
'weft': {
|
|
||||||
'treadles': 8,
|
|
||||||
'threads': 50,
|
|
||||||
'treadling': [{'treadle': 0}] * 50,
|
|
||||||
'defaultColour': '53,69,178',
|
|
||||||
'defaultSpacing': 1,
|
|
||||||
'defaultThickness': 1
|
|
||||||
},
|
|
||||||
'tieups': [[]] * 8,
|
|
||||||
'colours': ['256,256,256', '0,0,0', '50,0,256', '0,68,256', '0,256,256', '0,256,0', '119,256,0', '256,256,0', '256,136,0', '256,0,0', '256,0,153', '204,0,256', '132,102,256', '102,155,256', '102,256,256', '102,256,102', '201,256,102', '256,256,102', '256,173,102', '256,102,102', '256,102,194', '224,102,256', '31,0,153', '0,41,153', '0,153,153', '0,153,0', '71,153,0', '153,153,0', '153,82,0', '153,0,0', '153,0,92', '122,0,153', '94,68,204', '68,102,204', '68,204,204', '68,204,68', '153,204,68', '204,204,68', '204,136,68', '204,68,68', '204,68,153', '170,68,204', '37,0,204', '0,50,204', '0,204,204', '0,204,0', '89,204,0', '204,204,0', '204,102,0', '204,0,0', '204,0,115', '153,0,204', '168,136,256', '136,170,256', '136,256,256', '136,256,136', '230,256,136', '256,256,136', '256,178,136', '256,136,136', '256,136,204', '240,136,256', '49,34,238', '34,68,238', '34,238,238', '34,238,34', '71,238,34', '238,238,34', '238,82,34', '238,34,34', '238,34,92', '122,34,238', '128,102,238', '102,136,238', '102,238,238', '102,238,102', '187,238,102', '238,238,102', '238,170,102', '238,102,102', '238,102,187', '204,102,238', '178,53,111', '53,69,178'],
|
|
||||||
}
|
|
||||||
|
|
||||||
def derive_path(name):
|
|
||||||
path = name.replace(' ', '-').lower()
|
|
||||||
return re.sub('[^0-9a-z\-]+', '', path)
|
|
||||||
|
|
||||||
def get_by_username(username, project_path):
|
|
||||||
db = database.get_db()
|
|
||||||
owner = db.users.find_one({'username': username}, {'_id': 1, 'username': 1})
|
|
||||||
if not owner:
|
|
||||||
raise util.errors.BadRequest('User not found')
|
|
||||||
project = db.projects.find_one({'user': owner['_id'], 'path': project_path})
|
|
||||||
if not project:
|
|
||||||
raise util.errors.NotFound('Project not found')
|
|
||||||
project['owner'] = owner
|
|
||||||
project['fullName'] = owner['username'] + '/' + project['path']
|
|
||||||
return project
|
|
||||||
|
|
||||||
def create(user, data):
|
|
||||||
if not data: raise util.errors.BadRequest('Invalid request')
|
|
||||||
name = data.get('name', '')
|
|
||||||
if len(name) < 3: raise util.errors.BadRequest('A longer name is required')
|
|
||||||
db = database.get_db()
|
|
||||||
|
|
||||||
path = derive_path(name)
|
|
||||||
if db.projects.find_one({'user': user['_id'], 'path': path}, {'_id': 1}):
|
|
||||||
raise util.errors.BadRequest('Bad Name')
|
|
||||||
groups = data.get('groupVisibility', [])
|
|
||||||
group_visibility = []
|
|
||||||
for group in groups:
|
|
||||||
group_visibility.append(ObjectId(group))
|
|
||||||
proj = {
|
|
||||||
'name': name,
|
|
||||||
'description': data.get('description', ''),
|
|
||||||
'visibility': data.get('visibility', 'public'),
|
|
||||||
'openSource': data.get('openSource', True),
|
|
||||||
'groupVisibility': group_visibility,
|
|
||||||
'path': path,
|
|
||||||
'user': user['_id'],
|
|
||||||
'createdAt': datetime.datetime.now()
|
|
||||||
}
|
|
||||||
result = db.projects.insert_one(proj)
|
|
||||||
proj['_id'] = result.inserted_id
|
|
||||||
proj['owner'] = {'_id': user['_id'], 'username': user['username']}
|
|
||||||
proj['fullName'] = user['username'] + '/' + proj['path']
|
|
||||||
return proj
|
|
||||||
|
|
||||||
def get(user, username, path):
|
|
||||||
db = database.get_db()
|
|
||||||
owner = db.users.find_one({'username': username}, {'_id': 1, 'username': 1, 'avatar': 1})
|
|
||||||
if not owner: raise util.errors.NotFound('User not found')
|
|
||||||
project = db.projects.find_one({'user': owner['_id'], 'path': path})
|
|
||||||
if not project: raise util.errors.NotFound('Project not found')
|
|
||||||
if not util.can_view_project(user, project):
|
|
||||||
raise util.errors.Forbidden('This project is private')
|
|
||||||
|
|
||||||
if 'avatar' in owner:
|
|
||||||
owner['avatarUrl'] = uploads.get_presigned_url('users/{0}/{1}'.format(owner['_id'], owner['avatar']))
|
|
||||||
project['owner'] = owner
|
|
||||||
project['fullName'] = owner['username'] + '/' + project['path']
|
|
||||||
return project
|
|
||||||
|
|
||||||
def update(user, username, project_path, update):
|
|
||||||
db = database.get_db()
|
|
||||||
project = get_by_username(username, project_path)
|
|
||||||
if project['user'] != user['_id']: raise util.errors.Forbidden('Forbidden')
|
|
||||||
|
|
||||||
current_path = project_path
|
|
||||||
if 'name' in update:
|
|
||||||
if len(update['name']) < 3: raise util.errors.BadRequest('The name is too short.')
|
|
||||||
path = derive_path(update['name'])
|
|
||||||
if db.projects.find_one({'user': user['_id'], 'path': path}, {'_id': 1}):
|
|
||||||
raise util.errors.BadRequest('You already have a project with a similar name')
|
|
||||||
update['path'] = path
|
|
||||||
current_path = path
|
|
||||||
update['groupVisibility'] = list(map(lambda g: ObjectId(g), update.get('groupVisibility', [])))
|
|
||||||
allowed_keys = ['name', 'description', 'path', 'visibility', 'openSource', 'groupVisibility']
|
|
||||||
updater = util.build_updater(update, allowed_keys)
|
|
||||||
if updater:
|
|
||||||
db.projects.update({'_id': project['_id']}, updater)
|
|
||||||
return get(user, username, current_path)
|
|
||||||
|
|
||||||
def delete(user, username, project_path):
|
|
||||||
db = database.get_db()
|
|
||||||
project = get_by_username(username, project_path)
|
|
||||||
if project['user'] != user['_id']:
|
|
||||||
raise util.errors.Forbidden('Forbidden')
|
|
||||||
db.projects.remove({'_id': project['_id']})
|
|
||||||
db.objects.remove({'project': project['_id']})
|
|
||||||
return {'deletedProject': project['_id'] }
|
|
||||||
|
|
||||||
def get_objects(user, username, path):
|
|
||||||
db = database.get_db()
|
|
||||||
project = get_by_username(username, path)
|
|
||||||
if not project: raise util.errors.NotFound('Project not found')
|
|
||||||
if not util.can_view_project(user, project):
|
|
||||||
raise util.errors.Forbidden('This project is private')
|
|
||||||
|
|
||||||
objs = list(db.objects.find({'project': project['_id']}, {'createdAt': 1, 'name': 1, 'description': 1, 'project': 1, 'preview': 1, 'type': 1, 'storedName': 1, 'isImage': 1, 'imageBlurHash': 1, 'commentCount': 1}))
|
|
||||||
for obj in objs:
|
|
||||||
if obj['type'] == 'file' and 'storedName' in obj:
|
|
||||||
obj['url'] = uploads.get_presigned_url('projects/{0}/{1}'.format(project['_id'], obj['storedName']))
|
|
||||||
return objs
|
|
||||||
|
|
||||||
def create_object(user, username, path, data):
|
|
||||||
if not data and not data.get('type'): raise util.errors.BadRequest('Invalid request')
|
|
||||||
if not data.get('type'): raise util.errors.BadRequest('Object type is required.')
|
|
||||||
db = database.get_db()
|
|
||||||
project = get_by_username(username, path)
|
|
||||||
if project['user'] != user['_id']: raise util.errors.Forbidden('Forbidden')
|
|
||||||
file_count = db.objects.find({'project': project['_id']}).count()
|
|
||||||
|
|
||||||
if data['type'] == 'file':
|
|
||||||
if not 'storedName' in data:
|
|
||||||
raise util.errors.BadRequest('File stored name must be included')
|
|
||||||
obj = {
|
|
||||||
'project': project['_id'],
|
|
||||||
'name': data.get('name', 'Untitled file'),
|
|
||||||
'storedName': data['storedName'],
|
|
||||||
'createdAt': datetime.datetime.now(),
|
|
||||||
'type': 'file',
|
|
||||||
}
|
|
||||||
if re.search(r'(.jpg)|(.png)|(.jpeg)|(.gif)$', data['storedName'].lower()):
|
|
||||||
obj['isImage'] = True
|
|
||||||
result = db.objects.insert_one(obj)
|
|
||||||
obj['_id'] = result.inserted_id
|
|
||||||
obj['url'] = uploads.get_presigned_url('projects/{0}/{1}'.format(project['_id'], obj['storedName']))
|
|
||||||
if obj.get('isImage'):
|
|
||||||
def handle_cb(h):
|
|
||||||
db.objects.update_one({'_id': obj['_id']}, {'$set': {'imageBlurHash': h}})
|
|
||||||
uploads.blur_image('projects/' + str(project['_id']) + '/' + data['storedName'], handle_cb)
|
|
||||||
return obj
|
|
||||||
if data['type'] == 'pattern':
|
|
||||||
if data.get('wif'):
|
|
||||||
try:
|
|
||||||
pattern = wif.loads(data['wif'])
|
|
||||||
if pattern:
|
|
||||||
obj = {
|
|
||||||
'project': project['_id'],
|
|
||||||
'name': pattern['name'],
|
|
||||||
'createdAt': datetime.datetime.now(),
|
|
||||||
'type': 'pattern',
|
|
||||||
'pattern': pattern
|
|
||||||
}
|
|
||||||
result = db.objects.insert_one(obj)
|
|
||||||
obj['_id'] = result.inserted_id
|
|
||||||
return obj
|
|
||||||
except Exception as e:
|
|
||||||
raise util.errors.BadRequest('Unable to load WIF file')
|
|
||||||
elif data.get('name'):
|
|
||||||
pattern = default_pattern.copy()
|
|
||||||
pattern['warp'].update({'shafts': data.get('shafts', 8)})
|
|
||||||
pattern['weft'].update({'treadles': data.get('treadles', 8)})
|
|
||||||
obj = {
|
|
||||||
'project': project['_id'],
|
|
||||||
'name': data['name'],
|
|
||||||
'createdAt': datetime.datetime.now(),
|
|
||||||
'type': 'pattern',
|
|
||||||
'pattern': pattern
|
|
||||||
}
|
|
||||||
result = db.objects.insert_one(obj)
|
|
||||||
obj['_id'] = result.inserted_id
|
|
||||||
return obj
|
|
||||||
raise util.errors.BadRequest('Unable to create object')
|
|
||||||
|
|
||||||
|
|
@ -1,35 +0,0 @@
|
|||||||
import re, datetime
|
|
||||||
import pymongo
|
|
||||||
from bson.objectid import ObjectId
|
|
||||||
from chalicelib.util import database, util, mail
|
|
||||||
from chalicelib.api import uploads, groups
|
|
||||||
|
|
||||||
def get_users(user):
|
|
||||||
db = database.get_db()
|
|
||||||
if 'root' not in user.get('roles', []): raise util.errors.Forbidden('Not allowed')
|
|
||||||
users = list(db.users.find({}, {'username': 1, 'avatar': 1, 'email': 1, 'createdAt': 1, 'lastSeenAt': 1, 'roles': 1, 'groups': 1}).sort('lastSeenAt', -1))
|
|
||||||
group_ids = []
|
|
||||||
for u in users: group_ids += u.get('groups', [])
|
|
||||||
groups = list(db.groups.find({'_id': {'$in': group_ids}}))
|
|
||||||
projects = list(db.projects.find({}, {'name': 1, 'path': 1, 'user': 1}))
|
|
||||||
for u in users:
|
|
||||||
if 'avatar' in u:
|
|
||||||
u['avatarUrl'] = uploads.get_presigned_url('users/{0}/{1}'.format(str(u['_id']), u['avatar']))
|
|
||||||
u['projects'] = []
|
|
||||||
for p in projects:
|
|
||||||
if p['user'] == u['_id']:
|
|
||||||
u['projects'].append(p)
|
|
||||||
u['groupMemberships'] = []
|
|
||||||
if u.get('groups'):
|
|
||||||
for g in groups:
|
|
||||||
if g['_id'] in u.get('groups', []):
|
|
||||||
u['groupMemberships'].append(g)
|
|
||||||
return {'users': users}
|
|
||||||
|
|
||||||
def get_groups(user):
|
|
||||||
db = database.get_db()
|
|
||||||
if 'root' not in user.get('roles', []): raise util.errors.Forbidden('Not allowed')
|
|
||||||
groups = list(db.groups.find({}))
|
|
||||||
for group in groups:
|
|
||||||
group['memberCount'] = db.users.find({'groups': group['_id']}).count()
|
|
||||||
return {'groups': groups}
|
|
@ -1,42 +0,0 @@
|
|||||||
import re
|
|
||||||
import pymongo
|
|
||||||
from chalicelib.util import database, util
|
|
||||||
from chalicelib.api import uploads
|
|
||||||
|
|
||||||
def all(user, params):
|
|
||||||
if not params or 'query' not in params: raise util.errors.BadRequest('Username parameter needed')
|
|
||||||
expression = re.compile(params['query'], re.IGNORECASE)
|
|
||||||
db = database.get_db()
|
|
||||||
|
|
||||||
users = list(db.users.find({'username': expression}, {'username': 1, 'avatar': 1}).limit(10).sort('username', pymongo.ASCENDING))
|
|
||||||
for u in users:
|
|
||||||
if 'avatar' in u:
|
|
||||||
u['avatarUrl'] = uploads.get_presigned_url('users/{0}/{1}'.format(u['_id'], u['avatar']))
|
|
||||||
|
|
||||||
projects = list(db.projects.find({'name': expression, '$or': [
|
|
||||||
{'user': user['_id']},
|
|
||||||
{'groupVisibility': {'$in': user.get('groups', [])}},
|
|
||||||
{'visibility': 'public'}
|
|
||||||
]}, {'name': 1, 'path': 1, 'user': 1}).limit(5))
|
|
||||||
proj_users = list(db.users.find({'_id': {'$in': list(map(lambda p:p['user'], projects))}}, {'username': 1, 'avatar': 1}))
|
|
||||||
for proj in projects:
|
|
||||||
for proj_user in proj_users:
|
|
||||||
if proj['user'] == proj_user['_id']:
|
|
||||||
proj['owner'] = proj_user
|
|
||||||
proj['fullName'] = proj_user['username'] + '/' + proj['path']
|
|
||||||
if 'avatar' in proj_user:
|
|
||||||
proj['owner']['avatarUrl'] = uploads.get_presigned_url('users/{0}/{1}'.format(proj_user['_id'], proj_user['avatar']))
|
|
||||||
|
|
||||||
groups = list(db.groups.find({'name': expression, 'unlisted': {'$ne': True}}, {'name': 1, 'closed': 1}).limit(5))
|
|
||||||
|
|
||||||
return {'users': users, 'projects': projects, 'groups': groups}
|
|
||||||
|
|
||||||
def users(user, params):
|
|
||||||
if not params or 'username' not in params: raise util.errors.BadRequest('Username parameter needed')
|
|
||||||
expression = re.compile(params['username'], re.IGNORECASE)
|
|
||||||
db = database.get_db()
|
|
||||||
users = list(db.users.find({'username': expression}, {'username': 1, 'avatar': 1}).limit(5).sort('username', pymongo.ASCENDING))
|
|
||||||
for u in users:
|
|
||||||
if 'avatar' in u:
|
|
||||||
u['avatarUrl'] = uploads.get_presigned_url('users/{0}/{1}'.format(u['_id'], u['avatar']))
|
|
||||||
return {'users': users}
|
|
@ -1,83 +0,0 @@
|
|||||||
import os, time, re
|
|
||||||
from threading import Thread
|
|
||||||
from bson.objectid import ObjectId
|
|
||||||
import boto3
|
|
||||||
from botocore.client import Config
|
|
||||||
import blurhash
|
|
||||||
from chalicelib.util import database
|
|
||||||
|
|
||||||
def sanitise_filename(s):
|
|
||||||
bad_chars = re.compile('[^a-zA-Z0-9_.]')
|
|
||||||
s = bad_chars.sub('_', s)
|
|
||||||
return s
|
|
||||||
|
|
||||||
def get_s3():
|
|
||||||
session = boto3.session.Session()
|
|
||||||
|
|
||||||
s3_client = session.client(
|
|
||||||
service_name='s3',
|
|
||||||
aws_access_key_id=os.environ['AWS_ACCESS_KEY_ID'],
|
|
||||||
aws_secret_access_key=os.environ['AWS_SECRET_ACCESS_KEY'],
|
|
||||||
endpoint_url='https://eu-central-1.linodeobjects.com/',
|
|
||||||
)
|
|
||||||
return s3_client
|
|
||||||
|
|
||||||
def get_presigned_url(path):
|
|
||||||
return 'https://eu-central-1.linodeobjects.com/' + os.environ['AWS_S3_BUCKET'] + '/' + path
|
|
||||||
s3 = get_s3()
|
|
||||||
return s3.generate_presigned_url('get_object',
|
|
||||||
Params = {
|
|
||||||
'Bucket': os.environ['AWS_S3_BUCKET'],
|
|
||||||
'Key': path
|
|
||||||
}
|
|
||||||
)
|
|
||||||
|
|
||||||
def get_file(key):
|
|
||||||
s3 = get_s3()
|
|
||||||
return s3.get_object(
|
|
||||||
Bucket = os.environ['AWS_S3_BUCKET'],
|
|
||||||
Key = key
|
|
||||||
)
|
|
||||||
|
|
||||||
def generate_file_upload_request(user, file_name, file_size, file_type, for_type, for_id):
|
|
||||||
if int(file_size) > (1024 * 1024 * 30): # 30MB
|
|
||||||
raise util.errors.BadRequest('File size is too big')
|
|
||||||
db = database.get_db()
|
|
||||||
allowed = False
|
|
||||||
path = ''
|
|
||||||
if for_type == 'project':
|
|
||||||
project = db.projects.find_one(ObjectId(for_id))
|
|
||||||
allowed = project and project.get('user') == user['_id']
|
|
||||||
path = 'projects/' + for_id + '/'
|
|
||||||
if for_type == 'user':
|
|
||||||
allowed = for_id == str(user['_id'])
|
|
||||||
path = 'users/' + for_id + '/'
|
|
||||||
if for_type == 'group':
|
|
||||||
allowed = ObjectId(for_id) in user.get('groups', [])
|
|
||||||
path = 'groups/' + for_id + '/'
|
|
||||||
if not allowed:
|
|
||||||
raise util.errors.Forbidden('You\'re not allowed to upload this file')
|
|
||||||
|
|
||||||
file_body, file_extension = os.path.splitext(file_name)
|
|
||||||
new_name = sanitise_filename('{0}_{1}{2}'.format(file_body or file_name, int(time.time()), file_extension or ''))
|
|
||||||
s3 = get_s3()
|
|
||||||
signed_url = s3.generate_presigned_url('put_object',
|
|
||||||
Params = {
|
|
||||||
'Bucket': os.environ['AWS_S3_BUCKET'],
|
|
||||||
'Key': path + new_name,
|
|
||||||
'ContentType': file_type
|
|
||||||
}
|
|
||||||
)
|
|
||||||
return {
|
|
||||||
'signedRequest': signed_url,
|
|
||||||
'fileName': new_name
|
|
||||||
}
|
|
||||||
|
|
||||||
def handle_blur_image(key, func):
|
|
||||||
f = get_file(key)['Body']
|
|
||||||
bhash = blurhash.encode(f, x_components=4, y_components=3)
|
|
||||||
func(bhash)
|
|
||||||
|
|
||||||
def blur_image(key, func):
|
|
||||||
thr = Thread(target=handle_blur_image, args=[key, func])
|
|
||||||
thr.start()
|
|
@ -1,83 +0,0 @@
|
|||||||
import datetime
|
|
||||||
from bson.objectid import ObjectId
|
|
||||||
from chalicelib.util import database, util
|
|
||||||
from chalicelib.api import uploads
|
|
||||||
|
|
||||||
def me(user):
|
|
||||||
return {
|
|
||||||
'_id': user['_id'],
|
|
||||||
'username': user['username'],
|
|
||||||
'bio': user.get('bio'),
|
|
||||||
'email': user.get('email'),
|
|
||||||
'avatar': user.get('avatar'),
|
|
||||||
'avatarUrl': user.get('avatar') and uploads.get_presigned_url('users/{0}/{1}'.format(user['_id'], user['avatar'])),
|
|
||||||
'planId': user.get('billing', {}).get('planId'),
|
|
||||||
'roles': user.get('roles', []),
|
|
||||||
'groups': user.get('groups', []),
|
|
||||||
'subscriptions': user.get('subscriptions')
|
|
||||||
}
|
|
||||||
|
|
||||||
def get(user, username):
|
|
||||||
db = database.get_db()
|
|
||||||
fetch_user = db.users.find_one({'username': username}, {'username': 1, 'createdAt': 1, 'avatar': 1, 'avatarBlurHash': 1, 'bio': 1, 'location': 1, 'website': 1, 'twitter': 1, 'facebook': 1, 'linkedIn': 1, 'instagram': 1})
|
|
||||||
if not fetch_user:
|
|
||||||
raise util.errors.NotFound('User not found')
|
|
||||||
project_query = {'user': fetch_user['_id']}
|
|
||||||
if not user or not user['_id'] == fetch_user['_id']:
|
|
||||||
project_query['visibility'] = 'public'
|
|
||||||
|
|
||||||
fetch_user['projects'] = list(db.projects.find(project_query, {'name': 1, 'path': 1, 'description': 1, 'visibility': 1}).limit(15))
|
|
||||||
for project in fetch_user['projects']:
|
|
||||||
project['fullName'] = fetch_user['username'] + '/' + project['path']
|
|
||||||
if 'avatar' in fetch_user:
|
|
||||||
fetch_user['avatarUrl'] = uploads.get_presigned_url('users/{0}/{1}'.format(str(fetch_user['_id']), fetch_user['avatar']))
|
|
||||||
return fetch_user
|
|
||||||
|
|
||||||
def update(user, username, data):
|
|
||||||
if not data: raise util.errors.BadRequest('Invalid request')
|
|
||||||
db = database.get_db()
|
|
||||||
if user['username'] != username:
|
|
||||||
raise util.errors.Forbidden('Not allowed')
|
|
||||||
allowed_keys = ['username', 'avatar', 'bio', 'location', 'website', 'twitter', 'facebook', 'linkedIn', 'instagram']
|
|
||||||
if 'username' in data:
|
|
||||||
if not data.get('username') or len(data['username']) < 3:
|
|
||||||
raise util.errors.BadRequest('New username is not valid')
|
|
||||||
if db.users.find({'username': data['username'].lower()}).count():
|
|
||||||
raise util.errors.BadRequest('A user with this username already exists')
|
|
||||||
data['username'] = data['username'].lower()
|
|
||||||
if 'avatar' in data and len(data['avatar']) > 3: # Not a default avatar
|
|
||||||
def handle_cb(h):
|
|
||||||
db.users.update_one({'_id': user['_id']}, {'$set': {'avatarBlurHash': h}})
|
|
||||||
uploads.blur_image('users/' + str(user['_id']) + '/' + data['avatar'], handle_cb)
|
|
||||||
updater = util.build_updater(data, allowed_keys)
|
|
||||||
if updater:
|
|
||||||
db.users.update({'username': username}, updater)
|
|
||||||
return get(user, data.get('username', username))
|
|
||||||
|
|
||||||
def get_projects(user, id):
|
|
||||||
db = database.get_db()
|
|
||||||
u = db.users.find_one(id, {'username': 1, 'avatar': 1})
|
|
||||||
if not u: raise util.errors.NotFound('User not found')
|
|
||||||
if 'avatar' in u: u['avatarUrl'] = uploads.get_presigned_url('users/{0}/{1}'.format(str(u['_id']), u['avatar']))
|
|
||||||
projects = []
|
|
||||||
for project in db.projects.find({'user': ObjectId(id)}):
|
|
||||||
project['owner'] = u
|
|
||||||
project['fullName'] = u['username'] + '/' + project['path']
|
|
||||||
projects.append(project)
|
|
||||||
return projects
|
|
||||||
|
|
||||||
def create_email_subscription(user, username, subscription):
|
|
||||||
db = database.get_db()
|
|
||||||
if user['username'] != username: raise util.errors.Forbidden('Forbidden')
|
|
||||||
u = db.users.find_one({'username': username})
|
|
||||||
db.users.update({'_id': u['_id']}, {'$addToSet': {'subscriptions.email': subscription}})
|
|
||||||
subs = db.users.find_one(u['_id'], {'subscriptions': 1})
|
|
||||||
return {'subscriptions': subs.get('subscriptions', {})}
|
|
||||||
|
|
||||||
def delete_email_subscription(user, username, subscription):
|
|
||||||
db = database.get_db()
|
|
||||||
if user['username'] != username: raise util.errors.Forbidden('Forbidden')
|
|
||||||
u = db.users.find_one({'username': username})
|
|
||||||
db.users.update({'_id': u['_id']}, {'$pull': {'subscriptions.email': subscription}})
|
|
||||||
subs = db.users.find_one(u['_id'], {'subscriptions': 1})
|
|
||||||
return {'subscriptions': subs.get('subscriptions', {})}
|
|
@ -1,11 +0,0 @@
|
|||||||
import os
|
|
||||||
from pymongo import MongoClient
|
|
||||||
from flask import g
|
|
||||||
|
|
||||||
db = None
|
|
||||||
|
|
||||||
def get_db():
|
|
||||||
global db
|
|
||||||
if not db:
|
|
||||||
db = MongoClient(os.environ['MONGO_URL'])[os.environ['MONGO_DATABASE']]
|
|
||||||
return db
|
|
@ -1,30 +0,0 @@
|
|||||||
import os
|
|
||||||
from threading import Thread
|
|
||||||
import requests
|
|
||||||
|
|
||||||
def handle_send(data):
|
|
||||||
if 'from' not in data:
|
|
||||||
data['from'] = 'Treadl <no_reply@mail.treadl.com>'
|
|
||||||
if 'to_user' in data:
|
|
||||||
user = data['to_user']
|
|
||||||
data['to'] = user['username'] + ' <' + user['email'] + '>'
|
|
||||||
del data['to_user']
|
|
||||||
data['text'] += '\n\nFrom the team at Treadl\n\n\n\n--\n\nDon\'t like this email? Choose which emails you receive from Treadl by visiting https://treadl.com/settings/notifications\n\nReceived this email in error? Please let us know by contacting hello@treadl.com'
|
|
||||||
data['reply-to'] = 'hello@treadl.com'
|
|
||||||
|
|
||||||
base_url = os.environ.get('MAILGUN_URL')
|
|
||||||
api_key = os.environ.get('MAILGUN_KEY')
|
|
||||||
if base_url and api_key:
|
|
||||||
auth = ('api', api_key)
|
|
||||||
try:
|
|
||||||
response = requests.post(base_url, auth=auth, data=data)
|
|
||||||
response.raise_for_status()
|
|
||||||
except:
|
|
||||||
print('Unable to send email')
|
|
||||||
else:
|
|
||||||
print('Not sending email. Message pasted below.')
|
|
||||||
print(data)
|
|
||||||
|
|
||||||
def send(data):
|
|
||||||
thr = Thread(target=handle_send, args=[data])
|
|
||||||
thr.start()
|
|
@ -1,55 +0,0 @@
|
|||||||
from threading import Thread
|
|
||||||
import firebase_admin
|
|
||||||
from firebase_admin import messaging
|
|
||||||
|
|
||||||
default_app = firebase_admin.initialize_app()
|
|
||||||
|
|
||||||
def handle_send_multiple(users, title, body, extra = {}):
|
|
||||||
tokens = []
|
|
||||||
for user in users:
|
|
||||||
if user.get('pushToken'): tokens.append(user['pushToken'])
|
|
||||||
if not tokens: return
|
|
||||||
|
|
||||||
# Create a list containing up to 500 messages.
|
|
||||||
messages = list(map(lambda t: messaging.Message(
|
|
||||||
notification=messaging.Notification(title, body),
|
|
||||||
apns=messaging.APNSConfig(
|
|
||||||
payload=messaging.APNSPayload(
|
|
||||||
aps=messaging.Aps(badge=1, sound='default'),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
token=t,
|
|
||||||
data=extra,
|
|
||||||
), tokens))
|
|
||||||
try:
|
|
||||||
response = messaging.send_all(messages)
|
|
||||||
print('{0} messages were sent successfully'.format(response.success_count))
|
|
||||||
except Exception as e:
|
|
||||||
print('Error sending notification', str(e))
|
|
||||||
|
|
||||||
def send_multiple(users, title, body, extra = {}):
|
|
||||||
thr = Thread(target=handle_send_multiple, args=[users, title, body, extra])
|
|
||||||
thr.start()
|
|
||||||
|
|
||||||
def send_single(user, title, body, extra = {}):
|
|
||||||
token = user.get('pushToken')
|
|
||||||
if not token: return
|
|
||||||
message = messaging.Message(
|
|
||||||
notification=messaging.Notification(
|
|
||||||
title = title,
|
|
||||||
body = body,
|
|
||||||
),
|
|
||||||
apns=messaging.APNSConfig(
|
|
||||||
payload=messaging.APNSPayload(
|
|
||||||
aps=messaging.Aps(badge=1, sound='default'),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
data = extra,
|
|
||||||
token = token,
|
|
||||||
)
|
|
||||||
try:
|
|
||||||
response = messaging.send(message)
|
|
||||||
# Response is a message ID string.
|
|
||||||
print('Successfully sent message:', response)
|
|
||||||
except Exception as e:
|
|
||||||
print('Error sending notification', str(e))
|
|
@ -1,62 +0,0 @@
|
|||||||
import json, datetime
|
|
||||||
import werkzeug
|
|
||||||
from bson.objectid import ObjectId
|
|
||||||
from chalicelib.api import accounts, billing
|
|
||||||
|
|
||||||
errors = werkzeug.exceptions
|
|
||||||
|
|
||||||
def has_plan(user, plan_key):
|
|
||||||
if not user: return False
|
|
||||||
user_billing = user.get('billing', {})
|
|
||||||
if plan_key == 'free':
|
|
||||||
if not user_billing.get('planId'): return True
|
|
||||||
plan_id = None
|
|
||||||
for plan in billing.plans:
|
|
||||||
if plan['key'] == plan_key:
|
|
||||||
plan_id = plan['id']
|
|
||||||
break
|
|
||||||
return user_billing.get('planId') == plan_id
|
|
||||||
|
|
||||||
def can_view_project(user, project):
|
|
||||||
if not project: return False
|
|
||||||
if project.get('visibility') == 'public':
|
|
||||||
return True
|
|
||||||
if not user: return False
|
|
||||||
if project.get('visibility') == 'private' and user['_id'] == project['user']:
|
|
||||||
return True
|
|
||||||
if set(user.get('groups', [])).intersection(project.get('groupVisibility', [])):
|
|
||||||
return True
|
|
||||||
if 'root' in user.get('roles', []): return True
|
|
||||||
return False
|
|
||||||
|
|
||||||
def filter_keys(obj, allowed_keys):
|
|
||||||
filtered = {}
|
|
||||||
for key in allowed_keys:
|
|
||||||
if key in obj:
|
|
||||||
filtered[key] = obj[key]
|
|
||||||
return filtered
|
|
||||||
|
|
||||||
def build_updater(obj, allowed_keys):
|
|
||||||
if not obj: return {}
|
|
||||||
allowed = filter_keys(obj, allowed_keys)
|
|
||||||
updater = {}
|
|
||||||
for key in allowed:
|
|
||||||
if not allowed[key]:
|
|
||||||
if '$unset' not in updater: updater['$unset'] = {}
|
|
||||||
updater['$unset'][key] = ''
|
|
||||||
else:
|
|
||||||
if '$set' not in updater: updater['$set'] = {}
|
|
||||||
updater['$set'][key] = allowed[key]
|
|
||||||
return updater
|
|
||||||
|
|
||||||
|
|
||||||
class MongoJsonEncoder(json.JSONEncoder):
|
|
||||||
def default(self, obj):
|
|
||||||
if isinstance(obj, (datetime.datetime, datetime.date)):
|
|
||||||
return obj.isoformat()
|
|
||||||
elif isinstance(obj, ObjectId):
|
|
||||||
return str(obj)
|
|
||||||
return json.JSONEncoder.default(self, obj)
|
|
||||||
|
|
||||||
def jsonify(*args, **kwargs):
|
|
||||||
return json.dumps(dict(*args, **kwargs), cls=MongoJsonEncoder)
|
|
@ -1,179 +0,0 @@
|
|||||||
import configparser
|
|
||||||
|
|
||||||
def normalise_colour(max_color, triplet):
|
|
||||||
color_factor = 256/max_color
|
|
||||||
components = triplet.split(',')
|
|
||||||
new_components = []
|
|
||||||
for component in components:
|
|
||||||
new_components.append(str(int(float(color_factor) * int(component))))
|
|
||||||
return ','.join(new_components)
|
|
||||||
|
|
||||||
def denormalise_colour(max_color, triplet):
|
|
||||||
color_factor = max_color/256
|
|
||||||
components = triplet.split(',')
|
|
||||||
new_components = []
|
|
||||||
for component in components:
|
|
||||||
new_components.append(str(int(float(color_factor) * int(component))))
|
|
||||||
return ','.join(new_components)
|
|
||||||
|
|
||||||
def get_colour_index(colours, colour):
|
|
||||||
for (index, c) in enumerate(colours):
|
|
||||||
if c == colour: return index + 1
|
|
||||||
return 1
|
|
||||||
|
|
||||||
def dumps(obj):
|
|
||||||
if not obj or not obj['pattern']: raise Exception('Invalid pattern')
|
|
||||||
wif = []
|
|
||||||
|
|
||||||
wif.append('[WIF]')
|
|
||||||
wif.append('Version=1.1')
|
|
||||||
wif.append('Source Program=Treadl')
|
|
||||||
wif.append('Source Version=1')
|
|
||||||
|
|
||||||
wif.append('\n[CONTENTS]')
|
|
||||||
wif.append('COLOR PALETTE=true')
|
|
||||||
wif.append('TEXT=true')
|
|
||||||
wif.append('WEAVING=true')
|
|
||||||
wif.append('WARP=true')
|
|
||||||
wif.append('WARP COLORS=true')
|
|
||||||
wif.append('WEFT COLORS=true')
|
|
||||||
wif.append('WEFT=true')
|
|
||||||
wif.append('COLOR TABLE=true')
|
|
||||||
wif.append('THREADING=true')
|
|
||||||
wif.append('TIEUP=true')
|
|
||||||
wif.append('TREADLING=true')
|
|
||||||
|
|
||||||
wif.append('\n[TEXT]')
|
|
||||||
wif.append('Title={0}'.format(obj['name']))
|
|
||||||
|
|
||||||
wif.append('\n[COLOR TABLE]')
|
|
||||||
for (index, colour) in enumerate(obj['pattern']['colours']):
|
|
||||||
wif.append('{0}={1}'.format(index + 1, denormalise_colour(999, colour)))
|
|
||||||
|
|
||||||
wif.append('\n[COLOR PALETTE]')
|
|
||||||
wif.append('Range=0,999')
|
|
||||||
wif.append('Entries={0}'.format(len(obj['pattern']['colours'])))
|
|
||||||
|
|
||||||
wif.append('\n[WEAVING]')
|
|
||||||
wif.append('Rising Shed=true')
|
|
||||||
wif.append('Treadles={0}'.format(obj['pattern']['weft']['treadles']))
|
|
||||||
wif.append('Shafts={0}'.format(obj['pattern']['warp']['shafts']))
|
|
||||||
|
|
||||||
wif.append('\n[WARP]')
|
|
||||||
wif.append('Units=centimeters')
|
|
||||||
wif.append('Color={0}'.format(get_colour_index(obj['pattern']['colours'], obj['pattern']['warp']['defaultColour'])))
|
|
||||||
wif.append('Threads={0}'.format(obj['pattern']['warp']['threads']))
|
|
||||||
wif.append('Spacing=0.212')
|
|
||||||
wif.append('Thickness=0.212')
|
|
||||||
|
|
||||||
wif.append('\n[WARP COLORS]')
|
|
||||||
for (index, thread) in enumerate(obj['pattern']['warp']['threading']):
|
|
||||||
if 'colour' in thread:
|
|
||||||
wif.append('{0}={1}'.format(index + 1, get_colour_index(obj['pattern']['colours'], thread['colour'])))
|
|
||||||
|
|
||||||
wif.append('\n[THREADING]')
|
|
||||||
for (index, thread) in enumerate(obj['pattern']['warp']['threading']):
|
|
||||||
wif.append('{0}={1}'.format(index + 1, thread['shaft']))
|
|
||||||
|
|
||||||
wif.append('\n[WEFT]')
|
|
||||||
wif.append('Units=centimeters')
|
|
||||||
wif.append('Color={0}'.format(get_colour_index(obj['pattern']['colours'], obj['pattern']['weft']['defaultColour'])))
|
|
||||||
wif.append('Threads={0}'.format(obj['pattern']['weft']['threads']))
|
|
||||||
wif.append('Spacing=0.212')
|
|
||||||
wif.append('Thickness=0.212')
|
|
||||||
|
|
||||||
wif.append('\n[WEFT COLORS]')
|
|
||||||
for (index, thread) in enumerate(obj['pattern']['weft']['treadling']):
|
|
||||||
if 'colour' in thread:
|
|
||||||
wif.append('{0}={1}'.format(index + 1, get_colour_index(obj['pattern']['colours'], thread['colour'])))
|
|
||||||
|
|
||||||
wif.append('\n[TREADLING]')
|
|
||||||
for (index, thread) in enumerate(obj['pattern']['weft']['treadling']):
|
|
||||||
wif.append('{0}={1}'.format(index + 1, thread['treadle']))
|
|
||||||
|
|
||||||
wif.append('\n[TIEUP]')
|
|
||||||
for (index, tieup) in enumerate(obj['pattern']['tieups']):
|
|
||||||
wif.append('{0}={1}'.format(str(index + 1), ','.join(str(x) for x in tieup)))
|
|
||||||
|
|
||||||
return '\n'.join(wif)
|
|
||||||
|
|
||||||
def loads(wif_file):
|
|
||||||
config = configparser.ConfigParser(allow_no_value=True, strict=False)
|
|
||||||
config.read_string(wif_file.lower())
|
|
||||||
draft = {}
|
|
||||||
|
|
||||||
text = config['text']
|
|
||||||
draft['name'] = text.get('title')
|
|
||||||
|
|
||||||
min_color = 0
|
|
||||||
max_color = 255
|
|
||||||
if 'color palette' in config:
|
|
||||||
color_palette = config['color palette']
|
|
||||||
color_range = color_palette.get('range').split(',')
|
|
||||||
min_color = int(color_range[0])
|
|
||||||
max_color = int(color_range[1])
|
|
||||||
|
|
||||||
if 'color table' in config:
|
|
||||||
color_table = config['color table']
|
|
||||||
draft['colours'] = [None]*len(color_table)
|
|
||||||
for x in color_table:
|
|
||||||
draft['colours'][int(x)-1] = normalise_colour(max_color, color_table[x])
|
|
||||||
if not draft.get('colours'): draft['colours'] = []
|
|
||||||
if len(draft['colours']) < 2:
|
|
||||||
draft['colours'] += [normalise_colour(255, '255,255,255'), normalise_colour(255, '0,0,255')]
|
|
||||||
|
|
||||||
weaving = config['weaving']
|
|
||||||
|
|
||||||
threading = config['threading']
|
|
||||||
warp = config['warp']
|
|
||||||
draft['warp'] = {}
|
|
||||||
draft['warp']['shafts'] = weaving.getint('shafts')
|
|
||||||
draft['warp']['threading'] = []
|
|
||||||
draft['warp']['defaultColour'] = draft['colours'][warp.getint('color')-1]
|
|
||||||
warp_colour_index = warp.getint('color') - 1
|
|
||||||
# In case of no color table or colour index out of bounds
|
|
||||||
draft['warp']['defaultColour'] = draft['colours'][warp_colour_index] if len(draft['colours']) > warp_colour_index else draft['colours'][0]
|
|
||||||
for x in threading:
|
|
||||||
while int(x) >= len(draft['warp']['threading']) - 1:
|
|
||||||
draft['warp']['threading'].append({'shaft': 0})
|
|
||||||
draft['warp']['threading'][int(x) - 1] = {'shaft': int(threading[x])}
|
|
||||||
draft['warp']['threads'] = len(draft['warp']['threading'])
|
|
||||||
try:
|
|
||||||
warp_colours = config['warp colors']
|
|
||||||
for x in warp_colours:
|
|
||||||
draft['warp']['threading'][int(x) - 1]['colour'] = draft['colours'][warp_colours.getint(x)-1]
|
|
||||||
except Exception as e:
|
|
||||||
pass
|
|
||||||
|
|
||||||
treadling = config['treadling']
|
|
||||||
weft = config['weft']
|
|
||||||
draft['weft'] = {}
|
|
||||||
draft['weft']['treadles'] = weaving.getint('treadles')
|
|
||||||
draft['weft']['treadling'] = []
|
|
||||||
weft_colour_index = weft.getint('color') - 1
|
|
||||||
# In case of no color table or colour index out of bounds
|
|
||||||
draft['weft']['defaultColour'] = draft['colours'][weft_colour_index] if len(draft['colours']) > weft_colour_index else draft['colours'][1]
|
|
||||||
|
|
||||||
for x in treadling:
|
|
||||||
while int(x) >= len(draft['weft']['treadling']) - 1:
|
|
||||||
draft['weft']['treadling'].append({'treadle': 0})
|
|
||||||
draft['weft']['treadling'][int(x) - 1] = {'treadle': int(treadling[x])}
|
|
||||||
draft['weft']['threads'] = len(draft['weft']['treadling'])
|
|
||||||
try:
|
|
||||||
weft_colours = config['weft colors']
|
|
||||||
for x in weft_colours:
|
|
||||||
draft['weft']['treadling'][int(x) - 1]['colour'] = draft['colours'][weft_colours.getint(x)-1]
|
|
||||||
except: pass
|
|
||||||
|
|
||||||
tieup = config['tieup']
|
|
||||||
draft['tieups'] = []#[0]*len(tieup)
|
|
||||||
for x in tieup:
|
|
||||||
while int(x) >= len(draft['tieups']) - 1:
|
|
||||||
draft['tieups'].append([])
|
|
||||||
split = tieup[x].split(',')
|
|
||||||
try:
|
|
||||||
draft['tieups'][int(x)-1] = [int(i) for i in split]
|
|
||||||
except:
|
|
||||||
draft['tieups'][int(x)-1] = []
|
|
||||||
|
|
||||||
return draft
|
|
@ -1,13 +1,19 @@
|
|||||||
export FLASK_APP="app.py"
|
export FLASK_APP="app.py"
|
||||||
export FLASK_ENV="development"
|
export FLASK_ENV="development"
|
||||||
export FLASK_RUN_PORT="2001"
|
export FLASK_RUN_PORT="2001"
|
||||||
|
export MAILGUN_URL=""
|
||||||
|
export MAILGUN_KEY=""
|
||||||
export MONGO_URL="mongodb://localhost"
|
export MONGO_URL="mongodb://localhost"
|
||||||
export MONGO_DATABASE="weaving"
|
export MONGO_DATABASE="treadl"
|
||||||
export JWT_SECRET="devsecret"
|
export JWT_SECRET="devsecret"
|
||||||
export STRIPE_KEY=""
|
export GOOGLE_APPLICATION_CREDENTIALS="firebase.json"
|
||||||
export STRIPE_PLAN_HOBBYIST=""
|
export AWS_S3_ENDPOINT="https://eu-central-1.linodeobjects.com/"
|
||||||
export STRIPE_PLAN_WEAVER=""
|
export AWS_S3_BUCKET="treadl"
|
||||||
export GOOGLE_APPLICATION_CREDENTIALS="chalicelib/firebase.json",
|
|
||||||
export AWS_S3_BUCKET="treadl-files"
|
|
||||||
export AWS_ACCESS_KEY_ID=""
|
export AWS_ACCESS_KEY_ID=""
|
||||||
export AWS_SECRET_ACCESS_KEY=""
|
export AWS_SECRET_ACCESS_KEY=""
|
||||||
|
export CONTACT_EMAIL="hello@treadl.com"
|
||||||
|
export FROM_EMAIL="no_reply@mail.treadl.com"
|
||||||
|
export ADMIN_EMAIL="hello@treadl.com"
|
||||||
|
export APP_URL="https://www.treadl.com"
|
||||||
|
export APP_DOMAIN="treadl.com"
|
||||||
|
export APP_NAME="Treadl"
|
34
api/migrations/object_previews.py
Normal file
@ -0,0 +1,34 @@
|
|||||||
|
# Script to migrate from the old data: string URLs for images to image files directly on S3.
|
||||||
|
|
||||||
|
from pymongo import MongoClient
|
||||||
|
import base64
|
||||||
|
import os
|
||||||
|
|
||||||
|
db = MongoClient("mongodb://USER:PASS@db/admin")["treadl"]
|
||||||
|
|
||||||
|
os.makedirs("migration_projects/projects", exist_ok=True)
|
||||||
|
|
||||||
|
for obj in db.objects.find(
|
||||||
|
{"preview": {"$regex": "^data:"}}, {"preview": 1, "project": 1}
|
||||||
|
):
|
||||||
|
preview = obj["preview"]
|
||||||
|
preview = preview.replace("data:image/png;base64,", "")
|
||||||
|
|
||||||
|
imgdata = base64.b64decode(preview)
|
||||||
|
filename = "some_image.png"
|
||||||
|
|
||||||
|
os.makedirs("migration_projects/projects/" + str(obj["project"]), exist_ok=True)
|
||||||
|
with open(
|
||||||
|
"migration_projects/projects/"
|
||||||
|
+ str(obj["project"])
|
||||||
|
+ "/preview_"
|
||||||
|
+ str(obj["_id"])
|
||||||
|
+ ".png",
|
||||||
|
"wb",
|
||||||
|
) as f:
|
||||||
|
f.write(imgdata)
|
||||||
|
db.objects.update_one(
|
||||||
|
{"_id": obj["_id"]},
|
||||||
|
{"$set": {"previewNew": "preview_" + str(obj["_id"]) + ".png"}},
|
||||||
|
)
|
||||||
|
# exit()
|
2586
api/poetry.lock
generated
@ -1,30 +1,33 @@
|
|||||||
[tool.poetry]
|
[tool.poetry]
|
||||||
name = "api"
|
name = "api"
|
||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
|
package-mode = false
|
||||||
description = "Treadl API"
|
description = "Treadl API"
|
||||||
authors = ["Will <will@seastorm.co>"]
|
authors = ["Will <will@treadl.com>"]
|
||||||
|
|
||||||
[tool.poetry.dependencies]
|
[tool.poetry.dependencies]
|
||||||
python = "^3.7"
|
python = "^3.12"
|
||||||
flask = "^1.1.1"
|
flask = "^3.0.3"
|
||||||
bcrypt = "^3.1.7"
|
bcrypt = "^4.2.0"
|
||||||
pyjwt = "^1.7.1"
|
pyjwt = "^2.9.0"
|
||||||
boto3 = "^1.10.50"
|
boto3 = "^1.35.34"
|
||||||
flask-cors = "^3.0.8"
|
flask-cors = "^5.0.0"
|
||||||
dnspython = "^1.16.0"
|
dnspython = "^2.6.1"
|
||||||
requests = "^2.22.0"
|
requests = "^2.32.3"
|
||||||
botocore = "^1.13.50"
|
pymongo = "^4.10.1"
|
||||||
pymongo = "^3.10.1"
|
flask_limiter = "^3.8.0"
|
||||||
flask_limiter = "^1.3.1"
|
firebase-admin = "^6.5.0"
|
||||||
werkzeug = "^1.0.1"
|
blurhash-python = "^1.2.2"
|
||||||
firebase-admin = "^4.3.0"
|
gunicorn = "^23.0.0"
|
||||||
chalice = "^1.18.1"
|
sentry-sdk = {extras = ["flask"], version = "^2.15.0"}
|
||||||
blurhash-python = "^1.0.2"
|
pyOpenSSL = "^24.2.1"
|
||||||
gunicorn = "^20.0.4"
|
webargs = "^8.6.0"
|
||||||
stripe = "^2.50.0"
|
|
||||||
|
|
||||||
[tool.poetry.dev-dependencies]
|
[tool.poetry.dev-dependencies]
|
||||||
|
|
||||||
|
[tool.poetry.group.dev.dependencies]
|
||||||
|
ruff = "^0.6.9"
|
||||||
|
|
||||||
[build-system]
|
[build-system]
|
||||||
requires = ["poetry>=0.12"]
|
requires = ["poetry>=0.12"]
|
||||||
build-backend = "poetry.masonry.api"
|
build-backend = "poetry.masonry.api"
|
||||||
|
12
api/util/database.py
Normal file
@ -0,0 +1,12 @@
|
|||||||
|
import os
|
||||||
|
from pymongo import MongoClient
|
||||||
|
|
||||||
|
db = None
|
||||||
|
|
||||||
|
|
||||||
|
def get_db():
|
||||||
|
global db
|
||||||
|
|
||||||
|
if db is None:
|
||||||
|
db = MongoClient(os.environ["MONGO_URL"])[os.environ["MONGO_DATABASE"]]
|
||||||
|
return db
|
40
api/util/mail.py
Normal file
@ -0,0 +1,40 @@
|
|||||||
|
import os
|
||||||
|
from threading import Thread
|
||||||
|
import requests
|
||||||
|
|
||||||
|
|
||||||
|
def handle_send(data):
|
||||||
|
if "from" not in data:
|
||||||
|
data["from"] = "{} <{}>".format(
|
||||||
|
os.environ.get("APP_NAME"), os.environ.get("FROM_EMAIL")
|
||||||
|
)
|
||||||
|
if "to_user" in data:
|
||||||
|
user = data["to_user"]
|
||||||
|
data["to"] = user["username"] + " <" + user["email"] + ">"
|
||||||
|
del data["to_user"]
|
||||||
|
data["text"] += (
|
||||||
|
"\n\nFrom the team at {0}\n\n\n\n--\n\nDon't like this email? Choose which emails you receive from {0} by visiting {1}/settings/notifications\n\nReceived this email in error? Please let us know by contacting {2}".format(
|
||||||
|
os.environ.get("APP_NAME"),
|
||||||
|
os.environ.get("APP_URL"),
|
||||||
|
os.environ.get("CONTACT_EMAIL"),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
data["reply-to"] = os.environ.get("CONTACT_EMAIL")
|
||||||
|
|
||||||
|
base_url = os.environ.get("MAILGUN_URL")
|
||||||
|
api_key = os.environ.get("MAILGUN_KEY")
|
||||||
|
if base_url and api_key:
|
||||||
|
auth = ("api", api_key)
|
||||||
|
try:
|
||||||
|
response = requests.post(base_url, auth=auth, data=data)
|
||||||
|
response.raise_for_status()
|
||||||
|
except Exception:
|
||||||
|
print("Unable to send email")
|
||||||
|
else:
|
||||||
|
print("Not sending email. Message pasted below.")
|
||||||
|
print(data)
|
||||||
|
|
||||||
|
|
||||||
|
def send(data):
|
||||||
|
thr = Thread(target=handle_send, args=[data])
|
||||||
|
thr.start()
|
66
api/util/push.py
Normal file
@ -0,0 +1,66 @@
|
|||||||
|
from threading import Thread
|
||||||
|
import firebase_admin
|
||||||
|
from firebase_admin import messaging
|
||||||
|
|
||||||
|
default_app = firebase_admin.initialize_app()
|
||||||
|
|
||||||
|
|
||||||
|
def handle_send_multiple(users, title, body, extra={}):
|
||||||
|
tokens = []
|
||||||
|
for user in users:
|
||||||
|
if user.get("pushToken"):
|
||||||
|
tokens.append(user["pushToken"])
|
||||||
|
if not tokens:
|
||||||
|
return
|
||||||
|
|
||||||
|
# Create a list containing up to 500 messages.
|
||||||
|
messages = list(
|
||||||
|
map(
|
||||||
|
lambda t: messaging.Message(
|
||||||
|
notification=messaging.Notification(title, body),
|
||||||
|
apns=messaging.APNSConfig(
|
||||||
|
payload=messaging.APNSPayload(
|
||||||
|
aps=messaging.Aps(badge=1, sound="default"),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
token=t,
|
||||||
|
data=extra,
|
||||||
|
),
|
||||||
|
tokens,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
response = messaging.send_all(messages)
|
||||||
|
print("{0} messages were sent successfully".format(response.success_count))
|
||||||
|
except Exception as e:
|
||||||
|
print("Error sending notification", str(e))
|
||||||
|
|
||||||
|
|
||||||
|
def send_multiple(users, title, body, extra={}):
|
||||||
|
thr = Thread(target=handle_send_multiple, args=[users, title, body, extra])
|
||||||
|
thr.start()
|
||||||
|
|
||||||
|
|
||||||
|
def send_single(user, title, body, extra={}):
|
||||||
|
token = user.get("pushToken")
|
||||||
|
if not token:
|
||||||
|
return
|
||||||
|
message = messaging.Message(
|
||||||
|
notification=messaging.Notification(
|
||||||
|
title=title,
|
||||||
|
body=body,
|
||||||
|
),
|
||||||
|
apns=messaging.APNSConfig(
|
||||||
|
payload=messaging.APNSPayload(
|
||||||
|
aps=messaging.Aps(badge=1, sound="default"),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
data=extra,
|
||||||
|
token=token,
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
response = messaging.send(message)
|
||||||
|
# Response is a message ID string.
|
||||||
|
print("Successfully sent message:", response)
|
||||||
|
except Exception as e:
|
||||||
|
print("Error sending notification", str(e))
|
152
api/util/util.py
Normal file
@ -0,0 +1,152 @@
|
|||||||
|
import os
|
||||||
|
import json
|
||||||
|
import datetime
|
||||||
|
from flask import request, Response
|
||||||
|
import werkzeug
|
||||||
|
from flask_limiter.util import get_remote_address
|
||||||
|
from cryptography.hazmat.primitives import serialization
|
||||||
|
from cryptography.hazmat.primitives.asymmetric import rsa
|
||||||
|
from bson.objectid import ObjectId
|
||||||
|
from api import accounts
|
||||||
|
from util import util, mail
|
||||||
|
|
||||||
|
errors = werkzeug.exceptions
|
||||||
|
|
||||||
|
|
||||||
|
def get_user(required=True):
|
||||||
|
headers = request.headers
|
||||||
|
if not headers.get("Authorization") and required:
|
||||||
|
raise util.errors.Unauthorized("This resource requires authentication")
|
||||||
|
if headers.get("Authorization"):
|
||||||
|
user = accounts.get_user_context(
|
||||||
|
headers.get("Authorization").replace("Bearer ", "")
|
||||||
|
)
|
||||||
|
if user is None and required:
|
||||||
|
raise util.errors.Unauthorized("Invalid token")
|
||||||
|
return user
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def limit_by_client():
|
||||||
|
data = request.get_json()
|
||||||
|
if data:
|
||||||
|
if data.get("email"):
|
||||||
|
return data.get("email")
|
||||||
|
if data.get("token"):
|
||||||
|
return data.get("token")
|
||||||
|
return get_remote_address()
|
||||||
|
|
||||||
|
|
||||||
|
def limit_by_user():
|
||||||
|
user = util.get_user(required=False)
|
||||||
|
return user["_id"] if user else get_remote_address()
|
||||||
|
|
||||||
|
|
||||||
|
def is_root(user):
|
||||||
|
return user and "root" in user.get("roles", [])
|
||||||
|
|
||||||
|
|
||||||
|
def can_view_project(user, project):
|
||||||
|
if not project:
|
||||||
|
return False
|
||||||
|
if project.get("visibility") == "public":
|
||||||
|
return True
|
||||||
|
if not user:
|
||||||
|
return False
|
||||||
|
if project.get("visibility") == "private" and can_edit_project(user, project):
|
||||||
|
return True
|
||||||
|
if set(user.get("groups", [])).intersection(project.get("groupVisibility", [])):
|
||||||
|
return True
|
||||||
|
if "root" in user.get("roles", []):
|
||||||
|
return True
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def can_edit_project(user, project):
|
||||||
|
if not user or not project:
|
||||||
|
return False
|
||||||
|
return project.get("user") == user["_id"] or is_root(user)
|
||||||
|
|
||||||
|
|
||||||
|
def filter_keys(obj, allowed_keys):
|
||||||
|
filtered = {}
|
||||||
|
for key in allowed_keys:
|
||||||
|
if key in obj:
|
||||||
|
filtered[key] = obj[key]
|
||||||
|
return filtered
|
||||||
|
|
||||||
|
|
||||||
|
def build_updater(obj, allowed_keys):
|
||||||
|
if not obj:
|
||||||
|
return {}
|
||||||
|
allowed = filter_keys(obj, allowed_keys)
|
||||||
|
updater = {}
|
||||||
|
for key in allowed:
|
||||||
|
if not allowed[key]:
|
||||||
|
if "$unset" not in updater:
|
||||||
|
updater["$unset"] = {}
|
||||||
|
updater["$unset"][key] = ""
|
||||||
|
else:
|
||||||
|
if "$set" not in updater:
|
||||||
|
updater["$set"] = {}
|
||||||
|
updater["$set"][key] = allowed[key]
|
||||||
|
return updater
|
||||||
|
|
||||||
|
|
||||||
|
def send_report_email(report):
|
||||||
|
if not report:
|
||||||
|
return
|
||||||
|
mail.send(
|
||||||
|
{
|
||||||
|
"to": os.environ.get("ADMIN_EMAIL"),
|
||||||
|
"subject": "{} report".format(os.environ.get("APP_NAME")),
|
||||||
|
"text": "A new report has been submitted: {0}".format(
|
||||||
|
json.dumps(report, indent=4)
|
||||||
|
),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def send_moderation_request(from_user, item_type, item):
|
||||||
|
if not from_user or not item_type or not item:
|
||||||
|
return
|
||||||
|
mail.send(
|
||||||
|
{
|
||||||
|
"to": os.environ.get("ADMIN_EMAIL"),
|
||||||
|
"subject": "{} moderation needed".format(os.environ.get("APP_NAME")),
|
||||||
|
"text": "New content has been added by {0} ({1}) and needs moderating: {2} ({3})".format(
|
||||||
|
from_user["username"], from_user["email"], item_type, item["_id"]
|
||||||
|
),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def generate_rsa_keypair():
|
||||||
|
private_key = rsa.generate_private_key(public_exponent=65537, key_size=4096)
|
||||||
|
private_pem = private_key.private_bytes(
|
||||||
|
encoding=serialization.Encoding.PEM,
|
||||||
|
format=serialization.PrivateFormat.PKCS8,
|
||||||
|
encryption_algorithm=serialization.NoEncryption(),
|
||||||
|
)
|
||||||
|
public_key = private_key.public_key()
|
||||||
|
public_pem = public_key.public_bytes(
|
||||||
|
encoding=serialization.Encoding.PEM,
|
||||||
|
format=serialization.PublicFormat.SubjectPublicKeyInfo,
|
||||||
|
)
|
||||||
|
return private_pem, public_pem
|
||||||
|
|
||||||
|
|
||||||
|
class MongoJsonEncoder(json.JSONEncoder):
|
||||||
|
def default(self, obj):
|
||||||
|
if isinstance(obj, (datetime.datetime, datetime.date)):
|
||||||
|
return obj.isoformat()
|
||||||
|
elif isinstance(obj, ObjectId):
|
||||||
|
return str(obj)
|
||||||
|
return json.JSONEncoder.default(self, obj)
|
||||||
|
|
||||||
|
|
||||||
|
def jsonify(*args, **kwargs):
|
||||||
|
resp_data = json.dumps(dict(*args, **kwargs), cls=MongoJsonEncoder)
|
||||||
|
resp = Response(resp_data)
|
||||||
|
resp.headers["Content-Type"] = "application/json"
|
||||||
|
return resp
|
585
api/util/wif.py
Normal file
@ -0,0 +1,585 @@
|
|||||||
|
import io
|
||||||
|
import configparser
|
||||||
|
import time
|
||||||
|
from threading import Thread
|
||||||
|
from PIL import Image, ImageDraw
|
||||||
|
from api import uploads
|
||||||
|
from util import database
|
||||||
|
|
||||||
|
|
||||||
|
def normalise_colour(max_color, triplet):
|
||||||
|
color_factor = 256 / max_color
|
||||||
|
components = triplet.split(",")
|
||||||
|
new_components = []
|
||||||
|
for component in components:
|
||||||
|
new_components.append(str(int(float(color_factor) * int(float(component)))))
|
||||||
|
return ",".join(new_components)
|
||||||
|
|
||||||
|
|
||||||
|
def denormalise_colour(max_color, triplet):
|
||||||
|
color_factor = max_color / 256
|
||||||
|
components = triplet.split(",")
|
||||||
|
new_components = []
|
||||||
|
for component in components:
|
||||||
|
new_components.append(str(int(float(color_factor) * int(component))))
|
||||||
|
return ",".join(new_components)
|
||||||
|
|
||||||
|
|
||||||
|
def colour_tuple(triplet):
|
||||||
|
if not triplet:
|
||||||
|
return None
|
||||||
|
components = triplet.split(",")
|
||||||
|
return tuple(map(lambda c: int(c), components))
|
||||||
|
|
||||||
|
|
||||||
|
def darken_colour(c_tuple, val):
|
||||||
|
def darken(c):
|
||||||
|
c = c * val
|
||||||
|
if c < 0:
|
||||||
|
c = 0
|
||||||
|
if c > 255:
|
||||||
|
c = 255
|
||||||
|
return int(c)
|
||||||
|
|
||||||
|
return tuple(map(darken, c_tuple))
|
||||||
|
|
||||||
|
|
||||||
|
def get_colour_index(colours, colour):
|
||||||
|
for index, c in enumerate(colours):
|
||||||
|
if c == colour:
|
||||||
|
return index + 1
|
||||||
|
return 1
|
||||||
|
|
||||||
|
|
||||||
|
def dumps(obj):
|
||||||
|
if not obj or not obj["pattern"]:
|
||||||
|
raise Exception("Invalid pattern")
|
||||||
|
wif = []
|
||||||
|
|
||||||
|
wif.append("[WIF]")
|
||||||
|
wif.append("Version=1.1")
|
||||||
|
wif.append("Source Program=Treadl")
|
||||||
|
wif.append("Source Version=1")
|
||||||
|
|
||||||
|
wif.append("\n[CONTENTS]")
|
||||||
|
wif.append("COLOR PALETTE=true")
|
||||||
|
wif.append("TEXT=true")
|
||||||
|
wif.append("WEAVING=true")
|
||||||
|
wif.append("WARP=true")
|
||||||
|
wif.append("WARP COLORS=true")
|
||||||
|
wif.append("WEFT COLORS=true")
|
||||||
|
wif.append("WEFT=true")
|
||||||
|
wif.append("COLOR TABLE=true")
|
||||||
|
wif.append("THREADING=true")
|
||||||
|
wif.append("TIEUP=true")
|
||||||
|
wif.append("TREADLING=true")
|
||||||
|
|
||||||
|
wif.append("\n[TEXT]")
|
||||||
|
wif.append("Title={0}".format(obj["name"]))
|
||||||
|
|
||||||
|
wif.append("\n[COLOR TABLE]")
|
||||||
|
for index, colour in enumerate(obj["pattern"]["colours"]):
|
||||||
|
wif.append("{0}={1}".format(index + 1, denormalise_colour(999, colour)))
|
||||||
|
|
||||||
|
wif.append("\n[COLOR PALETTE]")
|
||||||
|
wif.append("Range=0,999")
|
||||||
|
wif.append("Entries={0}".format(len(obj["pattern"]["colours"])))
|
||||||
|
|
||||||
|
wif.append("\n[WEAVING]")
|
||||||
|
wif.append("Rising Shed=true")
|
||||||
|
wif.append("Treadles={0}".format(obj["pattern"]["weft"]["treadles"]))
|
||||||
|
wif.append("Shafts={0}".format(obj["pattern"]["warp"]["shafts"]))
|
||||||
|
|
||||||
|
wif.append("\n[WARP]")
|
||||||
|
wif.append("Units=centimeters")
|
||||||
|
wif.append(
|
||||||
|
"Color={0}".format(
|
||||||
|
get_colour_index(
|
||||||
|
obj["pattern"]["colours"], obj["pattern"]["warp"]["defaultColour"]
|
||||||
|
)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
wif.append("Threads={0}".format(len(obj["pattern"]["warp"]["threading"])))
|
||||||
|
wif.append("Spacing=0.212")
|
||||||
|
wif.append("Thickness=0.212")
|
||||||
|
|
||||||
|
wif.append("\n[WARP COLORS]")
|
||||||
|
for index, thread in enumerate(obj["pattern"]["warp"]["threading"]):
|
||||||
|
if "colour" in thread:
|
||||||
|
wif.append(
|
||||||
|
"{0}={1}".format(
|
||||||
|
index + 1,
|
||||||
|
get_colour_index(obj["pattern"]["colours"], thread["colour"]),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
wif.append("\n[THREADING]")
|
||||||
|
for index, thread in enumerate(obj["pattern"]["warp"]["threading"]):
|
||||||
|
wif.append("{0}={1}".format(index + 1, thread["shaft"]))
|
||||||
|
|
||||||
|
wif.append("\n[WEFT]")
|
||||||
|
wif.append("Units=centimeters")
|
||||||
|
wif.append(
|
||||||
|
"Color={0}".format(
|
||||||
|
get_colour_index(
|
||||||
|
obj["pattern"]["colours"], obj["pattern"]["weft"]["defaultColour"]
|
||||||
|
)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
wif.append("Threads={0}".format(len(obj["pattern"]["weft"]["treadling"])))
|
||||||
|
wif.append("Spacing=0.212")
|
||||||
|
wif.append("Thickness=0.212")
|
||||||
|
|
||||||
|
wif.append("\n[WEFT COLORS]")
|
||||||
|
for index, thread in enumerate(obj["pattern"]["weft"]["treadling"]):
|
||||||
|
if "colour" in thread:
|
||||||
|
wif.append(
|
||||||
|
"{0}={1}".format(
|
||||||
|
index + 1,
|
||||||
|
get_colour_index(obj["pattern"]["colours"], thread["colour"]),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
wif.append("\n[TREADLING]")
|
||||||
|
for index, thread in enumerate(obj["pattern"]["weft"]["treadling"]):
|
||||||
|
wif.append("{0}={1}".format(index + 1, thread["treadle"]))
|
||||||
|
|
||||||
|
wif.append("\n[TIEUP]")
|
||||||
|
for index, tieup in enumerate(obj["pattern"]["tieups"]):
|
||||||
|
wif.append("{0}={1}".format(str(index + 1), ",".join(str(x) for x in tieup)))
|
||||||
|
|
||||||
|
return "\n".join(wif)
|
||||||
|
|
||||||
|
|
||||||
|
def loads(wif_file):
|
||||||
|
# Ensure file exists:
|
||||||
|
if not wif_file or type(wif_file) is not str:
|
||||||
|
raise Exception("Invalid file: null or empty or not string")
|
||||||
|
|
||||||
|
# Some user-uploaded files (Quickdraw?) start with strange HTTP header info.
|
||||||
|
# Remove all preceding non-section lines:
|
||||||
|
wif_file = "[" + wif_file.split("[", 1)[1]
|
||||||
|
|
||||||
|
# Make all section names lowercase
|
||||||
|
normalized_lines = []
|
||||||
|
for line in wif_file.splitlines():
|
||||||
|
if line.strip().startswith("[") and line.strip().endswith("]"):
|
||||||
|
section_name = line.strip()[1:-1].lower()
|
||||||
|
normalized_lines.append(f"[{section_name}]")
|
||||||
|
else:
|
||||||
|
normalized_lines.append(line)
|
||||||
|
wif_file = "\n".join(normalized_lines)
|
||||||
|
|
||||||
|
# Load config
|
||||||
|
config = configparser.ConfigParser(
|
||||||
|
allow_no_value=True, strict=False, inline_comment_prefixes=("#", ";")
|
||||||
|
)
|
||||||
|
config.read_string(wif_file)
|
||||||
|
DEFAULT_TITLE = "Untitled Pattern"
|
||||||
|
draft = {}
|
||||||
|
|
||||||
|
if "wif" in config:
|
||||||
|
draft["wifInfo"] = dict(config["wif"])
|
||||||
|
draft["wifInfo"]["importedFile"] = wif_file
|
||||||
|
if "text" in config:
|
||||||
|
text = config["text"]
|
||||||
|
draft["name"] = text.get("title") or DEFAULT_TITLE
|
||||||
|
if not draft.get("name"):
|
||||||
|
draft["name"] = DEFAULT_TITLE
|
||||||
|
|
||||||
|
max_color = 255
|
||||||
|
if "color palette" in config:
|
||||||
|
color_palette = config["color palette"]
|
||||||
|
color_range = color_palette.get("range").split(",")
|
||||||
|
max_color = int(color_range[1])
|
||||||
|
|
||||||
|
if "color table" in config:
|
||||||
|
color_table = config["color table"]
|
||||||
|
draft["colours"] = [None] * len(color_table)
|
||||||
|
for x in color_table:
|
||||||
|
draft["colours"][int(x) - 1] = normalise_colour(max_color, color_table[x])
|
||||||
|
if not draft.get("colours"):
|
||||||
|
draft["colours"] = []
|
||||||
|
if len(draft["colours"]) < 2:
|
||||||
|
draft["colours"] += [
|
||||||
|
normalise_colour(255, "255,255,255"),
|
||||||
|
normalise_colour(255, "0,0,255"),
|
||||||
|
]
|
||||||
|
|
||||||
|
weaving = config["weaving"] if "weaving" in config else None
|
||||||
|
|
||||||
|
threading = config["threading"] if "threading" in config else []
|
||||||
|
warp = config["warp"] if "warp" in config else None
|
||||||
|
draft["warp"] = {}
|
||||||
|
draft["warp"]["shafts"] = weaving.getint("shafts") if weaving else 0
|
||||||
|
draft["warp"]["threading"] = []
|
||||||
|
|
||||||
|
# Work out default warp colour
|
||||||
|
if warp and warp.get("color"):
|
||||||
|
warp_colour_index = warp.getint("color") - 1
|
||||||
|
if warp_colour_index < len(draft["colours"]):
|
||||||
|
draft["warp"]["defaultColour"] = draft["colours"][warp_colour_index]
|
||||||
|
if not draft.get("warp").get("defaultColour"):
|
||||||
|
# In case of no color table or colour index out of bounds
|
||||||
|
draft["warp"]["defaultColour"] = draft["colours"][0]
|
||||||
|
|
||||||
|
for x in threading:
|
||||||
|
shaft = threading[x].strip()
|
||||||
|
if "," in shaft:
|
||||||
|
shaft = shaft.split(",")[0]
|
||||||
|
shaft = int(shaft) if shaft else 0
|
||||||
|
while int(x) >= len(draft["warp"]["threading"]) - 1:
|
||||||
|
draft["warp"]["threading"].append({"shaft": 0})
|
||||||
|
draft["warp"]["threading"][int(x) - 1] = {"shaft": shaft}
|
||||||
|
if shaft > draft["warp"]["shafts"]:
|
||||||
|
draft["warp"]["shafts"] = shaft
|
||||||
|
draft["warp"]["guideFrequency"] = draft["warp"]["shafts"]
|
||||||
|
try:
|
||||||
|
warp_colours = config["warp colors"]
|
||||||
|
for x in warp_colours:
|
||||||
|
draft["warp"]["threading"][int(x) - 1]["colour"] = draft["colours"][
|
||||||
|
warp_colours.getint(x) - 1
|
||||||
|
]
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
if not draft["warp"]["threading"]: # Make a bunch of empty threads
|
||||||
|
draft["warp"]["threading"] = [{"shaft": 0} for i in range(20)]
|
||||||
|
|
||||||
|
treadling = config["treadling"] if "treadling" in config else []
|
||||||
|
weft = config["weft"] if "weft" in config else None
|
||||||
|
draft["weft"] = {}
|
||||||
|
draft["weft"]["treadles"] = weaving.getint("treadles") if weaving else 0
|
||||||
|
draft["weft"]["treadling"] = []
|
||||||
|
|
||||||
|
# Work out default weft colour
|
||||||
|
if weft and weft.get("color"):
|
||||||
|
weft_colour_index = weft.getint("color") - 1
|
||||||
|
if weft_colour_index < len(draft["colours"]):
|
||||||
|
draft["weft"]["defaultColour"] = draft["colours"][weft_colour_index]
|
||||||
|
if not draft.get("weft").get("defaultColour"):
|
||||||
|
# In case of no color table or colour index out of bounds
|
||||||
|
draft["weft"]["defaultColour"] = draft["colours"][1]
|
||||||
|
|
||||||
|
for x in treadling:
|
||||||
|
treadle = treadling[x].strip()
|
||||||
|
if "," in treadle:
|
||||||
|
treadle = treadle.split(",")[0]
|
||||||
|
treadle = int(treadle) if treadle else 0
|
||||||
|
while int(x) >= len(draft["weft"]["treadling"]) - 1:
|
||||||
|
draft["weft"]["treadling"].append({"treadle": 0})
|
||||||
|
draft["weft"]["treadling"][int(x) - 1] = {"treadle": treadle}
|
||||||
|
if treadle > draft["weft"]["treadles"]:
|
||||||
|
draft["weft"]["treadles"] = treadle
|
||||||
|
draft["weft"]["guideFrequency"] = draft["weft"]["treadles"]
|
||||||
|
try:
|
||||||
|
weft_colours = config["weft colors"]
|
||||||
|
for x in weft_colours:
|
||||||
|
draft["weft"]["treadling"][int(x) - 1]["colour"] = draft["colours"][
|
||||||
|
weft_colours.getint(x) - 1
|
||||||
|
]
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
if not draft["weft"]["treadling"]: # Make a bunch of empty threads
|
||||||
|
draft["weft"]["treadling"] = [{"treadle": 0} for i in range(20)]
|
||||||
|
|
||||||
|
tieup = config["tieup"] if "tieup" in config else None
|
||||||
|
draft["tieups"] = []
|
||||||
|
if tieup:
|
||||||
|
for x in tieup:
|
||||||
|
while int(x) >= len(draft["tieups"]) - 1:
|
||||||
|
draft["tieups"].append([])
|
||||||
|
try:
|
||||||
|
split = tieup[x].split(",")
|
||||||
|
draft["tieups"][int(x) - 1] = [int(i) for i in split]
|
||||||
|
except Exception:
|
||||||
|
draft["tieups"][int(x) - 1] = []
|
||||||
|
|
||||||
|
return draft
|
||||||
|
|
||||||
|
|
||||||
|
def generate_images_thread(obj):
|
||||||
|
preview_image = draw_image(obj)
|
||||||
|
full_preview_image = draw_image(obj, with_plan=True)
|
||||||
|
if preview_image or full_preview_image:
|
||||||
|
db = database.get_db()
|
||||||
|
db.objects.update_one(
|
||||||
|
{"_id": obj["_id"]},
|
||||||
|
{
|
||||||
|
"$set": {
|
||||||
|
"preview": preview_image,
|
||||||
|
"fullPreview": full_preview_image,
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def generate_images(obj):
|
||||||
|
thr = Thread(target=generate_images_thread, args=[obj])
|
||||||
|
thr.start()
|
||||||
|
|
||||||
|
|
||||||
|
def draw_image(obj, with_plan=False):
|
||||||
|
if not obj or not obj["pattern"]:
|
||||||
|
raise Exception("Invalid pattern")
|
||||||
|
BASE_SIZE = 10
|
||||||
|
pattern = obj["pattern"]
|
||||||
|
warp = pattern["warp"]
|
||||||
|
weft = pattern["weft"]
|
||||||
|
tieups = pattern["tieups"]
|
||||||
|
|
||||||
|
full_width = (
|
||||||
|
len(warp["threading"]) * BASE_SIZE
|
||||||
|
+ BASE_SIZE
|
||||||
|
+ weft["treadles"] * BASE_SIZE
|
||||||
|
+ BASE_SIZE
|
||||||
|
if with_plan
|
||||||
|
else len(warp["threading"]) * BASE_SIZE
|
||||||
|
)
|
||||||
|
full_height = (
|
||||||
|
warp["shafts"] * BASE_SIZE + len(weft["treadling"]) * BASE_SIZE + BASE_SIZE * 2
|
||||||
|
if with_plan
|
||||||
|
else len(weft["treadling"]) * BASE_SIZE
|
||||||
|
)
|
||||||
|
|
||||||
|
warp_top = 0
|
||||||
|
warp_left = 0
|
||||||
|
warp_right = len(warp["threading"]) * BASE_SIZE
|
||||||
|
warp_bottom = warp["shafts"] * BASE_SIZE + BASE_SIZE
|
||||||
|
|
||||||
|
weft_left = warp_right + BASE_SIZE
|
||||||
|
weft_top = warp["shafts"] * BASE_SIZE + BASE_SIZE * 2
|
||||||
|
weft_right = warp_right + BASE_SIZE + weft["treadles"] * BASE_SIZE + BASE_SIZE
|
||||||
|
weft_bottom = weft_top + len(weft["treadling"]) * BASE_SIZE
|
||||||
|
|
||||||
|
tieup_left = warp_right + BASE_SIZE
|
||||||
|
tieup_top = BASE_SIZE
|
||||||
|
tieup_right = tieup_left + weft["treadles"] * BASE_SIZE
|
||||||
|
tieup_bottom = warp_bottom
|
||||||
|
|
||||||
|
drawdown_top = warp_bottom + BASE_SIZE if with_plan else 0
|
||||||
|
drawdown_right = warp_right if with_plan else full_width
|
||||||
|
drawdown_left = warp_left if with_plan else 0
|
||||||
|
drawdown_bottom = weft_bottom if with_plan else full_height
|
||||||
|
|
||||||
|
warp_guides = warp.get("guideFrequency") or 0
|
||||||
|
weft_guides = weft.get("guideFrequency") or 0
|
||||||
|
|
||||||
|
WHITE = (255, 255, 255)
|
||||||
|
GREY = (150, 150, 150)
|
||||||
|
BLACK = (0, 0, 0)
|
||||||
|
img = Image.new("RGBA", (full_width, full_height), WHITE)
|
||||||
|
draw = ImageDraw.Draw(img)
|
||||||
|
|
||||||
|
# Draw warp
|
||||||
|
if with_plan:
|
||||||
|
draw.rectangle(
|
||||||
|
[(warp_left, warp_top), (warp_right, warp_bottom)],
|
||||||
|
fill=None,
|
||||||
|
outline=GREY,
|
||||||
|
width=1,
|
||||||
|
)
|
||||||
|
for y in range(1, warp["shafts"] + 1):
|
||||||
|
ycoord = y * BASE_SIZE
|
||||||
|
draw.line(
|
||||||
|
[
|
||||||
|
(warp_left, ycoord),
|
||||||
|
(warp_right, ycoord),
|
||||||
|
],
|
||||||
|
fill=GREY,
|
||||||
|
width=1,
|
||||||
|
joint=None,
|
||||||
|
)
|
||||||
|
col_index = 1
|
||||||
|
for i, x in enumerate(range(len(warp["threading"]) - 1, 0, -1)):
|
||||||
|
is_guide = warp_guides and col_index % warp_guides == 0
|
||||||
|
col_index += 1
|
||||||
|
thread = warp["threading"][i]
|
||||||
|
xcoord = x * BASE_SIZE
|
||||||
|
draw.line(
|
||||||
|
[
|
||||||
|
(xcoord, warp_top),
|
||||||
|
(xcoord, warp_bottom),
|
||||||
|
],
|
||||||
|
fill=BLACK if is_guide else GREY,
|
||||||
|
width=2 if is_guide else 1,
|
||||||
|
joint=None,
|
||||||
|
)
|
||||||
|
if thread.get("shaft", 0) > 0:
|
||||||
|
ycoord = warp_bottom - (thread["shaft"] * BASE_SIZE)
|
||||||
|
draw.rectangle(
|
||||||
|
[(xcoord, ycoord), (xcoord + BASE_SIZE, ycoord + BASE_SIZE)],
|
||||||
|
fill=BLACK,
|
||||||
|
outline=None,
|
||||||
|
width=1,
|
||||||
|
)
|
||||||
|
colour = warp["defaultColour"]
|
||||||
|
if thread and thread.get("colour"):
|
||||||
|
colour = thread["colour"]
|
||||||
|
draw.rectangle(
|
||||||
|
[
|
||||||
|
(xcoord, warp_top),
|
||||||
|
(xcoord + BASE_SIZE, warp_top + BASE_SIZE),
|
||||||
|
],
|
||||||
|
fill=colour_tuple(colour),
|
||||||
|
)
|
||||||
|
|
||||||
|
# Draw weft
|
||||||
|
draw.rectangle(
|
||||||
|
[(weft_left, weft_top), (weft_right, weft_bottom)],
|
||||||
|
fill=None,
|
||||||
|
outline=GREY,
|
||||||
|
width=1,
|
||||||
|
)
|
||||||
|
for x in range(1, weft["treadles"] + 1):
|
||||||
|
xcoord = weft_left + x * BASE_SIZE
|
||||||
|
draw.line(
|
||||||
|
[
|
||||||
|
(xcoord, weft_top),
|
||||||
|
(xcoord, weft_bottom),
|
||||||
|
],
|
||||||
|
fill=GREY,
|
||||||
|
width=1,
|
||||||
|
joint=None,
|
||||||
|
)
|
||||||
|
row_index = 0
|
||||||
|
for i, y in enumerate(range(0, len(weft["treadling"]))):
|
||||||
|
is_guide = weft_guides and row_index % weft_guides == 0
|
||||||
|
row_index += 1
|
||||||
|
thread = weft["treadling"][i]
|
||||||
|
ycoord = weft_top + y * BASE_SIZE
|
||||||
|
draw.line(
|
||||||
|
[
|
||||||
|
(weft_left, ycoord),
|
||||||
|
(weft_right, ycoord),
|
||||||
|
],
|
||||||
|
fill=BLACK if is_guide else GREY,
|
||||||
|
width=2 if is_guide else 1,
|
||||||
|
joint=None,
|
||||||
|
)
|
||||||
|
if thread.get("treadle", 0) > 0:
|
||||||
|
xcoord = weft_left + (thread["treadle"] - 1) * BASE_SIZE
|
||||||
|
draw.rectangle(
|
||||||
|
[(xcoord, ycoord), (xcoord + BASE_SIZE, ycoord + BASE_SIZE)],
|
||||||
|
fill=BLACK,
|
||||||
|
outline=None,
|
||||||
|
width=1,
|
||||||
|
)
|
||||||
|
colour = weft["defaultColour"]
|
||||||
|
if thread and thread.get("colour"):
|
||||||
|
colour = thread["colour"]
|
||||||
|
draw.rectangle(
|
||||||
|
[
|
||||||
|
(weft_right - BASE_SIZE, ycoord),
|
||||||
|
(weft_right, ycoord + BASE_SIZE),
|
||||||
|
],
|
||||||
|
fill=colour_tuple(colour),
|
||||||
|
)
|
||||||
|
|
||||||
|
# Draw tieups
|
||||||
|
draw.rectangle(
|
||||||
|
[(tieup_left, tieup_top), (tieup_right, tieup_bottom)],
|
||||||
|
fill=None,
|
||||||
|
outline=GREY,
|
||||||
|
width=1,
|
||||||
|
)
|
||||||
|
for y in range(1, warp["shafts"] + 1):
|
||||||
|
ycoord = y * BASE_SIZE
|
||||||
|
draw.line(
|
||||||
|
[
|
||||||
|
(tieup_left, ycoord),
|
||||||
|
(tieup_right, ycoord),
|
||||||
|
],
|
||||||
|
fill=GREY,
|
||||||
|
width=1,
|
||||||
|
joint=None,
|
||||||
|
)
|
||||||
|
for x, tieup in enumerate(tieups):
|
||||||
|
xcoord = tieup_left + x * BASE_SIZE
|
||||||
|
draw.line(
|
||||||
|
[
|
||||||
|
(xcoord, tieup_top),
|
||||||
|
(xcoord, tieup_bottom),
|
||||||
|
],
|
||||||
|
fill=GREY,
|
||||||
|
width=1,
|
||||||
|
joint=None,
|
||||||
|
)
|
||||||
|
for entry in tieup:
|
||||||
|
if entry > 0:
|
||||||
|
ycoord = tieup_bottom - (entry * BASE_SIZE)
|
||||||
|
draw.rectangle(
|
||||||
|
[(xcoord, ycoord), (xcoord + BASE_SIZE, ycoord + BASE_SIZE)],
|
||||||
|
fill=BLACK,
|
||||||
|
outline=None,
|
||||||
|
width=1,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Draw drawdown
|
||||||
|
draw.rectangle(
|
||||||
|
[(drawdown_left, drawdown_top), (drawdown_right, drawdown_bottom)],
|
||||||
|
fill=None,
|
||||||
|
outline=(0, 0, 0),
|
||||||
|
width=1,
|
||||||
|
)
|
||||||
|
for y, weft_thread in enumerate(weft["treadling"]):
|
||||||
|
for x, warp_thread in enumerate(warp["threading"]):
|
||||||
|
# Ensure selected treadle and shaft is within configured pattern range
|
||||||
|
treadle = (
|
||||||
|
0
|
||||||
|
if weft_thread["treadle"] > weft["treadles"]
|
||||||
|
else weft_thread["treadle"]
|
||||||
|
)
|
||||||
|
shaft = 0 if warp_thread["shaft"] > warp["shafts"] else warp_thread["shaft"]
|
||||||
|
|
||||||
|
# Work out if should be warp or weft in "front"
|
||||||
|
tieup = (
|
||||||
|
tieups[treadle - 1] if (treadle > 0 and treadle <= len(tieups)) else []
|
||||||
|
)
|
||||||
|
tieup = [t for t in tieup if t <= warp["shafts"]]
|
||||||
|
thread_type = "warp" if shaft in tieup else "weft"
|
||||||
|
|
||||||
|
# Calculate current colour
|
||||||
|
weft_colour = weft_thread.get("colour") or weft.get("defaultColour")
|
||||||
|
warp_colour = warp_thread.get("colour") or warp.get("defaultColour")
|
||||||
|
colour = colour_tuple(warp_colour if thread_type == "warp" else weft_colour)
|
||||||
|
|
||||||
|
# Calculate drawdown coordinates
|
||||||
|
x1 = drawdown_right - (x + 1) * BASE_SIZE
|
||||||
|
x2 = drawdown_right - x * BASE_SIZE
|
||||||
|
y1 = drawdown_top + y * BASE_SIZE
|
||||||
|
y2 = drawdown_top + (y + 1) * BASE_SIZE
|
||||||
|
|
||||||
|
# Draw the thread, with shadow
|
||||||
|
d = [0.6, 0.8, 0.9, 1.1, 1.3, 1.3, 1.1, 0.9, 0.8, 0.6, 0.5]
|
||||||
|
if thread_type == "warp":
|
||||||
|
for i, grad_x in enumerate(range(x1, x2)):
|
||||||
|
draw.line(
|
||||||
|
[
|
||||||
|
(grad_x, y1),
|
||||||
|
(grad_x, y2),
|
||||||
|
],
|
||||||
|
fill=(darken_colour(colour, d[i])),
|
||||||
|
width=1,
|
||||||
|
joint=None,
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
for i, grad_y in enumerate(range(y1, y2)):
|
||||||
|
draw.line(
|
||||||
|
[
|
||||||
|
(x1, grad_y),
|
||||||
|
(x2, grad_y),
|
||||||
|
],
|
||||||
|
fill=(darken_colour(colour, d[i])),
|
||||||
|
width=1,
|
||||||
|
joint=None,
|
||||||
|
)
|
||||||
|
|
||||||
|
in_mem_file = io.BytesIO()
|
||||||
|
img.save(in_mem_file, "PNG")
|
||||||
|
in_mem_file.seek(0)
|
||||||
|
file_name = "preview-{0}_{1}-{2}.png".format(
|
||||||
|
"full" if with_plan else "base", obj["_id"], int(time.time())
|
||||||
|
)
|
||||||
|
path = "projects/{}/{}".format(obj["project"], file_name)
|
||||||
|
uploads.upload_file(path, in_mem_file)
|
||||||
|
return file_name
|
40
docker/Dockerfile
Normal file
@ -0,0 +1,40 @@
|
|||||||
|
# Stage 1: Build React SPA
|
||||||
|
FROM node:20 AS react-build
|
||||||
|
WORKDIR /app
|
||||||
|
COPY web/package.json web/package-lock.json ./
|
||||||
|
RUN npm install
|
||||||
|
COPY web/ ./
|
||||||
|
RUN npx vite build
|
||||||
|
|
||||||
|
# Stage 2: Set up Nginx with React and Flask
|
||||||
|
FROM python:3.12-slim
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
# Install Flask and dependencies
|
||||||
|
RUN pip install poetry
|
||||||
|
COPY api/poetry.lock .
|
||||||
|
COPY api/pyproject.toml .
|
||||||
|
RUN poetry config virtualenvs.create false --local
|
||||||
|
RUN poetry install
|
||||||
|
|
||||||
|
# Copy Flask app
|
||||||
|
COPY api/ ./
|
||||||
|
|
||||||
|
# Install Nginx
|
||||||
|
RUN apt-get update && apt-get install -y nginx && rm -rf /var/lib/apt/lists/*
|
||||||
|
RUN unlink /etc/nginx/sites-enabled/default # Ensure default Nginx configuration is not used
|
||||||
|
|
||||||
|
# Copy React build files into Nginx's static directory
|
||||||
|
COPY --from=react-build /app/dist /usr/share/nginx/html
|
||||||
|
|
||||||
|
# Copy custom Nginx configuration file
|
||||||
|
COPY docker/nginx.conf /etc/nginx/conf.d/default.conf
|
||||||
|
|
||||||
|
# Expose ports for Nginx
|
||||||
|
EXPOSE 80
|
||||||
|
|
||||||
|
# Start both Flask and Nginx using a script
|
||||||
|
COPY docker/start.sh /start.sh
|
||||||
|
RUN chmod +x /start.sh
|
||||||
|
|
||||||
|
CMD ["/start.sh"]
|
33
docker/docker-compose.yml
Normal file
@ -0,0 +1,33 @@
|
|||||||
|
services:
|
||||||
|
treadl:
|
||||||
|
image: wilw/treadl:latest
|
||||||
|
ports:
|
||||||
|
- "8080:80"
|
||||||
|
environment:
|
||||||
|
# App settings
|
||||||
|
- JWT_SECRET=secret # Change this to a secure secret
|
||||||
|
- APP_URL=http://example.com
|
||||||
|
- APP_DOMAIN=example.com
|
||||||
|
- APP_NAME=Treadl
|
||||||
|
|
||||||
|
# MongoDB connection
|
||||||
|
- MONGO_URL=mongodb://mongo:27017/treadl
|
||||||
|
- MONGO_DATABASE=treadl
|
||||||
|
|
||||||
|
# Mailgun email settings
|
||||||
|
- MAILGUN_URL=
|
||||||
|
- MAILGUN_KEY
|
||||||
|
- FROM_EMAIL= # An email address to send emails from
|
||||||
|
|
||||||
|
# Email addresses
|
||||||
|
- CONTACT_EMAIL= # An email address for people to contact you
|
||||||
|
- ADMIN_EMAIL= # An email address for admin notifications
|
||||||
|
|
||||||
|
# S3 storage settings
|
||||||
|
- AWS_S3_ENDPOINT=https://eu-central-1.linodeobjects.com/
|
||||||
|
- AWS_S3_BUCKET=treadl
|
||||||
|
- AWS_ACCESS_KEY_ID=
|
||||||
|
- AWS_SECRET_ACCESS_KEY=
|
||||||
|
|
||||||
|
mongo:
|
||||||
|
image: mongo:6
|
22
docker/nginx.conf
Normal file
@ -0,0 +1,22 @@
|
|||||||
|
server {
|
||||||
|
listen 80;
|
||||||
|
|
||||||
|
# Serve React static files for all non-API routes
|
||||||
|
location / {
|
||||||
|
root /usr/share/nginx/html;
|
||||||
|
index index.html;
|
||||||
|
try_files $uri /index.html;
|
||||||
|
}
|
||||||
|
|
||||||
|
# Proxy API requests to Flask backend
|
||||||
|
location /api/ {
|
||||||
|
proxy_pass http://127.0.0.1:5000/;
|
||||||
|
proxy_set_header Host $host;
|
||||||
|
proxy_set_header X-Real-IP $remote_addr;
|
||||||
|
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||||
|
proxy_set_header X-Forwarded-Proto $scheme;
|
||||||
|
}
|
||||||
|
|
||||||
|
gzip on;
|
||||||
|
gzip_types text/plain application/json text/css application/javascript;
|
||||||
|
}
|
8
docker/start.sh
Normal file
@ -0,0 +1,8 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
|
||||||
|
# Start Flask app in the background
|
||||||
|
gunicorn -b 0.0.0.0:5000 app:app &
|
||||||
|
|
||||||
|
# Start Nginx in the foreground
|
||||||
|
nginx -g "daemon off;"
|
||||||
|
|
46
mobile/.gitignore
vendored
Normal file
@ -0,0 +1,46 @@
|
|||||||
|
# Miscellaneous
|
||||||
|
*.class
|
||||||
|
*.log
|
||||||
|
*.pyc
|
||||||
|
*.swp
|
||||||
|
.DS_Store
|
||||||
|
.atom/
|
||||||
|
.buildlog/
|
||||||
|
.history
|
||||||
|
.svn/
|
||||||
|
|
||||||
|
# IntelliJ related
|
||||||
|
*.iml
|
||||||
|
*.ipr
|
||||||
|
*.iws
|
||||||
|
.idea/
|
||||||
|
|
||||||
|
# The .vscode folder contains launch configuration and tasks you configure in
|
||||||
|
# VS Code which you may wish to be included in version control, so this line
|
||||||
|
# is commented out by default.
|
||||||
|
#.vscode/
|
||||||
|
|
||||||
|
# Flutter/Dart/Pub related
|
||||||
|
**/doc/api/
|
||||||
|
.dart_tool/
|
||||||
|
.flutter-plugins
|
||||||
|
.flutter-plugins-dependencies
|
||||||
|
.packages
|
||||||
|
.pub-cache/
|
||||||
|
.pub/
|
||||||
|
/build/
|
||||||
|
|
||||||
|
# Web related
|
||||||
|
lib/generated_plugin_registrant.dart
|
||||||
|
|
||||||
|
# Symbolication related
|
||||||
|
app.*.symbols
|
||||||
|
|
||||||
|
# Obfuscation related
|
||||||
|
app.*.map.json
|
||||||
|
|
||||||
|
# Exceptions to above rules.
|
||||||
|
!/packages/flutter_tools/test/data/dart_dependencies_test/**/.packages
|
||||||
|
|
||||||
|
android/app/google-services.json
|
||||||
|
android/key.properties
|
36
mobile/.metadata
Normal file
@ -0,0 +1,36 @@
|
|||||||
|
# This file tracks properties of this Flutter project.
|
||||||
|
# Used by Flutter tool to assess capabilities and perform upgrades etc.
|
||||||
|
#
|
||||||
|
# This file should be version controlled and should not be manually edited.
|
||||||
|
|
||||||
|
version:
|
||||||
|
revision: "d211f42860350d914a5ad8102f9ec32764dc6d06"
|
||||||
|
channel: "stable"
|
||||||
|
|
||||||
|
project_type: app
|
||||||
|
|
||||||
|
# Tracks metadata for the flutter migrate command
|
||||||
|
migration:
|
||||||
|
platforms:
|
||||||
|
- platform: root
|
||||||
|
create_revision: d211f42860350d914a5ad8102f9ec32764dc6d06
|
||||||
|
base_revision: d211f42860350d914a5ad8102f9ec32764dc6d06
|
||||||
|
- platform: linux
|
||||||
|
create_revision: d211f42860350d914a5ad8102f9ec32764dc6d06
|
||||||
|
base_revision: d211f42860350d914a5ad8102f9ec32764dc6d06
|
||||||
|
- platform: macos
|
||||||
|
create_revision: d211f42860350d914a5ad8102f9ec32764dc6d06
|
||||||
|
base_revision: d211f42860350d914a5ad8102f9ec32764dc6d06
|
||||||
|
- platform: windows
|
||||||
|
create_revision: d211f42860350d914a5ad8102f9ec32764dc6d06
|
||||||
|
base_revision: d211f42860350d914a5ad8102f9ec32764dc6d06
|
||||||
|
|
||||||
|
# User provided section
|
||||||
|
|
||||||
|
# List of Local paths (relative to this file) that should be
|
||||||
|
# ignored by the migrate tool.
|
||||||
|
#
|
||||||
|
# Files that are not part of the templates will be ignored by default.
|
||||||
|
unmanaged_files:
|
||||||
|
- 'lib/main.dart'
|
||||||
|
- 'ios/Runner.xcodeproj/project.pbxproj'
|
20
mobile/README.md
Normal file
@ -0,0 +1,20 @@
|
|||||||
|
# Treadl Mobile
|
||||||
|
|
||||||
|
The source code for Treadl's iOS and Android application.
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
|
||||||
|
The application is written in Dart using the Flutter framework.
|
||||||
|
|
||||||
|
The mobile app currently supports only a subset of the features of the web app, and is largely useful only for the groups functionality and for adding images to projects when out and about.
|
||||||
|
|
||||||
|
## Start
|
||||||
|
|
||||||
|
Before building, some housekeeping needs to be done first:
|
||||||
|
|
||||||
|
* The `key.properties` file needs to be created in `android/`. This should correctly reference the `key.jks` file
|
||||||
|
* The `android/app/google-services.json` needs to be created with the Firebase configuration
|
||||||
|
|
||||||
|
## Notes
|
||||||
|
|
||||||
|
* Undraw hex code used: #F34E83
|
28
mobile/analysis_options.yaml
Normal file
@ -0,0 +1,28 @@
|
|||||||
|
# This file configures the analyzer, which statically analyzes Dart code to
|
||||||
|
# check for errors, warnings, and lints.
|
||||||
|
#
|
||||||
|
# The issues identified by the analyzer are surfaced in the UI of Dart-enabled
|
||||||
|
# IDEs (https://dart.dev/tools#ides-and-editors). The analyzer can also be
|
||||||
|
# invoked from the command line by running `flutter analyze`.
|
||||||
|
|
||||||
|
# The following line activates a set of recommended lints for Flutter apps,
|
||||||
|
# packages, and plugins designed to encourage good coding practices.
|
||||||
|
include: package:flutter_lints/flutter.yaml
|
||||||
|
|
||||||
|
linter:
|
||||||
|
# The lint rules applied to this project can be customized in the
|
||||||
|
# section below to disable rules from the `package:flutter_lints/flutter.yaml`
|
||||||
|
# included above or to enable additional rules. A list of all available lints
|
||||||
|
# and their documentation is published at https://dart.dev/lints.
|
||||||
|
#
|
||||||
|
# Instead of disabling a lint rule for the entire project in the
|
||||||
|
# section below, it can also be suppressed for a single line of code
|
||||||
|
# or a specific dart file by using the `// ignore: name_of_lint` and
|
||||||
|
# `// ignore_for_file: name_of_lint` syntax on the line or in the file
|
||||||
|
# producing the lint.
|
||||||
|
rules:
|
||||||
|
# avoid_print: false # Uncomment to disable the `avoid_print` rule
|
||||||
|
# prefer_single_quotes: true # Uncomment to enable the `prefer_single_quotes` rule
|
||||||
|
|
||||||
|
# Additional information about this file can be found at
|
||||||
|
# https://dart.dev/guides/language/analysis-options
|
7
mobile/android/.gitignore
vendored
Normal file
@ -0,0 +1,7 @@
|
|||||||
|
gradle-wrapper.jar
|
||||||
|
/.gradle
|
||||||
|
/captures/
|
||||||
|
/gradlew
|
||||||
|
/gradlew.bat
|
||||||
|
/local.properties
|
||||||
|
GeneratedPluginRegistrant.java
|
77
mobile/android/app/build.gradle
Normal file
@ -0,0 +1,77 @@
|
|||||||
|
def localProperties = new Properties()
|
||||||
|
def localPropertiesFile = rootProject.file('local.properties')
|
||||||
|
if (localPropertiesFile.exists()) {
|
||||||
|
localPropertiesFile.withReader('UTF-8') { reader ->
|
||||||
|
localProperties.load(reader)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
def flutterRoot = localProperties.getProperty('flutter.sdk')
|
||||||
|
if (flutterRoot == null) {
|
||||||
|
throw new GradleException("Flutter SDK not found. Define location with flutter.sdk in the local.properties file.")
|
||||||
|
}
|
||||||
|
|
||||||
|
def flutterVersionCode = localProperties.getProperty('flutter.versionCode')
|
||||||
|
if (flutterVersionCode == null) {
|
||||||
|
flutterVersionCode = '1'
|
||||||
|
}
|
||||||
|
|
||||||
|
def flutterVersionName = localProperties.getProperty('flutter.versionName')
|
||||||
|
if (flutterVersionName == null) {
|
||||||
|
flutterVersionName = '1.0'
|
||||||
|
}
|
||||||
|
|
||||||
|
apply plugin: 'com.android.application'
|
||||||
|
apply plugin: 'kotlin-android'
|
||||||
|
apply from: "$flutterRoot/packages/flutter_tools/gradle/flutter.gradle"
|
||||||
|
|
||||||
|
def keystoreProperties = new Properties()
|
||||||
|
def keystorePropertiesFile = rootProject.file('key.properties')
|
||||||
|
if (keystorePropertiesFile.exists()) {
|
||||||
|
keystoreProperties.load(new FileInputStream(keystorePropertiesFile))
|
||||||
|
}
|
||||||
|
|
||||||
|
android {
|
||||||
|
compileSdkVersion 33
|
||||||
|
|
||||||
|
sourceSets {
|
||||||
|
main.java.srcDirs += 'src/main/kotlin'
|
||||||
|
}
|
||||||
|
|
||||||
|
lintOptions {
|
||||||
|
disable 'InvalidPackage'
|
||||||
|
}
|
||||||
|
|
||||||
|
defaultConfig {
|
||||||
|
applicationId "com.treadl"
|
||||||
|
minSdkVersion 29
|
||||||
|
targetSdkVersion 34
|
||||||
|
versionCode flutterVersionCode.toInteger()
|
||||||
|
versionName flutterVersionName
|
||||||
|
}
|
||||||
|
|
||||||
|
signingConfigs {
|
||||||
|
release {
|
||||||
|
keyAlias keystoreProperties['keyAlias']
|
||||||
|
keyPassword keystoreProperties['keyPassword']
|
||||||
|
storeFile keystoreProperties['storeFile'] ? file(keystoreProperties['storeFile']) : null
|
||||||
|
storePassword keystoreProperties['storePassword']
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
buildTypes {
|
||||||
|
release {
|
||||||
|
signingConfig signingConfigs.release
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
flutter {
|
||||||
|
source '../..'
|
||||||
|
}
|
||||||
|
|
||||||
|
dependencies {
|
||||||
|
implementation "org.jetbrains.kotlin:kotlin-stdlib-jdk7:$kotlin_version"
|
||||||
|
}
|
||||||
|
|
||||||
|
apply plugin: 'com.google.gms.google-services'
|
7
mobile/android/app/src/debug/AndroidManifest.xml
Normal file
@ -0,0 +1,7 @@
|
|||||||
|
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
package="com.treadl">
|
||||||
|
<!-- Flutter needs it to communicate with the running application
|
||||||
|
to allow setting breakpoints, to provide hot reload, etc.
|
||||||
|
-->
|
||||||
|
<uses-permission android:name="android.permission.INTERNET"/>
|
||||||
|
</manifest>
|
61
mobile/android/app/src/main/AndroidManifest.xml
Normal file
@ -0,0 +1,61 @@
|
|||||||
|
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
package="com.treadl">
|
||||||
|
<!-- io.flutter.app.FlutterApplication is an android.app.Application that
|
||||||
|
calls FlutterMain.startInitialization(this); in its onCreate method.
|
||||||
|
In most cases you can leave this as-is, but you if you want to provide
|
||||||
|
additional functionality it is fine to subclass or reimplement
|
||||||
|
FlutterApplication and put your custom class here. -->
|
||||||
|
<uses-permission android:name="android.permission.INTERNET" />
|
||||||
|
<application
|
||||||
|
android:name="${applicationName}"
|
||||||
|
android:label="Treadl"
|
||||||
|
android:icon="@mipmap/launcher_icon">
|
||||||
|
<activity
|
||||||
|
android:name=".MainActivity"
|
||||||
|
android:exported="true"
|
||||||
|
android:launchMode="singleTop"
|
||||||
|
android:theme="@style/LaunchTheme"
|
||||||
|
android:configChanges="orientation|keyboardHidden|keyboard|screenSize|smallestScreenSize|locale|layoutDirection|fontScale|screenLayout|density|uiMode"
|
||||||
|
android:hardwareAccelerated="true"
|
||||||
|
android:windowSoftInputMode="adjustResize">
|
||||||
|
<!-- Specifies an Android theme to apply to this Activity as soon as
|
||||||
|
the Android process has started. This theme is visible to the user
|
||||||
|
while the Flutter UI initializes. After that, this theme continues
|
||||||
|
to determine the Window background behind the Flutter UI. -->
|
||||||
|
<meta-data
|
||||||
|
android:name="io.flutter.embedding.android.NormalTheme"
|
||||||
|
android:resource="@style/NormalTheme"
|
||||||
|
/>
|
||||||
|
<!-- Displays an Android View that continues showing the launch screen
|
||||||
|
Drawable until Flutter paints its first frame, then this splash
|
||||||
|
screen fades out. A splash screen is useful to avoid any visual
|
||||||
|
gap between the end of Android's launch screen and the painting of
|
||||||
|
Flutter's first frame. -->
|
||||||
|
<meta-data
|
||||||
|
android:name="io.flutter.embedding.android.SplashScreenDrawable"
|
||||||
|
android:resource="@drawable/launch_background"
|
||||||
|
/>
|
||||||
|
<intent-filter>
|
||||||
|
<action android:name="android.intent.action.MAIN"/>
|
||||||
|
<category android:name="android.intent.category.LAUNCHER"/>
|
||||||
|
</intent-filter>
|
||||||
|
<intent-filter>
|
||||||
|
<action android:name="FLUTTER_NOTIFICATION_CLICK" />
|
||||||
|
<category android:name="android.intent.category.DEFAULT" />
|
||||||
|
</intent-filter>
|
||||||
|
<meta-data android:name="flutter_deeplinking_enabled" android:value="true" />
|
||||||
|
<intent-filter android:autoVerify="true">
|
||||||
|
<action android:name="android.intent.action.VIEW" />
|
||||||
|
<category android:name="android.intent.category.DEFAULT" />
|
||||||
|
<category android:name="android.intent.category.BROWSABLE" />
|
||||||
|
<data android:scheme="http" android:host="treadl.com" />
|
||||||
|
<data android:scheme="https" android:host="treadl.com" />
|
||||||
|
</intent-filter>
|
||||||
|
</activity>
|
||||||
|
<!-- Don't delete the meta-data below.
|
||||||
|
This is used by the Flutter tool to generate GeneratedPluginRegistrant.java -->
|
||||||
|
<meta-data
|
||||||
|
android:name="flutterEmbedding"
|
||||||
|
android:value="2" />
|
||||||
|
</application>
|
||||||
|
</manifest>
|
@ -0,0 +1,25 @@
|
|||||||
|
// Generated file.
|
||||||
|
//
|
||||||
|
// If you wish to remove Flutter's multidex support, delete this entire file.
|
||||||
|
//
|
||||||
|
// Modifications to this file should be done in a copy under a different name
|
||||||
|
// as this file may be regenerated.
|
||||||
|
|
||||||
|
package io.flutter.app;
|
||||||
|
|
||||||
|
import android.app.Application;
|
||||||
|
import android.content.Context;
|
||||||
|
import androidx.annotation.CallSuper;
|
||||||
|
import androidx.multidex.MultiDex;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Extension of {@link android.app.Application}, adding multidex support.
|
||||||
|
*/
|
||||||
|
public class FlutterMultiDexApplication extends Application {
|
||||||
|
@Override
|
||||||
|
@CallSuper
|
||||||
|
protected void attachBaseContext(Context base) {
|
||||||
|
super.attachBaseContext(base);
|
||||||
|
MultiDex.install(this);
|
||||||
|
}
|
||||||
|
}
|
@ -0,0 +1,6 @@
|
|||||||
|
package com.treadl
|
||||||
|
|
||||||
|
import io.flutter.embedding.android.FlutterActivity
|
||||||
|
|
||||||
|
class MainActivity: FlutterActivity() {
|
||||||
|
}
|
@ -0,0 +1,12 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<!-- Modify this file to customize your launch splash screen -->
|
||||||
|
<layer-list xmlns:android="http://schemas.android.com/apk/res/android">
|
||||||
|
<item android:drawable="@android:color/white" />
|
||||||
|
|
||||||
|
<!-- You can insert your own image assets here -->
|
||||||
|
<!-- <item>
|
||||||
|
<bitmap
|
||||||
|
android:gravity="center"
|
||||||
|
android:src="@mipmap/launch_image" />
|
||||||
|
</item> -->
|
||||||
|
</layer-list>
|
BIN
mobile/android/app/src/main/res/mipmap-hdpi/ic_launcher.png
Normal file
After Width: | Height: | Size: 544 B |
BIN
mobile/android/app/src/main/res/mipmap-hdpi/launcher_icon.png
Normal file
After Width: | Height: | Size: 3.2 KiB |
BIN
mobile/android/app/src/main/res/mipmap-mdpi/ic_launcher.png
Normal file
After Width: | Height: | Size: 442 B |
BIN
mobile/android/app/src/main/res/mipmap-mdpi/launcher_icon.png
Normal file
After Width: | Height: | Size: 1.8 KiB |
BIN
mobile/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png
Normal file
After Width: | Height: | Size: 721 B |
BIN
mobile/android/app/src/main/res/mipmap-xhdpi/launcher_icon.png
Normal file
After Width: | Height: | Size: 5.0 KiB |
BIN
mobile/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png
Normal file
After Width: | Height: | Size: 1.0 KiB |
BIN
mobile/android/app/src/main/res/mipmap-xxhdpi/launcher_icon.png
Normal file
After Width: | Height: | Size: 10 KiB |
BIN
mobile/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png
Normal file
After Width: | Height: | Size: 1.4 KiB |
BIN
mobile/android/app/src/main/res/mipmap-xxxhdpi/launcher_icon.png
Normal file
After Width: | Height: | Size: 17 KiB |
18
mobile/android/app/src/main/res/values/styles.xml
Normal file
@ -0,0 +1,18 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<resources>
|
||||||
|
<!-- Theme applied to the Android Window while the process is starting -->
|
||||||
|
<style name="LaunchTheme" parent="@android:style/Theme.Black.NoTitleBar">
|
||||||
|
<!-- Show a splash screen on the activity. Automatically removed when
|
||||||
|
Flutter draws its first frame -->
|
||||||
|
<item name="android:windowBackground">@drawable/launch_background</item>
|
||||||
|
</style>
|
||||||
|
<!-- Theme applied to the Android Window as soon as the process has started.
|
||||||
|
This theme determines the color of the Android Window while your
|
||||||
|
Flutter UI initializes, as well as behind your Flutter UI while its
|
||||||
|
running.
|
||||||
|
|
||||||
|
This Theme is only used starting with V2 of Flutter's Android embedding. -->
|
||||||
|
<style name="NormalTheme" parent="@android:style/Theme.Black.NoTitleBar">
|
||||||
|
<item name="android:windowBackground">@android:color/white</item>
|
||||||
|
</style>
|
||||||
|
</resources>
|
7
mobile/android/app/src/profile/AndroidManifest.xml
Normal file
@ -0,0 +1,7 @@
|
|||||||
|
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
package="com.treadl">
|
||||||
|
<!-- Flutter needs it to communicate with the running application
|
||||||
|
to allow setting breakpoints, to provide hot reload, etc.
|
||||||
|
-->
|
||||||
|
<uses-permission android:name="android.permission.INTERNET"/>
|
||||||
|
</manifest>
|
32
mobile/android/build.gradle
Normal file
@ -0,0 +1,32 @@
|
|||||||
|
buildscript {
|
||||||
|
ext.kotlin_version = '1.8.20'
|
||||||
|
repositories {
|
||||||
|
google()
|
||||||
|
jcenter()
|
||||||
|
}
|
||||||
|
|
||||||
|
dependencies {
|
||||||
|
classpath 'com.android.tools.build:gradle:7.4.1'
|
||||||
|
classpath "org.jetbrains.kotlin:kotlin-gradle-plugin:$kotlin_version"
|
||||||
|
classpath 'com.google.gms:google-services:4.3.3'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
allprojects {
|
||||||
|
repositories {
|
||||||
|
google()
|
||||||
|
jcenter()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
rootProject.buildDir = '../build'
|
||||||
|
subprojects {
|
||||||
|
project.buildDir = "${rootProject.buildDir}/${project.name}"
|
||||||
|
}
|
||||||
|
subprojects {
|
||||||
|
project.evaluationDependsOn(':app')
|
||||||
|
}
|
||||||
|
|
||||||
|
tasks.register("clean", Delete) {
|
||||||
|
delete rootProject.buildDir
|
||||||
|
}
|
5
mobile/android/gradle.properties
Normal file
@ -0,0 +1,5 @@
|
|||||||
|
org.gradle.jvmargs=-Xmx1536M
|
||||||
|
android.enableR8=true
|
||||||
|
android.useAndroidX=true
|
||||||
|
android.enableJetifier=true
|
||||||
|
android.bundle.enableUncompressedNativeLibs=false
|
5
mobile/android/gradle/wrapper/gradle-wrapper.properties
vendored
Normal file
@ -0,0 +1,5 @@
|
|||||||
|
distributionBase=GRADLE_USER_HOME
|
||||||
|
distributionPath=wrapper/dists
|
||||||
|
distributionUrl=https\://services.gradle.org/distributions/gradle-7.5-all.zip
|
||||||
|
zipStoreBase=GRADLE_USER_HOME
|
||||||
|
zipStorePath=wrapper/dists
|
15
mobile/android/settings.gradle
Normal file
@ -0,0 +1,15 @@
|
|||||||
|
include ':app'
|
||||||
|
|
||||||
|
def flutterProjectRoot = rootProject.projectDir.parentFile.toPath()
|
||||||
|
|
||||||
|
def plugins = new Properties()
|
||||||
|
def pluginsFile = new File(flutterProjectRoot.toFile(), '.flutter-plugins')
|
||||||
|
if (pluginsFile.exists()) {
|
||||||
|
pluginsFile.withReader('UTF-8') { reader -> plugins.load(reader) }
|
||||||
|
}
|
||||||
|
|
||||||
|
plugins.each { name, path ->
|
||||||
|
def pluginDirectory = flutterProjectRoot.resolve(path).resolve('android').toFile()
|
||||||
|
include ":$name"
|
||||||
|
project(":$name").projectDir = pluginDirectory
|
||||||
|
}
|
BIN
mobile/assets/avatars/1.png
Normal file
After Width: | Height: | Size: 41 KiB |
BIN
mobile/assets/avatars/10.png
Normal file
After Width: | Height: | Size: 48 KiB |
BIN
mobile/assets/avatars/2.png
Normal file
After Width: | Height: | Size: 55 KiB |
BIN
mobile/assets/avatars/3.png
Normal file
After Width: | Height: | Size: 51 KiB |
BIN
mobile/assets/avatars/4.png
Normal file
After Width: | Height: | Size: 114 KiB |
BIN
mobile/assets/avatars/5.png
Normal file
After Width: | Height: | Size: 40 KiB |
BIN
mobile/assets/avatars/6.png
Normal file
After Width: | Height: | Size: 57 KiB |
BIN
mobile/assets/avatars/7.png
Normal file
After Width: | Height: | Size: 70 KiB |
BIN
mobile/assets/avatars/8.png
Normal file
After Width: | Height: | Size: 47 KiB |
BIN
mobile/assets/avatars/9.png
Normal file
After Width: | Height: | Size: 50 KiB |
BIN
mobile/assets/chat.png
Normal file
After Width: | Height: | Size: 162 KiB |
BIN
mobile/assets/completed.png
Normal file
After Width: | Height: | Size: 69 KiB |
BIN
mobile/assets/empty.png
Normal file
After Width: | Height: | Size: 117 KiB |
BIN
mobile/assets/folder.png
Normal file
After Width: | Height: | Size: 110 KiB |
BIN
mobile/assets/group.png
Normal file
After Width: | Height: | Size: 183 KiB |
BIN
mobile/assets/icon.png
Normal file
After Width: | Height: | Size: 218 KiB |