Compare commits

..

No commits in common. "main" and "cf4308fbfb2129edd6a20646465be114ccb2feb8" have entirely different histories.

418 changed files with 22619 additions and 28219 deletions

View File

@ -1,3 +0,0 @@
api/.venv
web/node_modules
*.pyc

2
.gitignore vendored
View File

@ -1,2 +0,0 @@
*.swp
.DS_Store

View File

@ -1,42 +0,0 @@
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

View File

@ -1,4 +1,4 @@
Copyright (c) 2022 Will Webberley. All rights reserved.
Copyright (c) 2021 Seastorm Limited. All rights reserved.
Redistribution and use in source and binary forms, with or without modification,
are permitted provided that the following conditions are met:

View File

@ -1,96 +1,7 @@
# Treadl
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
```
This is a monorepo containing the code for the web front-end and web API for the Treadl web application.
## Contributions
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.
Contributions to the core project are certainly welcomed. Please [get in touch](https://wilw.dev) for an invitation to join this repository.

View File

@ -1,118 +0,0 @@
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

4
api/.gitignore vendored
View File

@ -6,6 +6,4 @@ __pycache__/
.chalice/venv/
config-prod.yml
envfile
firebase.json
.DS_Store
migration_projects/
firebase.json

View File

@ -1,14 +1,15 @@
FROM amd64/python:3.12-slim
FROM python:3.9-slim-buster
# set work directory
WORKDIR /app
# Install dependencies
RUN pip install poetry
COPY poetry.lock .
COPY pyproject.toml .
RUN poetry config virtualenvs.create false --local
RUN poetry install
COPY poetry.lock /app
COPY pyproject.toml /app
RUN poetry export -f requirements.txt | pip install -r /dev/stdin
# Add remaining files
COPY . /app/

View File

@ -1,3 +1,36 @@
# Treadl web 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.

View File

@ -1,321 +0,0 @@
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"]}

View File

@ -1,190 +0,0 @@
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

View File

@ -1,804 +0,0 @@
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}

View File

@ -1,252 +0,0 @@
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}

View File

@ -1,272 +0,0 @@
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"]}

View File

@ -1,362 +0,0 @@
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")

View File

@ -1,135 +0,0 @@
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}

View File

@ -1,253 +0,0 @@
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}

View File

@ -1,41 +0,0 @@
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"]}

View File

@ -1,108 +0,0 @@
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()

View File

@ -1,355 +0,0 @@
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}

File diff suppressed because it is too large Load Diff

View File

@ -1,18 +0,0 @@
{
"Statement": [
{
"Effect": "Allow",
"Principal": {
"AWS": [
"*"
]
},
"Action": [
"s3:GetObject"
],
"Resource": [
"arn:aws:s3:::treadl-dev/*"
]
}
]
}

View File

@ -11,7 +11,7 @@
"s3:GetObject"
],
"Resource": [
"arn:aws:s3:::treadl/*"
"arn:aws:s3::treadl-files/*"
]
}
]

View File

@ -0,0 +1,188 @@
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']}

View File

@ -0,0 +1,112 @@
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)

View File

@ -0,0 +1,245 @@
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}

View File

@ -0,0 +1,157 @@
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}

View File

@ -0,0 +1,139 @@
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']}

View File

@ -0,0 +1,190 @@
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. It is either invalid or in a format Treadl cannot understand.')
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')

View File

@ -0,0 +1,35 @@
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}

View File

@ -0,0 +1,42 @@
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}

View File

@ -0,0 +1,83 @@
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()

View File

@ -0,0 +1,83 @@
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', {})}

View File

View File

@ -0,0 +1,12 @@
import os
from pymongo import MongoClient
from flask import g
db = None
def get_db():
global db
if db is None:
db = MongoClient(os.environ['MONGO_URL'])[os.environ['MONGO_DATABASE']]
return db

View File

@ -0,0 +1,30 @@
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()

View File

@ -0,0 +1,55 @@
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))

View File

@ -0,0 +1,62 @@
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)

197
api/chalicelib/util/wif.py Normal file
View File

@ -0,0 +1,197 @@
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'] = []
if warp.get('color'):
warp_colour_index = warp.getint('color') - 1
draft['warp']['defaultColour'] = draft['colours'][warp_colour_index]
else:
# 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]
if ',' in shaft:
shaft = shaft.split(",")[0]
shaft = int(shaft)
while int(x) >= len(draft['warp']['threading']) - 1:
draft['warp']['threading'].append({'shaft': 0})
draft['warp']['threading'][int(x) - 1] = {'shaft': shaft}
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'] = []
if weft.get('color'):
weft_colour_index = weft.getint('color') - 1
draft['weft']['defaultColour'] = draft['colours'][weft_colour_index]
else:
# In case of no color table or colour index out of bounds
draft['weft']['defaultColour'] = draft['colours'][1]
for x in treadling:
shaft = treadling[x]
if ',' in shaft:
shaft = shaft.split(",")[0]
shaft = int(shaft)
while int(x) >= len(draft['weft']['treadling']) - 1:
draft['weft']['treadling'].append({'treadle': 0})
draft['weft']['treadling'][int(x) - 1] = {'treadle': shaft}
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

View File

@ -1,19 +1,13 @@
export FLASK_APP="app.py"
export FLASK_ENV="development"
export FLASK_RUN_PORT="2001"
export MAILGUN_URL=""
export MAILGUN_KEY=""
export MONGO_URL="mongodb://localhost"
export MONGO_DATABASE="treadl"
export MONGO_DATABASE="weaving"
export JWT_SECRET="devsecret"
export GOOGLE_APPLICATION_CREDENTIALS="firebase.json"
export AWS_S3_ENDPOINT="https://eu-central-1.linodeobjects.com/"
export AWS_S3_BUCKET="treadl"
export STRIPE_KEY=""
export STRIPE_PLAN_HOBBYIST=""
export STRIPE_PLAN_WEAVER=""
export GOOGLE_APPLICATION_CREDENTIALS="chalicelib/firebase.json",
export AWS_S3_BUCKET="treadl-files"
export AWS_ACCESS_KEY_ID=""
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"

View File

@ -1,34 +0,0 @@
# 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()

2562
api/poetry.lock generated

File diff suppressed because it is too large Load Diff

View File

@ -1,33 +1,30 @@
[tool.poetry]
name = "api"
version = "0.1.0"
package-mode = false
description = "Treadl API"
authors = ["Will <will@treadl.com>"]
authors = ["Will <will@seastorm.co>"]
[tool.poetry.dependencies]
python = "^3.12"
flask = "^3.0.3"
bcrypt = "^4.2.0"
pyjwt = "^2.9.0"
boto3 = "^1.35.34"
flask-cors = "^5.0.0"
dnspython = "^2.6.1"
requests = "^2.32.3"
pymongo = "^4.10.1"
flask_limiter = "^3.8.0"
firebase-admin = "^6.5.0"
blurhash-python = "^1.2.2"
gunicorn = "^23.0.0"
sentry-sdk = {extras = ["flask"], version = "^2.15.0"}
pyOpenSSL = "^24.2.1"
webargs = "^8.6.0"
python = "^3.9"
flask = "^1.1.1"
bcrypt = "^3.1.7"
pyjwt = "^1.7.1"
boto3 = "^1.10.50"
flask-cors = "^3.0.8"
dnspython = "^1.16.0"
requests = "^2.22.0"
botocore = "^1.13.50"
pymongo = "^3.10.1"
flask_limiter = "^1.3.1"
werkzeug = "^1.0.1"
firebase-admin = "^4.3.0"
chalice = "^1.18.1"
blurhash-python = "^1.0.2"
gunicorn = "^20.0.4"
stripe = "^2.50.0"
[tool.poetry.dev-dependencies]
[tool.poetry.group.dev.dependencies]
ruff = "^0.6.9"
[build-system]
requires = ["poetry>=0.12"]
build-backend = "poetry.masonry.api"

View File

@ -1,12 +0,0 @@
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

View File

@ -1,40 +0,0 @@
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()

View File

@ -1,66 +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))

View File

@ -1,152 +0,0 @@
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

View File

@ -1,585 +0,0 @@
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

View File

@ -1,40 +0,0 @@
# 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"]

View File

@ -1,33 +0,0 @@
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

View File

@ -1,22 +0,0 @@
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;
}

View File

@ -1,8 +0,0 @@
#!/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;"

View File

@ -4,33 +4,7 @@
# This file should be version controlled and should not be manually edited.
version:
revision: "d211f42860350d914a5ad8102f9ec32764dc6d06"
channel: "stable"
revision: e6b34c2b5c96bb95325269a29a84e83ed8909b5f
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'

View File

@ -6,7 +6,7 @@ The source code for Treadl's iOS and Android application.
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.
The mobile app currently supports only a subset of the features of the web app, and is largely useful only for the groups functionality.
## Start

View File

@ -1,28 +0,0 @@
# 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

View File

@ -32,7 +32,7 @@ if (keystorePropertiesFile.exists()) {
}
android {
compileSdkVersion 33
compileSdkVersion 28
sourceSets {
main.java.srcDirs += 'src/main/kotlin'
@ -44,8 +44,8 @@ android {
defaultConfig {
applicationId "com.treadl"
minSdkVersion 29
targetSdkVersion 34
minSdkVersion 16
targetSdkVersion 29
versionCode flutterVersionCode.toInteger()
versionName flutterVersionName
}

View File

@ -12,7 +12,6 @@
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"
@ -42,15 +41,7 @@
<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>
</intent-filter>
</activity>
<!-- Don't delete the meta-data below.
This is used by the Flutter tool to generate GeneratedPluginRegistrant.java -->

View File

@ -1,25 +0,0 @@
// 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);
}
}

View File

@ -1,12 +1,12 @@
buildscript {
ext.kotlin_version = '1.8.20'
ext.kotlin_version = '1.3.50'
repositories {
google()
jcenter()
}
dependencies {
classpath 'com.android.tools.build:gradle:7.4.1'
classpath 'com.android.tools.build:gradle:3.5.0'
classpath "org.jetbrains.kotlin:kotlin-gradle-plugin:$kotlin_version"
classpath 'com.google.gms:google-services:4.3.3'
}
@ -27,6 +27,6 @@ subprojects {
project.evaluationDependsOn(':app')
}
tasks.register("clean", Delete) {
task clean(type: Delete) {
delete rootProject.buildDir
}

View File

@ -1,5 +1,6 @@
#Fri Jun 23 08:50:38 CEST 2017
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
distributionUrl=https\://services.gradle.org/distributions/gradle-5.6.2-all.zip

Binary file not shown.

Before

Width:  |  Height:  |  Size: 56 KiB

View File

@ -21,6 +21,6 @@
<key>CFBundleVersion</key>
<string>1.0</string>
<key>MinimumOSVersion</key>
<string>11.0</string>
<string>9.0</string>
</dict>
</plist>

View File

@ -1,5 +1,5 @@
# Uncomment this line to define a global platform for your project
# platform :ios, '11.0'
# platform :ios, '9.0'
# CocoaPods analytics sends network stats synchronously affecting flutter build latency.
ENV['COCOAPODS_DISABLE_STATS'] = 'true'

View File

@ -1,186 +1,153 @@
PODS:
- DKImagePickerController/Core (4.3.4):
- DKImagePickerController/ImageDataManager
- DKImagePickerController/Resource
- DKImagePickerController/ImageDataManager (4.3.4)
- DKImagePickerController/PhotoGallery (4.3.4):
- DKImagePickerController/Core
- DKPhotoGallery
- DKImagePickerController/Resource (4.3.4)
- DKPhotoGallery (0.0.17):
- DKPhotoGallery/Core (= 0.0.17)
- DKPhotoGallery/Model (= 0.0.17)
- DKPhotoGallery/Preview (= 0.0.17)
- DKPhotoGallery/Resource (= 0.0.17)
- SDWebImage
- SwiftyGif
- DKPhotoGallery/Core (0.0.17):
- DKPhotoGallery/Model
- DKPhotoGallery/Preview
- SDWebImage
- SwiftyGif
- DKPhotoGallery/Model (0.0.17):
- SDWebImage
- SwiftyGif
- DKPhotoGallery/Preview (0.0.17):
- DKPhotoGallery/Model
- DKPhotoGallery/Resource
- SDWebImage
- SwiftyGif
- DKPhotoGallery/Resource (0.0.17):
- SDWebImage
- SwiftyGif
- file_picker (0.0.1):
- DKImagePickerController/PhotoGallery
- Flutter
- Firebase/CoreOnly (10.9.0):
- FirebaseCore (= 10.9.0)
- Firebase/Messaging (10.9.0):
- Firebase/CoreOnly (8.11.0):
- FirebaseCore (= 8.11.0)
- Firebase/Messaging (8.11.0):
- Firebase/CoreOnly
- FirebaseMessaging (~> 10.9.0)
- firebase_core (2.13.1):
- Firebase/CoreOnly (= 10.9.0)
- FirebaseMessaging (~> 8.11.0)
- firebase_core (1.13.1):
- Firebase/CoreOnly (= 8.11.0)
- Flutter
- firebase_messaging (14.6.2):
- Firebase/Messaging (= 10.9.0)
- firebase_messaging (11.2.8):
- Firebase/Messaging (= 8.11.0)
- firebase_core
- Flutter
- FirebaseCore (10.9.0):
- FirebaseCoreInternal (~> 10.0)
- GoogleUtilities/Environment (~> 7.8)
- GoogleUtilities/Logger (~> 7.8)
- FirebaseCoreInternal (10.10.0):
- "GoogleUtilities/NSData+zlib (~> 7.8)"
- FirebaseInstallations (10.10.0):
- FirebaseCore (~> 10.0)
- GoogleUtilities/Environment (~> 7.8)
- GoogleUtilities/UserDefaults (~> 7.8)
- PromisesObjC (~> 2.1)
- FirebaseMessaging (10.9.0):
- FirebaseCore (~> 10.0)
- FirebaseInstallations (~> 10.0)
- GoogleDataTransport (~> 9.2)
- GoogleUtilities/AppDelegateSwizzler (~> 7.8)
- GoogleUtilities/Environment (~> 7.8)
- GoogleUtilities/Reachability (~> 7.8)
- GoogleUtilities/UserDefaults (~> 7.8)
- nanopb (< 2.30910.0, >= 2.30908.0)
- Flutter (1.0.0)
- GoogleDataTransport (9.2.3):
- FirebaseCore (8.11.0):
- FirebaseCoreDiagnostics (~> 8.0)
- GoogleUtilities/Environment (~> 7.7)
- nanopb (< 2.30910.0, >= 2.30908.0)
- GoogleUtilities/Logger (~> 7.7)
- FirebaseCoreDiagnostics (8.12.0):
- GoogleDataTransport (~> 9.1)
- GoogleUtilities/Environment (~> 7.7)
- GoogleUtilities/Logger (~> 7.7)
- nanopb (~> 2.30908.0)
- FirebaseInstallations (8.12.0):
- FirebaseCore (~> 8.0)
- GoogleUtilities/Environment (~> 7.7)
- GoogleUtilities/UserDefaults (~> 7.7)
- PromisesObjC (< 3.0, >= 1.2)
- GoogleUtilities/AppDelegateSwizzler (7.11.1):
- FirebaseMessaging (8.11.0):
- FirebaseCore (~> 8.0)
- FirebaseInstallations (~> 8.0)
- GoogleDataTransport (~> 9.1)
- GoogleUtilities/AppDelegateSwizzler (~> 7.7)
- GoogleUtilities/Environment (~> 7.7)
- GoogleUtilities/Reachability (~> 7.7)
- GoogleUtilities/UserDefaults (~> 7.7)
- nanopb (~> 2.30908.0)
- Flutter (1.0.0)
- fluttertoast (0.0.2):
- Flutter
- Toast
- GoogleDataTransport (9.1.2):
- GoogleUtilities/Environment (~> 7.2)
- nanopb (~> 2.30908.0)
- PromisesObjC (< 3.0, >= 1.2)
- GoogleUtilities/AppDelegateSwizzler (7.7.0):
- GoogleUtilities/Environment
- GoogleUtilities/Logger
- GoogleUtilities/Network
- GoogleUtilities/Environment (7.11.1):
- GoogleUtilities/Environment (7.7.0):
- PromisesObjC (< 3.0, >= 1.2)
- GoogleUtilities/Logger (7.11.1):
- GoogleUtilities/Logger (7.7.0):
- GoogleUtilities/Environment
- GoogleUtilities/Network (7.11.1):
- GoogleUtilities/Network (7.7.0):
- GoogleUtilities/Logger
- "GoogleUtilities/NSData+zlib"
- GoogleUtilities/Reachability
- "GoogleUtilities/NSData+zlib (7.11.1)"
- GoogleUtilities/Reachability (7.11.1):
- "GoogleUtilities/NSData+zlib (7.7.0)"
- GoogleUtilities/Reachability (7.7.0):
- GoogleUtilities/Logger
- GoogleUtilities/UserDefaults (7.11.1):
- GoogleUtilities/UserDefaults (7.7.0):
- GoogleUtilities/Logger
- image_picker_ios (0.0.1):
- image_picker (0.0.1):
- Flutter
- nanopb (2.30909.0):
- nanopb/decode (= 2.30909.0)
- nanopb/encode (= 2.30909.0)
- nanopb/decode (2.30909.0)
- nanopb/encode (2.30909.0)
- path_provider_foundation (0.0.1):
- nanopb (2.30908.0):
- nanopb/decode (= 2.30908.0)
- nanopb/encode (= 2.30908.0)
- nanopb/decode (2.30908.0)
- nanopb/encode (2.30908.0)
- PromisesObjC (2.0.0)
- shared_preferences_ios (0.0.1):
- Flutter
- FlutterMacOS
- PromisesObjC (2.2.0)
- SDWebImage (5.18.8):
- SDWebImage/Core (= 5.18.8)
- SDWebImage/Core (5.18.8)
- share_plus (0.0.1):
- Flutter
- shared_preferences_foundation (0.0.1):
- Flutter
- FlutterMacOS
- SwiftyGif (5.4.4)
- Toast (4.0.0)
- url_launcher_ios (0.0.1):
- Flutter
- video_player_avfoundation (0.0.1):
- Flutter
- wakelock (0.0.1):
- Flutter
- webview_flutter_wkwebview (0.0.1):
- Flutter
DEPENDENCIES:
- file_picker (from `.symlinks/plugins/file_picker/ios`)
- firebase_core (from `.symlinks/plugins/firebase_core/ios`)
- firebase_messaging (from `.symlinks/plugins/firebase_messaging/ios`)
- Flutter (from `Flutter`)
- image_picker_ios (from `.symlinks/plugins/image_picker_ios/ios`)
- path_provider_foundation (from `.symlinks/plugins/path_provider_foundation/darwin`)
- share_plus (from `.symlinks/plugins/share_plus/ios`)
- shared_preferences_foundation (from `.symlinks/plugins/shared_preferences_foundation/darwin`)
- fluttertoast (from `.symlinks/plugins/fluttertoast/ios`)
- image_picker (from `.symlinks/plugins/image_picker/ios`)
- shared_preferences_ios (from `.symlinks/plugins/shared_preferences_ios/ios`)
- url_launcher_ios (from `.symlinks/plugins/url_launcher_ios/ios`)
- video_player_avfoundation (from `.symlinks/plugins/video_player_avfoundation/ios`)
- wakelock (from `.symlinks/plugins/wakelock/ios`)
- webview_flutter_wkwebview (from `.symlinks/plugins/webview_flutter_wkwebview/ios`)
SPEC REPOS:
trunk:
- DKImagePickerController
- DKPhotoGallery
- Firebase
- FirebaseCore
- FirebaseCoreInternal
- FirebaseCoreDiagnostics
- FirebaseInstallations
- FirebaseMessaging
- GoogleDataTransport
- GoogleUtilities
- nanopb
- PromisesObjC
- SDWebImage
- SwiftyGif
- Toast
EXTERNAL SOURCES:
file_picker:
:path: ".symlinks/plugins/file_picker/ios"
firebase_core:
:path: ".symlinks/plugins/firebase_core/ios"
firebase_messaging:
:path: ".symlinks/plugins/firebase_messaging/ios"
Flutter:
:path: Flutter
image_picker_ios:
:path: ".symlinks/plugins/image_picker_ios/ios"
path_provider_foundation:
:path: ".symlinks/plugins/path_provider_foundation/darwin"
share_plus:
:path: ".symlinks/plugins/share_plus/ios"
shared_preferences_foundation:
:path: ".symlinks/plugins/shared_preferences_foundation/darwin"
fluttertoast:
:path: ".symlinks/plugins/fluttertoast/ios"
image_picker:
:path: ".symlinks/plugins/image_picker/ios"
shared_preferences_ios:
:path: ".symlinks/plugins/shared_preferences_ios/ios"
url_launcher_ios:
:path: ".symlinks/plugins/url_launcher_ios/ios"
video_player_avfoundation:
:path: ".symlinks/plugins/video_player_avfoundation/ios"
wakelock:
:path: ".symlinks/plugins/wakelock/ios"
webview_flutter_wkwebview:
:path: ".symlinks/plugins/webview_flutter_wkwebview/ios"
SPEC CHECKSUMS:
DKImagePickerController: b512c28220a2b8ac7419f21c491fc8534b7601ac
DKPhotoGallery: fdfad5125a9fdda9cc57df834d49df790dbb4179
file_picker: 15fd9539e4eb735dc54bae8c0534a7a9511a03de
Firebase: bd152f0f3d278c4060c5c71359db08ebcfd5a3e2
firebase_core: ce64b0941c6d87c6ef5022ae9116a158236c8c94
firebase_messaging: 42912365e62efc1ea3e00724e5eecba6068ddb88
FirebaseCore: b68d3616526ec02e4d155166bbafb8eca64af557
FirebaseCoreInternal: 971029061d326000d65bfdc21f5502c75c8b0893
FirebaseInstallations: 52153982b057d3afcb4e1fbb3eb0b6d00611e681
FirebaseMessaging: 6b7052cc3da7bc8e5f72bef871243e8f04a14eed
Flutter: f04841e97a9d0b0a8025694d0796dd46242b2854
GoogleDataTransport: f0308f5905a745f94fb91fea9c6cbaf3831cb1bd
GoogleUtilities: 9aa0ad5a7bc171f8bae016300bfcfa3fb8425749
image_picker_ios: 4a8aadfbb6dc30ad5141a2ce3832af9214a705b5
nanopb: b552cce312b6c8484180ef47159bc0f65a1f0431
path_provider_foundation: 29f094ae23ebbca9d3d0cec13889cd9060c0e943
PromisesObjC: 09985d6d70fbe7878040aa746d78236e6946d2ef
SDWebImage: a81bbb3ba4ea5f810f4069c68727cb118467a04a
share_plus: c3fef564749587fc939ef86ffb283ceac0baf9f5
shared_preferences_foundation: e2dae3258e06f44cc55f49d42024fd8dd03c590c
SwiftyGif: 93a1cc87bf3a51916001cf8f3d63835fb64c819f
url_launcher_ios: 08a3dfac5fb39e8759aeb0abbd5d9480f30fc8b4
Firebase: 44dd9724c84df18b486639e874f31436eaa9a20c
firebase_core: 08f6a85f62060111de5e98d6a214810d11365de9
firebase_messaging: 36238f3d0b933af8c919aef608408aae06ba22e8
FirebaseCore: 2f4f85b453cc8fea4bb2b37e370007d2bcafe3f0
FirebaseCoreDiagnostics: 3b40dfadef5b90433a60ae01f01e90fe87aa76aa
FirebaseInstallations: 25764cf322e77f99449395870a65b2bef88e1545
FirebaseMessaging: 02e248e8997f71fa8cc9d78e9d49ec1a701ba14a
Flutter: 50d75fe2f02b26cc09d224853bb45737f8b3214a
fluttertoast: 16fbe6039d06a763f3533670197d01fc73459037
GoogleDataTransport: 629c20a4d363167143f30ea78320d5a7eb8bd940
GoogleUtilities: e0913149f6b0625b553d70dae12b49fc62914fd1
image_picker: 541dcbb3b9cf32d87eacbd957845d8651d6c62c3
nanopb: a0ba3315591a9ae0a16a309ee504766e90db0c96
PromisesObjC: 68159ce6952d93e17b2dfe273b8c40907db5ba58
shared_preferences_ios: 548a61f8053b9b8a49ac19c1ffbc8b92c50d68ad
Toast: 91b396c56ee72a5790816f40d3a94dd357abc196
url_launcher_ios: 839c58cdb4279282219f5e248c3321761ff3c4de
video_player_avfoundation: e489aac24ef5cf7af82702979ed16f2a5ef84cff
wakelock: d0fc7c864128eac40eba1617cb5264d9c940b46f
webview_flutter_wkwebview: 005fbd90c888a42c5690919a1527ecc6649e1162
PODFILE CHECKSUM: ef19549a9bc3046e7bb7d2fab4d021637c0c58a3
PODFILE CHECKSUM: aafe91acc616949ddb318b77800a7f51bffa2a4c
COCOAPODS: 1.14.2
COCOAPODS: 1.10.1

View File

@ -3,7 +3,7 @@
archiveVersion = 1;
classes = {
};
objectVersion = 54;
objectVersion = 51;
objects = {
/* Begin PBXBuildFile section */
@ -48,7 +48,6 @@
97C147001CF9000F007C117D /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; name = Base; path = Base.lproj/LaunchScreen.storyboard; sourceTree = "<group>"; };
97C147021CF9000F007C117D /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = "<group>"; };
9C430D344D81D00E4F8BC572 /* Pods_Runner.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_Runner.framework; sourceTree = BUILT_PRODUCTS_DIR; };
BE18F7F22B54707500363B2E /* Runner.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = Runner.entitlements; sourceTree = "<group>"; };
BE6C8E7324CDE9B20018AD10 /* RunnerDebug.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = RunnerDebug.entitlements; sourceTree = "<group>"; };
BEA6727A24CCAF5600BBF836 /* RunnerRelease.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = RunnerRelease.entitlements; sourceTree = "<group>"; };
BEA6727B24CCB04900BBF836 /* GoogleService-Info.plist */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.xml; path = "GoogleService-Info.plist"; sourceTree = "<group>"; };
@ -117,7 +116,6 @@
97C146F01CF9000F007C117D /* Runner */ = {
isa = PBXGroup;
children = (
BE18F7F22B54707500363B2E /* Runner.entitlements */,
BE6C8E7324CDE9B20018AD10 /* RunnerDebug.entitlements */,
BEA6727A24CCAF5600BBF836 /* RunnerRelease.entitlements */,
97C146FA1CF9000F007C117D /* Main.storyboard */,
@ -172,7 +170,7 @@
97C146E61CF9000F007C117D /* Project object */ = {
isa = PBXProject;
attributes = {
LastUpgradeCheck = 1430;
LastUpgradeCheck = 1300;
ORGANIZATIONNAME = "";
TargetAttributes = {
97C146ED1CF9000F007C117D = {
@ -234,12 +232,10 @@
};
3B06AD1E1E4923F5004D2608 /* Thin Binary */ = {
isa = PBXShellScriptBuildPhase;
alwaysOutOfDate = 1;
buildActionMask = 2147483647;
files = (
);
inputPaths = (
"${TARGET_BUILD_DIR}/${INFOPLIST_PATH}",
);
name = "Thin Binary";
outputPaths = (
@ -250,7 +246,6 @@
};
9740EEB61CF901F6004384FC /* Run Script */ = {
isa = PBXShellScriptBuildPhase;
alwaysOutOfDate = 1;
buildActionMask = 2147483647;
files = (
);
@ -375,7 +370,6 @@
buildSettings = {
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
CLANG_ENABLE_MODULES = YES;
CODE_SIGN_ENTITLEMENTS = Runner/Runner.entitlements;
CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)";
DEVELOPMENT_TEAM = 38T664W57F;
ENABLE_BITCODE = NO;

View File

@ -1,6 +1,6 @@
<?xml version="1.0" encoding="UTF-8"?>
<Scheme
LastUpgradeVersion = "1430"
LastUpgradeVersion = "1300"
version = "1.3">
<BuildAction
parallelizeBuildables = "YES"

View File

@ -2,9 +2,7 @@
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>ITSAppUsesNonExemptEncryption</key>
<false/>
<key>CFBundleDevelopmentRegion</key>
<key>CFBundleDevelopmentRegion</key>
<string>$(DEVELOPMENT_LANGUAGE)</string>
<key>CFBundleExecutable</key>
<string>$(EXECUTABLE_NAME)</string>
@ -14,8 +12,6 @@
<string>6.0</string>
<key>CFBundleName</key>
<string>Treadl</string>
<key>FlutterDeepLinkingEnabled</key>
<true/>
<key>CFBundlePackageType</key>
<string>APPL</string>
<key>CFBundleShortVersionString</key>
@ -50,9 +46,5 @@
</array>
<key>UIViewControllerBasedStatusBarAppearance</key>
<false/>
<key>CADisableMinimumFrameDurationOnPhone</key>
<true/>
<key>UIApplicationSupportsIndirectInputEvents</key>
<true/>
</dict>
</plist>

View File

@ -1,11 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>com.apple.developer.associated-domains</key>
<array>
<string>applinks:treadl.com</string>
<string>applinks:www.treadl.com</string>
</array>
</dict>
</plist>

View File

@ -4,10 +4,5 @@
<dict>
<key>aps-environment</key>
<string>development</string>
<key>com.apple.developer.associated-domains</key>
<array>
<string>applinks:treadl.com</string>
<string>applinks:www.treadl.com</string>
</array>
</dict>
</plist>

View File

@ -4,10 +4,5 @@
<dict>
<key>aps-environment</key>
<string>development</string>
<key>com.apple.developer.associated-domains</key>
<array>
<string>applinks:treadl.com</string>
<string>applinks:www.treadl.com</string>
</array>
</dict>
</plist>

View File

@ -1,37 +1,30 @@
import 'package:flutter/material.dart';
import 'package:http/http.dart' as http;
import 'package:provider/provider.dart';
import 'dart:convert';
import 'dart:io';
import 'package:shared_preferences/shared_preferences.dart';
import 'util.dart';
import 'model.dart';
class Api {
String? _token;
final String apiBase = 'https://api.treadl.com';
//final String apiBase = 'http://192.168.5.134:2001';
String _token;
//final String apiBase = 'https://api.treadl.com';
final String apiBase = 'http://localhost:2001';
Api({token: null}) {
if (token != null) _token = token;
}
Future<String?> loadToken() async {
Future<String> loadToken() async {
if (_token != null) {
return _token!;
return _token;
}
SharedPreferences prefs = await SharedPreferences.getInstance();
String? token = prefs.getString('apiToken');
final String token = prefs.getString('apiToken');
return token;
}
Future<Map<String,String>> getHeaders(method) async {
Map<String,String> headers = {};
String? token = await loadToken();
String token = await loadToken();
if (token != null) {
headers['Authorization'] = 'Bearer ' + token!;
headers['Authorization'] = 'Bearer ' + token;
}
if (method == 'POST' || method == 'DELETE') {
if (method == 'POST') {
headers['Content-Type'] = 'application/json';
}
return headers;
@ -41,36 +34,25 @@ class Api {
http.Client client = http.Client();
return await client.get(url, headers: await getHeaders('GET'));
}
Future<http.Response> _post(Uri url, Map<String, dynamic>? data) async {
String? json = null;
if (data != null) {
json = jsonEncode(data!);
}
Future<http.Response> _post(Uri url, Map<String, dynamic> data) async {
String json = jsonEncode(data);
http.Client client = http.Client();
return await client.post(url, headers: await getHeaders('POST'), body: json);
}
Future<http.Response> _put(Uri url, Map<String, dynamic>? data) async {
String? json = null;
if (data != null) {
json = jsonEncode(data!);
}
Future<http.Response> _put(Uri url, Map<String, dynamic> data) async {
String json = jsonEncode(data);
http.Client client = http.Client();
return await client.put(url, headers: await getHeaders('POST'), body: json);
}
Future<http.Response> _delete(Uri url, [Map<String, dynamic>? data]) async {
Future<http.Response> _delete(Uri url) async {
http.Client client = http.Client();
if (data != null) {
String json = jsonEncode(data);
return await client.delete(url, headers: await getHeaders('DELETE'), body: json);
} else {
return await client.delete(url, headers: await getHeaders('DELETE'));
}
return await client.delete(url, headers: await getHeaders('DELETE'));
}
Future<Map<String, dynamic>> request(String method, String path, [Map<String, dynamic>? data]) async {
Future<Map<String, dynamic>> request(String method, String path, [Map<String, dynamic> data]) async {
String url = apiBase + path;
Uri uri = Uri.parse(url);
http.Response? response;
http.Response response;
if (method == 'POST') {
response = await _post(uri, data);
}
@ -81,21 +63,18 @@ class Api {
response = await _get(uri);
}
if (method == 'DELETE') {
response = await _delete(uri, data);
response = await _delete(uri);
}
if (response == null) {
return {'success': false, 'message': 'No response for your request'};
}
int status = response!.statusCode;
int status = response.statusCode;
if (status == 200) {
print('SUCCESS');
Map<String, dynamic> respData = jsonDecode(response!.body);
Map<String, dynamic> respData = jsonDecode(response.body);
return {'success': true, 'payload': respData};
}
else {
print('ERROR');
Map<String, dynamic> respData = jsonDecode(response!.body);
return {'success': false, 'code': status, 'message': respData['message']};
Map<String, dynamic> respData = jsonDecode(response.body);
return {'success': false, 'code': response.statusCode, 'message': respData['Message']};
}
}
@ -109,17 +88,4 @@ class Api {
int status = response.statusCode;
return status == 200;
}
Future<File?> downloadFile(String url, String fileName) async {
Uri uri = Uri.parse(url);
http.Client client = http.Client();
http.Response response = await client.get(uri);
if(response.statusCode == 200) {
final String dirPath = await Util.storagePath();
final file = File('$dirPath/$fileName');
await file.writeAsBytes(response.bodyBytes);
return file;
}
return null;
}
}

View File

@ -0,0 +1,31 @@
import 'dart:async';
import 'dart:convert';
import 'dart:typed_data';
import 'package:flutter/material.dart';
class DataImage extends StatefulWidget {
final String _data;
DataImage(this._data) {}
@override
DataImageState createState() => new DataImageState(_data);
}
class DataImageState extends State<MyHomePage> {
String _base64;
DataImageState(this._base64) {}
@override
Widget build(BuildContext context) {
if (_base64 == null)
return new Container();
Uint8List bytes = BASE64.decode(_base64);
return new Scaffold(
appBar: new AppBar(title: new Text('Example App')),
body: new ListTile(
leading: new Image.memory(bytes),
title: new Text(_base64),
),
);
}
}

View File

@ -1,115 +0,0 @@
import 'package:flutter/material.dart';
import 'package:flutter/cupertino.dart';
import 'package:provider/provider.dart';
import 'api.dart';
import 'util.dart';
import 'lib.dart';
class _ExploreTabState extends State<ExploreTab> {
List<dynamic> objects = [];
List<dynamic> projects = [];
bool loading = false;
int explorePage = 1;
final Api api = Api();
final Util util = Util();
@override
initState() {
super.initState();
getExploreData();
getData();
}
void getExploreData() async {
if (explorePage == -1) return;
var data = await api.request('GET', '/search/explore?page=${explorePage}');
if (data['success'] == true) {
setState(() {
loading = false;
objects = objects + data['payload']['objects'];
explorePage = data['payload']['objects'].length == 0 ? -1 : (explorePage + 1); // Set to -1 to disable 'load more'
});
}
}
void getData() async {
setState(() {
loading = true;
});
var data2 = await api.request('GET', '/search/discover');
if (data2['success'] == true) {
setState(() {
projects = data2['payload']['highlightProjects'];
});
}
}
@override
Widget build(BuildContext context) {
List<Widget> patternCards = objects.map<Widget>((object) =>
PatternCard(object)
).toList();
if (explorePage > -1) {
patternCards.add(Container(
decoration: BoxDecoration(
color: Colors.pink[50],
borderRadius: BorderRadius.all(Radius.circular(10)),
),
child:Center(
child: CupertinoButton(
child: Text('Load more'),
onPressed: () => getExploreData(),
)
)
));
}
return Scaffold(
appBar: AppBar(
title: Text('Explore'),
),
body: loading ?
Container(
margin: const EdgeInsets.all(10.0),
alignment: Alignment.center,
child: CircularProgressIndicator()
)
: Container(
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
SizedBox(height: 10),
CustomText('Discover projects', 'h1', margin: 5),
SizedBox(height: 5),
Container(
height: 130,
child: ListView(
scrollDirection: Axis.horizontal,
children: projects.map((p) => ProjectCard(p)).toList()
)
),
SizedBox(height: 10),
CustomText('Recent patterns', 'h1', margin: 5),
SizedBox(height: 5),
Expanded(child: Container(
margin: EdgeInsets.only(left: 15, right: 15),
child: GridView.count(
crossAxisCount: 2,
mainAxisSpacing: 5,
crossAxisSpacing: 5,
childAspectRatio: 0.9,
children: patternCards,
),
)),
]
)
),
);
}
}
class ExploreTab extends StatefulWidget {
@override
_ExploreTabState createState() => _ExploreTabState();
}

View File

@ -6,41 +6,31 @@ import 'group_noticeboard.dart';
import 'group_members.dart';
class _GroupScreenState extends State<GroupScreen> {
final String id;
Map<String, dynamic>? _group;
int _selectedIndex = 0;
List<Widget> _widgetOptions = <Widget> [];
final Map<String, dynamic> _group;
_GroupScreenState(this.id) { }
@override
void initState() {
fetchGroup();
super.initState();
_GroupScreenState(this._group) {
_widgetOptions = <Widget> [
GroupNoticeBoardTab(this._group),
GroupMembersTab(this._group)
];
}
void fetchGroup() async {
Api api = Api();
var data = await api.request('GET', '/groups/' + id);
if (data['success'] == true) {
setState(() {
_group = data['payload'];
});
}
void _onItemTapped(int index) {
setState(() {
_selectedIndex = index;
});
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: Text(_group?['name'] ?? 'Group')
title: Text(_group['name'])
),
body: Center(
child: _group != null ?
[
GroupNoticeBoardTab(_group!),
GroupMembersTab(_group!)
].elementAt(_selectedIndex)
: CircularProgressIndicator(),
child: _widgetOptions.elementAt(_selectedIndex),
),
bottomNavigationBar: BottomNavigationBar(
items: const <BottomNavigationBarItem>[
@ -55,17 +45,15 @@ class _GroupScreenState extends State<GroupScreen> {
],
currentIndex: _selectedIndex,
selectedItemColor: Colors.pink[600],
onTap: (int index) => setState(() {
_selectedIndex = index;
}),
onTap: _onItemTapped,
),
);
}
}
class GroupScreen extends StatefulWidget {
final String id;
GroupScreen(this.id) { }
final Map<String,dynamic> group;
GroupScreen(this.group) { }
@override
_GroupScreenState createState() => _GroupScreenState(id);
_GroupScreenState createState() => _GroupScreenState(group);
}

View File

@ -1,11 +1,12 @@
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import 'package:go_router/go_router.dart';
import 'api.dart';
import 'util.dart';
import 'group_noticeboard.dart';
import 'user.dart';
class _GroupMembersTabState extends State<GroupMembersTab> {
final Util util = new Util();
final Map<String,dynamic> _group;
final Api api = Api();
List<dynamic> _members = [];
@ -32,8 +33,15 @@ class _GroupMembersTabState extends State<GroupMembersTab> {
Widget getMemberCard(member) {
return new ListTile(
onTap: () => context.push('/' + member['username']),
leading: Util.avatarImage(Util.avatarUrl(member), size: 40),
onTap: () {
Navigator.push(
context,
MaterialPageRoute(
builder: (context) => UserScreen(member),
),
);
},
leading: util.avatarImage(util.avatarUrl(member), size: 40),
trailing: Icon(Icons.keyboard_arrow_right),
title: Text(member['username'])
);

View File

@ -1,11 +1,15 @@
import 'package:flutter/material.dart';
import 'package:url_launcher/url_launcher.dart';
import 'package:intl/intl.dart';
import 'package:provider/provider.dart';
import 'util.dart';
import 'api.dart';
import 'user.dart';
import 'lib.dart';
class _GroupNoticeBoardTabState extends State<GroupNoticeBoardTab> {
final TextEditingController _newEntryController = TextEditingController();
final Util utils = new Util();
final Api api = Api();
Map<String,dynamic> _group;
List<dynamic> _entries = [];
@ -38,10 +42,8 @@ class _GroupNoticeBoardTabState extends State<GroupNoticeBoardTab> {
}
void _sendPost(context) async {
String text = _newEntryController.text;
if (text.length == 0) return;
setState(() => _posting = true);
var data = await api.request('POST', '/groups/' + _group['_id'] + '/entries', {'content': text});
var data = await api.request('POST', '/groups/' + _group['_id'] + '/entries', {'content': _newEntryController.text});
if (data['success'] == true) {
_newEntryController.value = TextEditingValue(text: '');
FocusScope.of(context).requestFocus(FocusNode());

View File

@ -1,9 +1,9 @@
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import 'package:go_router/go_router.dart';
import 'dart:convert';
import 'package:http/http.dart' as http;
import 'package:shared_preferences/shared_preferences.dart';
import 'group.dart';
import 'api.dart';
import 'model.dart';
import 'lib.dart';
class _GroupsTabState extends State<GroupsTab> {
List<dynamic> _groups = [];
@ -16,8 +16,6 @@ class _GroupsTabState extends State<GroupsTab> {
}
void getGroups() async {
AppModel model = Provider.of<AppModel>(context, listen: false);
if (model.user == null) return;
setState(() => _loading = true);
Api api = Api();
var data = await api.request('GET', '/groups');
@ -30,61 +28,70 @@ class _GroupsTabState extends State<GroupsTab> {
}
Widget buildGroupCard(Map<String,dynamic> group) {
String? description = group['description'];
String description = group['description'];
if (description != null && description.length > 80) {
description = description.substring(0, 77) + '...';
} else if (description == null) {
description = 'This group doesn\'t have a description.';
} else {
description = '';
}
return Card(
child: InkWell(
onTap: () => context.push('/groups/' + group['_id']),
child: ListTile(
leading: Icon(Icons.people, size: 40, color: Colors.pink[300]),
trailing: Icon(Icons.keyboard_arrow_right),
title: Text(group['name']),
subtitle: Text(description.replaceAll("\n", " ")),
child: InkWell(
onTap: () {
Navigator.push(
context,
MaterialPageRoute(
builder: (context) => GroupScreen(group),
),
);
},
child: Column(
mainAxisSize: MainAxisSize.min,
children: <Widget>[
new ListTile(
leading: Icon(Icons.people),
trailing: Icon(Icons.keyboard_arrow_right),
title: Text(group['name']),
subtitle: Text(description.replaceAll("\n", " ")),
),
]
)
)
)
);
}
Widget getBody() {
AppModel model = Provider.of<AppModel>(context);
if (model.user == null)
return LoginNeeded(text: 'Once logged in, you\'ll find your groups here.');
else if (_loading)
return CircularProgressIndicator();
else if (_groups != null && _groups.length > 0)
return ListView.builder(
itemCount: _groups.length,
itemBuilder: (BuildContext context, int index) {
return buildGroupCard(_groups[index]);
},
);
else
return Column(
crossAxisAlignment: CrossAxisAlignment.center,
mainAxisAlignment: MainAxisAlignment.center,
children: [
Text('You aren\'t a member of any groups yet', style: TextStyle(fontSize: 20), textAlign: TextAlign.center),
Image(image: AssetImage('assets/group.png'), width: 300),
Text('Groups let you meet and keep in touch with others in the weaving community.', textAlign: TextAlign.center),
Text('Please use our website to join and leave groups.', textAlign: TextAlign.center),
]);
;
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: Text('My Groups'),
title: Text('Groups'),
),
body: Container(
body: _loading ?
Container(
margin: const EdgeInsets.all(10.0),
alignment: Alignment.center,
child: CircularProgressIndicator()
)
: Container(
margin: const EdgeInsets.all(10.0),
alignment: Alignment.center,
child: getBody()
)
child: (_groups != null && _groups.length > 0) ?
ListView.builder(
itemCount: _groups.length,
itemBuilder: (BuildContext context, int index) {
return buildGroupCard(_groups[index]);
},
)
:
Column(
crossAxisAlignment: CrossAxisAlignment.center,
mainAxisAlignment: MainAxisAlignment.center,
children: [
Text('You aren\'t a member of any groups yet', style: TextStyle(fontSize: 20), textAlign: TextAlign.center),
Image(image: AssetImage('assets/group.png'), width: 300),
Text('Groups let you meet and keep in touch with others in the weaving community.', textAlign: TextAlign.center),
Text('Please use our website to join and leave groups.', textAlign: TextAlign.center),
])
),
);
}
}

View File

@ -2,7 +2,6 @@ import 'package:flutter/material.dart';
import 'dart:convert';
import 'package:http/http.dart' as http;
import 'package:shared_preferences/shared_preferences.dart';
import 'explore.dart';
import 'projects.dart';
import 'groups.dart';
@ -14,7 +13,6 @@ class HomeScreen extends StatefulWidget {
class _MyStatefulWidgetState extends State<HomeScreen> {
int _selectedIndex = 0;
List<Widget> _widgetOptions = <Widget> [
ExploreTab(),
ProjectsTab(),
GroupsTab()
];
@ -33,17 +31,13 @@ class _MyStatefulWidgetState extends State<HomeScreen> {
),
bottomNavigationBar: BottomNavigationBar(
items: const <BottomNavigationBarItem>[
BottomNavigationBarItem(
icon: Icon(Icons.explore),
label: 'Explore',
),
BottomNavigationBarItem(
icon: Icon(Icons.folder),
label: 'My Projects',
label: 'Projects',
),
BottomNavigationBarItem(
icon: Icon(Icons.people),
label: 'My Groups',
icon: Icon(Icons.person),
label: 'Groups',
),
],
currentIndex: _selectedIndex,

View File

@ -4,20 +4,17 @@ import 'package:flutter/material.dart';
import 'package:flutter/cupertino.dart';
import 'package:intl/intl.dart';
import 'package:url_launcher/url_launcher.dart';
import 'package:go_router/go_router.dart';
import 'api.dart';
import 'util.dart';
import 'user.dart';
import 'object.dart';
import 'project.dart';
class Alert extends StatelessWidget {
final String type;
final String title;
final String description;
final String actionText;
final Widget? descriptionWidget;
final Function? action;
final Widget descriptionWidget;
final Function action;
Alert({this.type = 'info', this.title = '', this.description = '', this.descriptionWidget = null, this.actionText = 'Click here', this.action}) {}
@override
@ -42,7 +39,7 @@ class Alert extends StatelessWidget {
color: accentColor,
borderRadius: new BorderRadius.all(Radius.circular(10.0)),
boxShadow: [
BoxShadow(color: Colors.grey[50]!, spreadRadius: 5),
BoxShadow(color: Colors.grey[50], spreadRadius: 5),
],
),
child: Column(
@ -51,12 +48,12 @@ class Alert extends StatelessWidget {
Icon(icon, color: color),
SizedBox(height: 20),
Text(description, textAlign: TextAlign.center),
descriptionWidget != null ? descriptionWidget! : Text(""),
descriptionWidget,
action != null ? CupertinoButton(
child: Text(actionText),
onPressed: () => action!(),
) : Text("")
]
onPressed: action,
) : null
].where((o) => o != null).toList()
)
);
}
@ -64,31 +61,30 @@ class Alert extends StatelessWidget {
class NoticeboardPost extends StatefulWidget {
final Map<String,dynamic> _entry;
final Function? onDelete;
final Function? onReply;
final Function onDelete;
final Function onReply;
NoticeboardPost(this._entry, {this.onDelete = null, this.onReply = null});
_NoticeboardPostState createState() => _NoticeboardPostState(_entry, onDelete: onDelete, onReply: onReply);
}
class _NoticeboardPostState extends State<NoticeboardPost> {
final Map<String,dynamic> _entry;
final Util utils = new Util();
final Api api = new Api();
final Function? onDelete;
final Function? onReply;
final Function onDelete;
final Function onReply;
final TextEditingController _replyController = TextEditingController();
bool _isReplying = false;
bool _replying = false;
_NoticeboardPostState(this._entry, {this.onDelete = null, this.onReply = null}) { }
void _sendReply() async {
setState(() => _replying = true);
var data = await api.request('POST', '/groups/' + _entry['group'] + '/entries/' + _entry['_id'] + '/replies', {'content': _replyController.text});
if (data['success'] == true) {
_replyController.value = TextEditingValue(text: '');
FocusScope.of(context).requestFocus(FocusNode());
if (onReply != null) {
onReply!(data['payload']);
}
onReply(data['payload']);
setState(() {
_replying = false;
_isReplying = false;
@ -99,10 +95,8 @@ class _NoticeboardPostState extends State<NoticeboardPost> {
void _deletePost() async {
var data = await api.request('DELETE', '/groups/' + _entry['group'] + '/entries/' + _entry['_id']);
if (data['success'] == true) {
if (onDelete != null) {
onDelete!(_entry);
}
context.pop();
onDelete(_entry);
Navigator.of(context).pop();
}
}
@ -110,17 +104,17 @@ class _NoticeboardPostState extends State<NoticeboardPost> {
Widget build(BuildContext context) {
var createdAt = DateTime.parse(_entry['createdAt']);
bool isReply = _entry['inReplyTo'] != null;
int replyCount = _entry['replies'] == null ? 0 : _entry['replies']!.length;
int replyCount = _entry['replies'] == null ? 0 : _entry['replies'].length;
String replyText = 'Write a reply...';
if (replyCount == 1) replyText = '1 Reply';
if (replyCount > 1) replyText = replyCount.toString() + ' replies';
if (_isReplying) replyText = 'Cancel reply';
List<Widget> replyWidgets = [];
if (_entry['replies'] != null) {
for (int i = 0; i < _entry['replies']!.length; i++) {
for (int i = 0; i < _entry['replies'].length; i++) {
replyWidgets.add(new Container(
key: Key(_entry['replies']![i]['_id']),
child: NoticeboardPost(_entry['replies']![i], onDelete: onDelete)
key: Key(_entry['replies'][i]['_id']),
child: NoticeboardPost(_entry['replies'][i], onDelete: onDelete)
));
}
}
@ -133,22 +127,22 @@ class _NoticeboardPostState extends State<NoticeboardPost> {
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: <Widget>[
ElevatedButton(
//color: Colors.orange,
RaisedButton(
color: Colors.orange,
onPressed: () {
launch('https://www.treadl.com');
launch('https://seastorm.co/support#treadl');
},
child: Text('Report this post'),
),
SizedBox(height: 10),
ElevatedButton(
//color: Colors.red,
RaisedButton(
color: Colors.red,
onPressed: _deletePost,
child: Text('Delete post'),
),
TextButton(
FlatButton(
onPressed: () {
context.pop();
Navigator.of(context).pop();
},
child: Text('Cancel'),
)
@ -167,29 +161,33 @@ class _NoticeboardPostState extends State<NoticeboardPost> {
Row(
children: <Widget>[
GestureDetector(
onTap: () => context.push('/' + _entry['authorUser']['username']),
child: Util.avatarImage(Util.avatarUrl(_entry['authorUser']), size: isReply ? 30 : 40)
onTap: () {
Navigator.push(context, MaterialPageRoute(
builder: (context) => UserScreen(_entry['authorUser']),
));
},
child: utils.avatarImage(utils.avatarUrl(_entry['authorUser']), size: isReply ? 30 : 40)
),
SizedBox(width: 5),
SizedBox(width: 5),
Text(_entry['authorUser']['username'], style: TextStyle(color: Colors.pink)),
SizedBox(width: 5),
SizedBox(width: 5),
Text(DateFormat('kk:mm on MMMM d y').format(createdAt), style: TextStyle(color: Colors.grey, fontSize: 10)),
SizedBox(width: 10),
!isReply ? GestureDetector(
onTap: () => setState(() => _isReplying = !_isReplying),
child: Text(replyText, style: TextStyle(color: replyCount > 0 ? Colors.pink : Colors.black, fontSize: 10, fontWeight: FontWeight.bold)),
): SizedBox(width: 0),
],
): null,
].where((o) => o != null).toList(),
),
Row(children: [
SizedBox(width: 45),
Expanded(child: Text(_entry['content'], textAlign: TextAlign.left))
]),
_isReplying ? NoticeboardInput(_replyController, _sendReply, _replying, label: 'Reply to this post') : SizedBox(width: 0),
_isReplying ? NoticeboardInput(_replyController, _sendReply, _replying, label: 'Reply to this post') : null,
Column(
children: replyWidgets
),
],
].where((o) => o != null).toList(),
))
);
}
@ -213,11 +211,11 @@ class NoticeboardInput extends StatelessWidget {
maxLines: 8,
minLines: 1,
decoration: InputDecoration(
hintText: 'Begin writing...', labelText: label
hintText: 'Begin writing...', labelText: label
),
)),
IconButton(
onPressed: () => _onPost!(),
onPressed: _onPost,
color: Colors.pink,
icon: _posting ? CircularProgressIndicator() : Icon(Icons.send),
)
@ -227,174 +225,3 @@ class NoticeboardInput extends StatelessWidget {
}
}
class UserChip extends StatelessWidget {
final Map<String,dynamic> user;
UserChip(this.user) {}
@override
Widget build(BuildContext context) {
ImageProvider? avatar = Util.avatarUrl(user);
return GestureDetector(
onTap: () => context.push('/' + user['username']),
child: Row(
crossAxisAlignment: CrossAxisAlignment.center,
children: [
Util.avatarImage(avatar, size: 20),
SizedBox(width: 5),
Text(user['username'], style: TextStyle(color: Colors.grey))
]
)
);
}
}
class PatternCard extends StatelessWidget {
final Map<String,dynamic> object;
PatternCard(this.object) {}
@override
Widget build(BuildContext context) {
return Card(
elevation: 2,
clipBehavior: Clip.hardEdge,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(6.0),
),
child: InkWell(
onTap: () {
context.push('/' + object['projectObject']['owner']['username'] + '/' + object['projectObject']['path'] + '/' + object['_id']);
},
child: Column(
children: [
Container(
height: 100,
decoration: BoxDecoration(
image: DecorationImage(
fit: BoxFit.cover,
image: NetworkImage(object['previewUrl']),
),
),
),
Container(
padding: EdgeInsets.all(10),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
UserChip(object['projectObject']['owner']),
SizedBox(height: 5),
Text(Util.ellipsis(object['name'], 35), style: TextStyle(fontSize: 15, fontWeight: FontWeight.bold)),
]
)
)
]
)
)
);
}
}
class ProjectCard extends StatelessWidget {
final Map<String,dynamic> project;
ProjectCard(this.project) {}
@override
Widget build(BuildContext context) {
return Card(
elevation: 2,
clipBehavior: Clip.hardEdge,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(6.0),
),
child: InkWell(
onTap: () {
context.push('/' + this.project['owner']['username'] + '/' + this.project['path']);
},
child: Column(
children: [
Container(
width: 200,
padding: EdgeInsets.all(10),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Icon(Icons.folder, color: Colors.pink[200]),
SizedBox(height: 10),
UserChip(project['owner']),
SizedBox(height: 5),
Text(Util.ellipsis(project['name'], 35), style: TextStyle(fontSize: 15, fontWeight: FontWeight.bold)),
]
)
)
]
)
)
);
}
}
class CustomText extends StatelessWidget {
final String text;
final String type;
final double margin;
TextStyle? style;
CustomText(this.text, this.type, {this.margin = 0}) {
if (this.type == 'h1') {
style = TextStyle(fontSize: 25, fontWeight: FontWeight.bold);
}
else {
style = TextStyle();
}
}
@override
Widget build(BuildContext context) {
return Container(
margin: EdgeInsets.all(this.margin),
child: Text(text, style: style)
);
}
}
class LoginNeeded extends StatelessWidget {
final String? text;
LoginNeeded({this.text}) {}
@override
Widget build(BuildContext context) {
return Column(
crossAxisAlignment: CrossAxisAlignment.center,
mainAxisAlignment: MainAxisAlignment.center,
children: [
Text('You need to login to see this', style: TextStyle(fontSize: 20), textAlign: TextAlign.center),
Image(image: AssetImage('assets/login.png'), width: 300),
text != null ? Text(text!, textAlign: TextAlign.center) : SizedBox(height: 10),
CupertinoButton(
onPressed: () {
context.push('/welcome');
},
child: new Text("Login or register",
textAlign: TextAlign.center,
)
)
]
);
}
}
class EmptyBox extends StatelessWidget {
final String title;
final String? description;
EmptyBox(this.title, {this.description}) {}
@override
Widget build(BuildContext context) {
return Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
mainAxisAlignment: MainAxisAlignment.center,
children: [
Text(title, style: TextStyle(fontSize: 20), textAlign: TextAlign.center),
Image(image: AssetImage('assets/empty.png'), width: 300),
description != null ? Text('Add a pattern file, an image, or something else to this project using the + button below.', textAlign: TextAlign.center) : SizedBox(height: 0),
]);
}
}

View File

@ -1,11 +1,9 @@
import 'package:flutter/material.dart';
import 'package:flutter/cupertino.dart';
import 'package:flutter/widgets.dart';
import 'package:shared_preferences/shared_preferences.dart';
import 'package:url_launcher/url_launcher.dart';
import 'package:provider/provider.dart';
import 'package:go_router/go_router.dart';
import 'api.dart';
import 'model.dart';
class _LoginScreenState extends State<LoginScreen> {
final TextEditingController _emailController = TextEditingController();
@ -13,14 +11,15 @@ class _LoginScreenState extends State<LoginScreen> {
final Api api = Api();
bool _loggingIn = false;
void _submit(BuildContext context) async {
void _submit(context) async {
setState(() => _loggingIn = true);
var data = await api.request('POST', '/accounts/login', {'email': _emailController.text, 'password': _passwordController.text});
setState(() => _loggingIn = false);
if (data['success'] == true) {
AppModel model = Provider.of<AppModel>(context, listen: false);
await model.setToken(data['payload']['token']);
context.go('/onboarding');
String token = data['payload']['token'];
SharedPreferences prefs = await SharedPreferences.getInstance();
prefs.setString('apiToken', token);
Navigator.of(context).pushNamedAndRemoveUntil('/onboarding', (Route<dynamic> route) => false);
}
else {
showDialog(
@ -32,7 +31,7 @@ class _LoginScreenState extends State<LoginScreen> {
CupertinoDialogAction(
isDefaultAction: true,
child: Text('Try again'),
onPressed: () => context.pop(),
onPressed: () => Navigator.pop(context),
),
],
)
@ -47,46 +46,50 @@ class _LoginScreenState extends State<LoginScreen> {
title: Text('Login to Treadl'),
),
body: Container(
margin: const EdgeInsets.only(top: 40, left: 10, right: 10),
child: ListView(
children: <Widget>[
Text('Login with your Treadl account', style: TextStyle(fontSize: 20)),
SizedBox(height: 30),
TextField(
autofocus: true,
controller: _emailController,
decoration: InputDecoration(
hintText: 'sam@example.com', labelText: 'Email address or username',
border: OutlineInputBorder(),
margin: const EdgeInsets.all(10.0),
child: SingleChildScrollView(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: <Widget>[
Image(image: AssetImage('assets/logo.png'), width: 100),
SizedBox(height: 20),
Text('Login using your Treadl account.'),
SizedBox(height: 20),
TextField(
autofocus: true,
controller: _emailController,
decoration: InputDecoration(
hintText: 'sam@example.com', labelText: 'Email address or username'
),
),
),
SizedBox(height: 10),
TextField(
onEditingComplete: () => _submit(context),
controller: _passwordController,
obscureText: true,
decoration: InputDecoration(
hintText: 'Type your password', labelText: 'Your password',
border: OutlineInputBorder(),
SizedBox(height: 10),
TextField(
onEditingComplete: () => _submit(context),
controller: _passwordController,
obscureText: true,
decoration: InputDecoration(
hintText: 'Type your password', labelText: 'Your password'
),
),
),
SizedBox(height: 5),
Row(
mainAxisAlignment: MainAxisAlignment.end,
children: [GestureDetector(
onTap: () => launch('https://treadl.com/password/forgotten'),
child: Text('Forgotten your password?'),
)]
),
SizedBox(height: 20),
ElevatedButton(
onPressed: () => _submit(context),
child: _loggingIn ? SizedBox(height: 20, width: 20, child:CircularProgressIndicator(backgroundColor: Colors.white)) : Text("Login",
textAlign: TextAlign.center,
style: TextStyle(color: Colors.white, fontSize: 15)
)
),
]
SizedBox(height: 5),
Row(
mainAxisAlignment: MainAxisAlignment.end,
children: [GestureDetector(
onTap: () => launch('https://treadl.com/password/forgotten'),
child: Text('Forgotten your password?'),
)]
),
SizedBox(height: 20),
RaisedButton(
onPressed: () => _submit(context),
color: Colors.pink,
child: _loggingIn ? SizedBox(height: 20, width: 20, child:CircularProgressIndicator(backgroundColor: Colors.white)) : Text("Login",
textAlign: TextAlign.center,
style: TextStyle(color: Colors.white, fontSize: 15)
)
),
]
)
)
),
);

View File

@ -3,54 +3,19 @@ import 'package:provider/provider.dart';
import 'package:shared_preferences/shared_preferences.dart';
import 'package:firebase_core/firebase_core.dart';
import 'package:firebase_messaging/firebase_messaging.dart';
import 'package:go_router/go_router.dart';
//import 'package:fluttertoast/fluttertoast.dart';
import 'package:fluttertoast/fluttertoast.dart';
import 'api.dart';
import 'model.dart';
import 'store.dart';
import 'welcome.dart';
import 'login.dart';
import 'register.dart';
import 'onboarding.dart';
import 'home.dart';
import 'project.dart';
import 'object.dart';
import 'settings.dart';
import 'group.dart';
import 'user.dart';
final router = GoRouter(
routes: [
GoRoute(path: '/', builder: (context, state) => Startup()),
GoRoute(path: '/welcome', pageBuilder: (context, state) {
return CustomTransitionPage(
key: state.pageKey,
child: WelcomeScreen(),
transitionsBuilder: (context, animation, secondaryAnimation, child) {
// Change the opacity of the screen using a Curve based on the the animation's value
return FadeTransition(
opacity:
CurveTween(curve: Curves.easeInOutCirc).animate(animation),
child: child,
);
},
);
}),
GoRoute(path: '/login', builder: (context, state) => LoginScreen()),
GoRoute(path: '/register', builder: (context, state) => RegisterScreen()),
GoRoute(path: '/onboarding', builder: (context, state) => OnboardingScreen()),
GoRoute(path: '/home', builder: (context, state) => HomeScreen()),
GoRoute(path: '/settings', builder: (context, state) => SettingsScreen()),
GoRoute(path: '/groups/:id', builder: (context, state) => GroupScreen(state.pathParameters['id']!)),
GoRoute(path: '/:username', builder: (context, state) => UserScreen(state.pathParameters['username']!)),
GoRoute(path: '/:username/:path', builder: (context, state) => ProjectScreen(state.pathParameters['username']!, state.pathParameters['path']!)),
GoRoute(path: '/:username/:path/:id', builder: (context, state) => ObjectScreen(state.pathParameters['username']!, state.pathParameters['path']!, state.pathParameters['id']!)),
],
);
void main() {
runApp(
ChangeNotifierProvider(
create: (context) => AppModel(),
create: (context) => Store(),
child: MyApp()
)
);
@ -72,14 +37,21 @@ class _AppState extends State<MyApp> {
// Initialize FlutterFire:
future: _initialization,
builder: (context, snapshot) {
return MaterialApp.router(
routerConfig: router,
return MaterialApp(
debugShowCheckedModeBanner: false,
title: 'Treadl',
theme: ThemeData(
primarySwatch: Colors.pink,
scaffoldBackgroundColor: Color.fromRGBO(255, 251, 248, 1),
textSelectionColor: Colors.blue,
),
home: Startup(),
routes: <String, WidgetBuilder>{
'/welcome': (BuildContext context) => WelcomeScreen(),
'/login': (BuildContext context) => LoginScreen(),
'/register': (BuildContext context) => RegisterScreen(),
'/onboarding': (BuildContext context) => OnboardingScreen(),
'/home': (BuildContext context) => HomeScreen(),
}
);
},
);
@ -92,12 +64,12 @@ class Startup extends StatelessWidget {
Startup() {
FirebaseMessaging.onMessage.listen((RemoteMessage message) {
if (message.notification != null) {
print(message.notification!);
print(message.notification.body);
String text = '';
if (message.notification != null && message.notification!.body != null) {
text = message.notification!.body!;
if (message.notification != null && message.notification.body != null) {
text = message.notification.body;
}
/*Fluttertoast.showToast(
Fluttertoast.showToast(
msg: text,
toastLength: Toast.LENGTH_LONG,
gravity: ToastGravity.TOP,
@ -105,7 +77,7 @@ class Startup extends StatelessWidget {
backgroundColor: Colors.grey[100],
textColor: Colors.black,
fontSize: 16.0
);*/
);
}
});
}
@ -114,10 +86,10 @@ class Startup extends StatelessWidget {
if (_handled) return;
_handled = true;
SharedPreferences prefs = await SharedPreferences.getInstance();
String? token = prefs.getString('apiToken');
final String token = prefs.getString('apiToken');
if (token != null) {
AppModel model = Provider.of<AppModel>(context, listen: false);
await model.setToken(token!);
Provider.of<Store>(context, listen: false).setToken(token);
FirebaseMessaging _firebaseMessaging = FirebaseMessaging.instance;
await _firebaseMessaging.requestPermission(
alert: true,
@ -128,14 +100,19 @@ class Startup extends StatelessWidget {
provisional: false,
sound: true,
);
String? _pushToken = await _firebaseMessaging.getToken();
String _pushToken = await _firebaseMessaging.getToken();
if (_pushToken != null) {
print("sending push");
Api api = Api();
api.request('PUT', '/accounts/pushToken', {'pushToken': _pushToken!});
api.request('PUT', '/accounts/pushToken', {'pushToken': _pushToken});
}
print('111');
// Push without including current route in stack:
Navigator.of(context, rootNavigator: true).pushNamedAndRemoveUntil('/home', (Route<dynamic> route) => false);
print('222');
} else {
Navigator.of(context).pushNamedAndRemoveUntil('/welcome', (Route<dynamic> route) => false);
}
context.go('/home');
}
@override

View File

@ -1,65 +0,0 @@
import 'package:flutter/foundation.dart';
import 'package:shared_preferences/shared_preferences.dart';
import 'api.dart';
class User {
final String id;
final String username;
String? avatar;
String? avatarUrl;
User(this.id, this.username, {this.avatar, this.avatarUrl}) {}
static User loadJSON(Map<String,dynamic> input) {
return User(input['_id'], input['username'], avatar: input['avatar'], avatarUrl: input['avatarUrl']);
}
}
class AppModel extends ChangeNotifier {
User? user;
void setUser(User? u) {
user = u;
notifyListeners();
}
String? apiToken;
Future<void> setToken(String? newToken) async {
apiToken = newToken;
SharedPreferences prefs = await SharedPreferences.getInstance();
if (apiToken != null) {
Api api = Api(token: apiToken!);
prefs.setString('apiToken', apiToken!);
var data = await api.request('GET', '/users/me');
if (data['success'] == true) {
setUser(User.loadJSON(data['payload']));
print(data);
}
} else {
prefs.remove('apiToken');
}
}
/*
/// Internal, private state of the cart.
final List<Item> _items = [];
/// An unmodifiable view of the items in the cart.
UnmodifiableListView<Item> get items => UnmodifiableListView(_items);
/// The current total price of all items (assuming all items cost $42).
int get totalPrice => _items.length * 42;
/// Adds [item] to cart. This and [removeAll] are the only ways to modify the
/// cart from the outside.
void add(Item item) {
_items.add(item);
// This call tells the widgets that are listening to this model to rebuild.
notifyListeners();
}
/// Removes all items from the cart.
void removeAll() {
_items.clear();
// This call tells the widgets that are listening to this model to rebuild.
notifyListeners();
}*/
}

View File

@ -3,64 +3,22 @@ import 'package:flutter/cupertino.dart';
import 'package:provider/provider.dart';
import 'package:url_launcher/url_launcher.dart';
import 'package:flutter_html/flutter_html.dart';
import 'package:go_router/go_router.dart';
import 'dart:io';
import 'api.dart';
import 'util.dart';
import 'model.dart';
import 'patterns/pattern.dart';
import 'patterns/viewer.dart';
class _ObjectScreenState extends State<ObjectScreen> {
final String username;
final String projectPath;
final String id;
Map<String,dynamic>? object;
Map<String,dynamic>? pattern;
bool _isLoading = false;
final Map<String,dynamic> _object;
final Function _onDelete;
final Api api = Api();
_ObjectScreenState(this.username, this.projectPath, this.id) { }
@override
initState() {
super.initState();
fetchObject();
}
void fetchObject() async {
var data = await api.request('GET', '/objects/' + id);
if (data['success'] == true) {
setState(() {
object = data['payload'];
pattern = data['payload']['pattern'];
});
}
}
void _shareObject() async {
setState(() => _isLoading = true);
File? file;
if (object!['type'] == 'pattern') {
var data = await api.request('GET', '/objects/' + id + '/wif');
if (data['success'] == true) {
file = await Util.writeFile(object!['name'] + '.wif', data['payload']['wif']);
}
} else {
String fileName = Uri.file(object!['url']).pathSegments.last;
file = await api.downloadFile(object!['url'], fileName);
}
if (file != null) {
Util.shareFile(file!, withDelete: true);
}
setState(() => _isLoading = false);
}
_ObjectScreenState(this._object, this._onDelete) { }
void _deleteObject(BuildContext context, BuildContext modalContext) async {
var data = await api.request('DELETE', '/objects/' + id);
var data = await api.request('DELETE', '/objects/' + _object['_id']);
if (data['success']) {
context.go('/home');
Navigator.pop(context);
Navigator.pop(modalContext);
Navigator.pop(context);
_onDelete(_object['_id']);
}
}
@ -68,13 +26,13 @@ class _ObjectScreenState extends State<ObjectScreen> {
showDialog(
context: modalContext,
builder: (BuildContext context) => CupertinoAlertDialog(
title: new Text('Really delete this item?'),
title: new Text('Really delete this object?'),
content: new Text('This action cannot be undone.'),
actions: <Widget>[
CupertinoDialogAction(
isDefaultAction: true,
child: Text('No'),
onPressed: () => context.pop(),
onPressed: () => Navigator.pop(context),
),
CupertinoDialogAction(
isDestructiveAction: true,
@ -85,45 +43,6 @@ class _ObjectScreenState extends State<ObjectScreen> {
)
);
}
void _renameObject(BuildContext context) {
TextEditingController renameController = TextEditingController();
showDialog(
context: context,
builder: (context) {
return AlertDialog(
title: Text('Rename this item'),
content: TextField(
autofocus: true,
controller: renameController,
decoration: InputDecoration(hintText: "Enter a new name for the item"),
),
actions: <Widget>[
TextButton(
child: Text('CANCEL'),
onPressed: () {
context.pop();
},
),
TextButton(
child: Text('OK'),
onPressed: () async {
var data = await api.request('PUT', '/objects/' + id, {'name': renameController.text});
if (data['success']) {
context.pop();
object!['name'] = data['payload']['name'];
setState(() {
object = object;
});
}
context.pop();
},
),
],
);
},
);
}
void _showSettingsModal(context) {
showCupertinoModalPopup(
@ -132,17 +51,13 @@ class _ObjectScreenState extends State<ObjectScreen> {
return CupertinoActionSheet(
title: Text('Manage this object'),
cancelButton: CupertinoActionSheetAction(
onPressed: () => modalContext.pop(),
onPressed: () => Navigator.of(modalContext).pop(),
child: Text('Cancel')
),
actions: [
CupertinoActionSheetAction(
onPressed: () => _renameObject(context),
child: Text('Rename item'),
),
CupertinoActionSheetAction(
onPressed: () => _confirmDeleteObject(modalContext),
child: Text('Delete item'),
child: Text('Delete object'),
isDestructiveAction: true,
),
]
@ -152,80 +67,44 @@ class _ObjectScreenState extends State<ObjectScreen> {
}
Widget getObjectWidget() {
if (object == null) {
return Center(child: Column(
crossAxisAlignment: CrossAxisAlignment.center,
children: [CircularProgressIndicator()]
));
if (_object['isImage'] == true) {
return Image.network(_object['url']);
}
else if (object!['isImage'] == true && object!['url'] != null) {
print(object!['url']);
return Image.network(object!['url']);
}
else if (object!['type'] == 'pattern') {
if (pattern != null) {
return PatternViewer(pattern!, withEditor: true);
}
else if (object!['previewUrl'] != null) {
return Image.network(object!['previewUrl']!);;
}
else {
return Column(
children: [
SizedBox(height: 50),
Icon(Icons.pattern, size: 40),
SizedBox(height: 20),
Text('A preview of this pattern is not yet available'),
],
);
}
else if (_object['type'] == 'pattern') {
var dat = Uri.parse(_object['preview']).data;
return Image.memory(dat.contentAsBytes());
}
else {
return Center(child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Text('Treadl cannot display this type of item.'),
SizedBox(height: 20),
ElevatedButton(child: Text('View file'), onPressed: () {
launch(object!['url']);
}),
],
));
return RaisedButton(child: Text('View file'), onPressed: () {
launch(_object['url']);
});
}
}
@override
Widget build(BuildContext context) {
AppModel model = Provider.of<AppModel>(context);
User? user = model.user;
String description = '';
if (object?['description'] != null)
description = object!['description']!;
if (_object['description'] != null)
description = _object['description'];
return Scaffold(
appBar: AppBar(
title: Text(object?['name'] ?? 'Object'),
title: Text(_object['name']),
actions: <Widget>[
IconButton(
icon: Icon(Icons.ios_share),
onPressed: () {
_shareObject();
},
),
Util.canEditProject(user, object?['projectObject']) ? IconButton(
IconButton(
icon: Icon(Icons.settings),
onPressed: () {
_showSettingsModal(context);
},
) : SizedBox(height: 0),
),
]
),
body: Container(
margin: const EdgeInsets.all(10.0),
child: Column(
children: [
_isLoading ? LinearProgressIndicator() : SizedBox(height: 0),
Expanded(child: getObjectWidget()),
]
child: ListView(
children: <Widget>[
getObjectWidget(),
Html(data: description)
]
)
),
);
@ -233,11 +112,9 @@ class _ObjectScreenState extends State<ObjectScreen> {
}
class ObjectScreen extends StatefulWidget {
final String username;
final String projectPath;
final String id;
ObjectScreen(this.username, this.projectPath, this.id, ) { }
final Map<String,dynamic> _object;
final Function _onDelete;
ObjectScreen(this._object, this._onDelete) { }
@override
_ObjectScreenState createState() => _ObjectScreenState(username, projectPath, id);
_ObjectScreenState createState() => _ObjectScreenState(_object, _onDelete);
}

View File

@ -1,8 +1,6 @@
import 'package:flutter/material.dart';
import 'package:flutter/cupertino.dart';
import 'package:url_launcher/url_launcher.dart';
import 'package:firebase_messaging/firebase_messaging.dart';
import 'package:go_router/go_router.dart';
import 'api.dart';
class _OnboardingScreenState extends State<OnboardingScreen> {
@ -11,7 +9,7 @@ class _OnboardingScreenState extends State<OnboardingScreen> {
);
final Api api = Api();
bool _loading = false;
String? _pushToken;
String _pushToken;
@override
void dispose() {
@ -20,24 +18,26 @@ class _OnboardingScreenState extends State<OnboardingScreen> {
}
void _requestPushPermissions() async {
try {
setState(() => _loading = true);
FirebaseMessaging _firebaseMessaging = FirebaseMessaging.instance;
await _firebaseMessaging.requestPermission(
alert: true,
announcement: false,
badge: true,
carPlay: false,
criticalAlert: false,
provisional: false,
sound: true,
);
_pushToken = await _firebaseMessaging.getToken();
if (_pushToken != null) {
api.request('PUT', '/accounts/pushToken', {'pushToken': _pushToken!});
}
setState(() => _loading = true);
FirebaseMessaging _firebaseMessaging = FirebaseMessaging.instance;
await _firebaseMessaging.requestPermission(
alert: true,
announcement: false,
badge: true,
carPlay: false,
criticalAlert: false,
provisional: false,
sound: true,
);
_pushToken = await _firebaseMessaging.getToken();
/*final FirebaseMessaging _firebaseMessaging = FirebaseMessaging();
await _firebaseMessaging.requestNotificationPermissions(
const IosNotificationSettings(sound: true, badge: true, alert: true, provisional: false),
);
_pushToken = await _firebaseMessaging.getToken();*/
if (_pushToken != null) {
api.request('PUT', '/accounts/pushToken', {'pushToken': _pushToken});
}
on Exception { }
setState(() => _loading = false);
_controller.animateToPage(2, duration: Duration(milliseconds: 500), curve: Curves.easeInOut);
}
@ -57,15 +57,14 @@ class _OnboardingScreenState extends State<OnboardingScreen> {
children: <Widget>[
Text('Thanks for joining us! 🎉', style: TextStyle(color: Colors.white, fontSize: 20), textAlign: TextAlign.center),
SizedBox(height: 10),
Text('Treadl is a safe space for you to build your weaving projects.', style: TextStyle(color: Colors.white, fontSize: 15), textAlign: TextAlign.center),
Text('Treadl is a free and safe place for you to build your weaving projects.', style: TextStyle(color: Colors.white, fontSize: 15), textAlign: TextAlign.center),
SizedBox(height: 10),
Image(image: AssetImage('assets/folder.png'), width: 300),
SizedBox(height: 10),
Text('You can create as many projects as you like. Upload weaving draft patterns, images, and other files to your projects to store or showcase your work.', style: TextStyle(color: Colors.white, fontSize: 13), textAlign: TextAlign.center),
SizedBox(height: 20),
CupertinoButton(
color: Colors.white,
child: Text('OK, I know what projects are!', style: TextStyle(color: Colors.pink)),
Text('You can create as many projects as you like. Upload weaving draft patterns, images, and other files to your projects to store and showcase your work.', style: TextStyle(color: Colors.white, fontSize: 13), textAlign: TextAlign.center),
SizedBox(height: 10),
RaisedButton(
child: Text('OK, I know what projects are!'),
onPressed: () => _controller.animateToPage(1, duration: Duration(milliseconds: 500), curve: Curves.easeInOut),
)
]
@ -84,14 +83,13 @@ class _OnboardingScreenState extends State<OnboardingScreen> {
Text('Use groups for your classes, shared interest groups, or whatever you like!', style: TextStyle(color: Colors.white, fontSize: 13), textAlign: TextAlign.center),
SizedBox(height: 10),
Text('We recommend enabling push notifications so you can keep up-to-date with your groups and projects.', style: TextStyle(color: Colors.white, fontSize: 14, fontWeight: FontWeight.bold), textAlign: TextAlign.center),
SizedBox(height: 20),
CupertinoButton(
color: Colors.white,
SizedBox(height: 10),
RaisedButton(
child: Row(mainAxisAlignment: MainAxisAlignment.center, children: [
_loading ? CircularProgressIndicator() : SizedBox(width: 0),
_loading ? SizedBox(width: 10) : SizedBox(width: 0),
Text('Continue', style: TextStyle(color: Colors.pink)),
]),
_loading ? CircularProgressIndicator() : null,
_loading ? SizedBox(width: 5) : null,
Text('What\'s next?'),
].where((o) => o != null).toList()),
onPressed: _requestPushPermissions,
)
]
@ -103,16 +101,15 @@ class _OnboardingScreenState extends State<OnboardingScreen> {
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: <Widget>[
Text('That\'s it for now!', style: TextStyle(color: Colors.white, fontSize: 25), textAlign: TextAlign.center),
Text('That\'s it for now!', style: TextStyle(color: Colors.white, fontSize: 15), textAlign: TextAlign.center),
SizedBox(height: 10),
Image(image: AssetImage('assets/completed.png'), width: 300),
SizedBox(height: 10),
Text('You\'re ready to get started. We hope you enjoy using Treadl.', style: TextStyle(color: Colors.white, fontSize: 13), textAlign: TextAlign.center),
SizedBox(height: 20),
CupertinoButton(
color: Colors.white,
child: Text('Get started', style: TextStyle(color: Colors.pink)),
onPressed: () => context.go('/home'),
Text('You\'re ready to get started. If you have any questions or want to get in touch then just send us a quick tweet.', style: TextStyle(color: Colors.white, fontSize: 13), textAlign: TextAlign.center),
SizedBox(height: 10),
RaisedButton(
child: Text('Let\'s go'),
onPressed: () => Navigator.of(context).pushNamedAndRemoveUntil('/home', (Route<dynamic> route) => false),
),
]
)

View File

@ -1,75 +0,0 @@
import 'package:flutter/material.dart';
import 'dart:ui' as ui;
import '../util.dart';
class DrawdownPainter extends CustomPainter {
final Map<String,dynamic> pattern;
final double BASE_SIZE;
@override
DrawdownPainter(this.BASE_SIZE, this.pattern) {}
@override
void paint(Canvas canvas, Size size) {
var weft = pattern['weft'];
var warp = pattern['warp'];
var tieups = pattern['tieups'];
var paint = Paint()
..color = Colors.black
..strokeWidth = 1;
// Draw grid
for (double i = 0; i <= size.width; i += BASE_SIZE) {
canvas.drawLine(Offset(i.toDouble(), size.height), Offset(i.toDouble(), 0), paint);
}
for (double y = 0; y <= size.height; y += BASE_SIZE) {
canvas.drawLine(Offset(0, y.toDouble()), Offset(size.width, y.toDouble()), paint);
}
for (int tread = 0; tread < weft['treadling']?.length; tread++) {
for (int thread = 0; thread < warp['threading']?.length; thread++) {
// Ensure we only get a treadle in the allowed bounds
int treadle = weft['treadling'][tread]['treadle'] > weft['treadles'] ? 0 : weft['treadling'][tread]['treadle'];
int shaft = warp['threading'][thread]['shaft'];
Color weftColour = Util.rgb(weft['treadling'][tread]['colour'] ?? weft['defaultColour']);
Color warpColour = Util.rgb(warp['threading'][thread]['colour'] ?? warp['defaultColour']);
// Only capture valid tie-ups (e.g. in case there is data for more shafts, which are then reduced)
// Dart throws error if index < 0 so check fiest
List<dynamic> tieup = treadle > 0 ? tieups[treadle - 1] : [];
List<dynamic> filteredTieup = tieup.where((t) => t< warp['shafts']).toList();
String threadType = filteredTieup.contains(shaft) ? 'warp' : 'weft';
Rect rect = Offset(
size.width - BASE_SIZE * (thread + 1),
tread * BASE_SIZE
) & Size(BASE_SIZE, BASE_SIZE);
canvas.drawRect(
rect,
Paint()
..color = threadType == 'warp' ? warpColour : weftColour
);
canvas.drawRect(
rect,
Paint()
..shader = ui.Gradient.linear(
threadType == 'warp' ? rect.centerLeft : rect.topCenter,
threadType == 'warp' ? rect.centerRight : rect.bottomCenter,
[
Color.fromRGBO(0,0,0,0.4),
Color.fromRGBO(0,0,0,0.0),
Color.fromRGBO(0,0,0,0.4),
],
[0.0,0.5,1.0],
)
);
}
}
}
@override
bool shouldRepaint(CustomPainter oldDelegate) {
return false;
}
}

View File

@ -1,102 +0,0 @@
import 'package:flutter/material.dart';
import 'warp.dart';
import 'weft.dart';
import 'tieup.dart';
import 'drawdown.dart';
class Pattern extends StatelessWidget {
final Map<String,dynamic> pattern;
final Function? onUpdate;
final double BASE_SIZE = 5;
@override
Pattern(this.pattern, {this.onUpdate}) {}
@override
Widget build(BuildContext context) {
var warp = pattern['warp'];
var weft = pattern['weft'];
double draftWidth = warp['threading']?.length * BASE_SIZE + weft['treadles'] * BASE_SIZE + BASE_SIZE;
double draftHeight = warp['shafts'] * BASE_SIZE + weft['treadling']?.length * BASE_SIZE + BASE_SIZE;
double tieupTop = BASE_SIZE;
double tieupRight = BASE_SIZE;
double tieupWidth = weft['treadles'] * BASE_SIZE;
double tieupHeight = warp['shafts'] * BASE_SIZE;
double warpTop = 0;
double warpRight = weft['treadles'] * BASE_SIZE + BASE_SIZE * 2;
double warpWidth = warp['threading']?.length * BASE_SIZE;
double warpHeight = warp['shafts'] * BASE_SIZE + BASE_SIZE;
double weftRight = 0;
double weftTop = warp['shafts'] * BASE_SIZE + BASE_SIZE * 2;
double weftWidth = weft['treadles'] * BASE_SIZE + BASE_SIZE;
double weftHeight = weft['treadling'].length * BASE_SIZE;
double drawdownTop = warpHeight + BASE_SIZE;
double drawdownRight = weftWidth + BASE_SIZE;
double drawdownWidth = warpWidth;
double drawdownHeight = weftHeight;
return Container(
width: draftWidth,
height: draftHeight,
child: Stack(
children: [
Positioned(
right: tieupRight,
top: tieupTop,
child: GestureDetector(
onTapDown: (details) {
var tieups = pattern['tieups'];
double dx = details.localPosition.dx;
double dy = details.localPosition.dy;
int tie = (dx / BASE_SIZE).toInt();
int shaft = ((tieupHeight - dy) / BASE_SIZE).toInt() + 1;
if (tieups[tie].contains(shaft)) {
tieups[tie].remove(shaft);
} else {
tieups[tie].add(shaft);
}
print(tieups);
if (onUpdate != null) {
onUpdate!({'tieups': tieups});
}
// Toggle tieups[tie][shaft]
},
child: CustomPaint(
size: Size(tieupWidth, tieupHeight),
painter: TieupPainter(BASE_SIZE, this.pattern),
)),
),
Positioned(
right: warpRight,
top: warpTop,
child: CustomPaint(
size: Size(warpWidth, warpHeight),
painter: WarpPainter(BASE_SIZE, this.pattern),
),
),
Positioned(
right: weftRight,
top: weftTop,
child: CustomPaint(
size: Size(weftWidth, weftHeight),
painter: WeftPainter(BASE_SIZE, this.pattern),
),
),
Positioned(
right: drawdownRight,
top: drawdownTop,
child: CustomPaint(
size: Size(drawdownWidth, drawdownHeight),
painter: DrawdownPainter(BASE_SIZE, this.pattern),
),
)
]
)
);
}
}

View File

@ -1,41 +0,0 @@
import 'package:flutter/material.dart';
class TieupPainter extends CustomPainter {
final Map<String,dynamic> pattern;
final double BASE_SIZE;
@override
TieupPainter(this.BASE_SIZE, this.pattern) {}
@override
void paint(Canvas canvas, Size size) {
var tieup = pattern['tieups'];
var paint = Paint()
..color = Colors.black..strokeWidth = 0.5;
// Draw grid
for (double i = 0; i <= size.width; i += BASE_SIZE) {
canvas.drawLine(Offset(i.toDouble(), size.height), Offset(i.toDouble(), 0), paint);
}
for (double y = 0; y <= size.height; y += BASE_SIZE) {
canvas.drawLine(Offset(0, y.toDouble()), Offset(size.width, y.toDouble()), paint);
}
for (var i = 0; i < tieup.length; i++) {
List<dynamic>? tie = tieup[i];
if (tie != null) {
for (var j = 0; j < tie!.length; j++) {
canvas.drawRect(
Offset(i.toDouble()*BASE_SIZE, size.height - (tie[j]*BASE_SIZE)) &
Size(BASE_SIZE.toDouble(), BASE_SIZE.toDouble()),
paint);
}
}
}
}
@override
bool shouldRepaint(CustomPainter oldDelegate) {
return false;
}
}

View File

@ -1,68 +0,0 @@
import 'package:flutter/material.dart';
import 'pattern.dart';
class PatternViewer extends StatefulWidget {
final Map<String,dynamic> pattern;
final bool withEditor;
PatternViewer(this.pattern, {this.withEditor = false}) {}
@override
State<PatternViewer> createState() => _PatternViewerState(this.pattern, this.withEditor);
}
class _PatternViewerState extends State<PatternViewer> {
Map<String,dynamic> pattern;
final bool withEditor;
bool controllerInitialised = false;
final controller = TransformationController();
final double BASE_SIZE = 5;
_PatternViewerState(this.pattern, this.withEditor) {}
void updatePattern(update) {
setState(() {
pattern!.addAll(update);
});
}
@override
Widget build(BuildContext context) {
if (!controllerInitialised) {
var warp = pattern['warp'];
var weft = pattern['weft'];
double draftWidth = warp['threading']?.length * BASE_SIZE + weft['treadles'] * BASE_SIZE + BASE_SIZE;
final zoomFactor = 1.0;
final xTranslate = draftWidth - MediaQuery.of(context).size.width - 0;
final yTranslate = 0.0;
controller.value.setEntry(0, 0, zoomFactor);
controller.value.setEntry(1, 1, zoomFactor);
controller.value.setEntry(2, 2, zoomFactor);
controller.value.setEntry(0, 3, -xTranslate);
controller.value.setEntry(1, 3, -yTranslate);
setState(() => controllerInitialised = true);
}
return InteractiveViewer(
minScale: 0.5,
maxScale: 5,
constrained: false,
transformationController: controller,
child: RepaintBoundary(child: Pattern(pattern))
);
/*return Column(
children: [
Text('Hi'),
Expanded(child: InteractiveViewer(
minScale: 0.5,
maxScale: 5,
constrained: false,
transformationController: controller,
child: RepaintBoundary(child: Pattern(pattern))))
,
Text('Another'),
]
);*/
}
}

View File

@ -1,65 +0,0 @@
import 'package:flutter/material.dart';
import '../util.dart';
class WarpPainter extends CustomPainter {
final Map<String,dynamic> pattern;
final double BASE_SIZE;
@override
WarpPainter(this.BASE_SIZE, this.pattern) {}
@override
void paint(Canvas canvas, Size size) {
var warp = pattern['warp'];
var paint = Paint()
..color = Colors.black
..strokeWidth = 0.5;
var thickPaint = Paint()
..color = Colors.black
..strokeWidth = 1.5;
// Draw grid
int columnsPainted = 0;
for (double i = size.width; i >= 0; i -= BASE_SIZE) {
canvas.drawLine(Offset(i.toDouble(), size.height), Offset(i.toDouble(), 0), paint);
columnsPainted += 1;
}
for (double y = 0; y <= size.height; y += BASE_SIZE) {
canvas.drawLine(Offset(0, y.toDouble()), Offset(size.width, y.toDouble()), paint);
}
// Draw threads
for (var i = 0; i < warp['threading'].length; i++) {
var thread = warp['threading'][i];
int? shaft = thread?['shaft'];
String? colour = warp['defaultColour'];
double x = size.width - (i+1)*BASE_SIZE;
if (shaft != null) {
if (shaft! > 0) {
canvas.drawRect(
Offset(x, size.height - shaft!*BASE_SIZE) &
Size(BASE_SIZE.toDouble(), BASE_SIZE.toDouble()),
paint
);
}
}
if (thread?['colour'] != null) {
colour = thread!['colour'];
}
if (colour != null) {
canvas.drawRect(
Offset(x, 0) &
Size(BASE_SIZE.toDouble(), BASE_SIZE.toDouble()),
Paint()
..color = Util.rgb(colour!)
);
}
}
}
@override
bool shouldRepaint(CustomPainter oldDelegate) {
return false;
}
}

View File

@ -1,61 +0,0 @@
import 'package:flutter/material.dart';
import '../util.dart';
class WeftPainter extends CustomPainter {
final Map<String,dynamic> pattern;
final double BASE_SIZE;
@override
WeftPainter(this.BASE_SIZE, this.pattern) {}
@override
void paint(Canvas canvas, Size size) {
var weft = pattern['weft'];
var paint = Paint()
..color = Colors.black
..strokeWidth = 0.5;
var thickPaint = Paint()
..color = Colors.black
..strokeWidth = 1.5;
// Draw grid
int rowsPainted = 0;
for (double i = 0; i <= size.width; i += BASE_SIZE) {
canvas.drawLine(Offset(i.toDouble(), size.height), Offset(i.toDouble(), 0), paint);
}
for (double y = 0; y <= size.height; y += BASE_SIZE) {
canvas.drawLine(Offset(0, y.toDouble()), Offset(size.width, y.toDouble()), paint);
rowsPainted += 1;
}
for (var i = 0; i < weft['treadling'].length; i++) {
var thread = weft['treadling'][i];
int? treadle = thread?['treadle'];
String? colour = weft['defaultColour'];
double y = i.toDouble()*BASE_SIZE;
if (treadle != null && treadle! > 0) {
canvas.drawRect(
Offset((treadle!.toDouble()-1)*BASE_SIZE, y) &
Size(BASE_SIZE.toDouble(), BASE_SIZE.toDouble()),
paint
);
}
if (thread?['colour'] != null) {
colour = thread!['colour'];
}
if (colour != null) {
canvas.drawRect(
Offset(size.width - BASE_SIZE, y) &
Size(BASE_SIZE.toDouble(), BASE_SIZE.toDouble()),
Paint()
..color = Util.rgb(colour!)
);
}
}
}
@override
bool shouldRepaint(CustomPainter oldDelegate) {
return false;
}
}

View File

@ -2,432 +2,30 @@ import 'package:flutter/material.dart';
import 'package:flutter/cupertino.dart';
import 'package:provider/provider.dart';
import 'package:image_picker/image_picker.dart';
import 'package:file_picker/file_picker.dart';
import 'package:flutter_expandable_fab/flutter_expandable_fab.dart';
import 'package:go_router/go_router.dart';
import 'package:intl/intl.dart';
import 'dart:io';
import 'api.dart';
import 'util.dart';
import 'model.dart';
import 'lib.dart';
class _ProjectScreenState extends State<ProjectScreen> {
final String username;
final String projectPath;
final String fullPath;
final Function? onUpdate;
final Function? onDelete;
final picker = ImagePicker();
final Api api = Api();
Map<String,dynamic>? project;
List<dynamic> _objects = [];
bool _loading = false;
Map<String,dynamic>? _creatingObject;
_ProjectScreenState(this.username, this.projectPath, {this.project, this.onUpdate, this.onDelete}) :
fullPath = username + '/' + projectPath;
@override
initState() {
super.initState();
getProject(fullPath);
getObjects(fullPath);
}
void getProject(String fullName) async {
setState(() => _loading = true);
var data = await api.request('GET', '/projects/' + fullName);
if (data['success'] == true) {
setState(() {
project = data['payload'];
_loading = false;
});
}
}
void getObjects(String fullName) async {
setState(() => _loading = true);
var data = await api.request('GET', '/projects/' + fullName + '/objects');
if (data['success'] == true) {
setState(() {
_objects = data['payload']['objects'];
_loading = false;
});
}
}
void _shareProject() {
Util.shareUrl('Check out my project on Treadl', Util.appUrl(fullPath));
}
void _onDeleteProject() {
context.pop();
onDelete!(project!['_id']);
}
void _onUpdateProject(project) {
setState(() {
project = project;
});
onUpdate!(project!['_id'], project!);
}
void _onUpdateObject(String id, Map<String,dynamic> update) {
List<dynamic> _newObjects = _objects.map((o) {
if (o['_id'] == id) {
o.addAll(update);
}
return o;
}).toList();
setState(() {
_objects = _newObjects;
});
}
void _onDeleteObject(String id) {
List<dynamic> _newObjects = _objects.where((p) => p['_id'] != id).toList();
setState(() {
_objects = _newObjects;
});
}
void _createObject(objectData) async {
var resp = await api.request('POST', '/projects/$fullPath/objects', objectData);
setState(() => _creatingObject = null);
if (resp['success']) {
List<dynamic> newObjects = _objects;
newObjects.add(resp['payload']);
setState(() {
_objects = newObjects;
});
}
}
void _createObjectFromWif(String name, String wif) {
setState(() => _creatingObject = {
'name': name,
'type': 'pattern',
});
_createObject({
'name': name,
'type': 'pattern',
'wif': wif,
});
}
void _createObjectFromFile(String name, XFile file) async {
final int size = await file.length();
final String forId = project!['_id'];
final String type = file.mimeType ?? 'text/plain';
setState(() => _creatingObject = {
'name': name,
'type': 'file',
});
var data = await api.request('GET', '/uploads/file/request?name=$name&size=$size&type=$type&forType=project&forId=$forId');
if (!data['success']) {
setState(() => _creatingObject = null);
return;
}
var uploadSuccess = await api.putFile(data['payload']['signedRequest'], File(file.path), type);
if (!uploadSuccess) {
setState(() => _creatingObject = null);
return;
}
_createObject({
'name': name,
'storedName': data['payload']['fileName'],
'type': 'file',
});
}
void _chooseFile() async {
FilePickerResult? result = await FilePicker.platform.pickFiles();
if (result != null) {
PlatformFile file = result.files.single;
XFile xFile = XFile(file.path!);
String? ext = file.extension;
if (ext != null && ext!.toLowerCase() == 'wif' || xFile.name.toLowerCase().contains('.wif')) {
final String contents = await xFile.readAsString();
_createObjectFromWif(file.name, contents);
} else {
_createObjectFromFile(file.name, xFile);
}
}
}
void _chooseImage() async {
File file;
try {
final XFile? imageFile = await picker.pickImage(source: ImageSource.gallery);
if (imageFile == null) return;
final f = new DateFormat('yyyy-MM-dd_hh-mm-ss');
String time = f.format(new DateTime.now());
String name = project!['name'] + ' ' + time + '.' + imageFile.name.split('.').last;
_createObjectFromFile(name, imageFile);
}
on Exception {
showDialog(
context: context,
builder: (BuildContext context) => CupertinoAlertDialog(
title: Text('Treadl needs access'),
content: Text('To add objects to this project you need to give Treadl access to your photos in your phone\'s settings.'),
actions: <Widget>[
CupertinoDialogAction(
isDefaultAction: true,
child: Text('OK'),
onPressed: () => context.pop(),
),
],
)
);
}
}
void showSettingsModal() {
Widget settingsDialog = new _ProjectSettingsDialog(project!, _onDeleteProject, _onUpdateProject);
showCupertinoModalPopup(context: context, builder: (BuildContext context) => settingsDialog);
}
Widget getNetworkImageBox(String url) {
return new AspectRatio(
aspectRatio: 1 / 1,
child: new Container(
decoration: new BoxDecoration(
borderRadius: BorderRadius.circular(10.0),
image: new DecorationImage(
fit: BoxFit.cover,
alignment: FractionalOffset.topCenter,
image: new NetworkImage(url),
)
),
),
);
}
Widget getIconBox(Icon icon) {
return new AspectRatio(
aspectRatio: 1 / 1,
child: new Container(
decoration: new BoxDecoration(
color: Colors.grey[100],
borderRadius: BorderRadius.circular(10.0),
),
child: icon
),
);
}
Widget getObjectCard(int index) {
Map<String,dynamic>? objectToShow;
if (index >= _objects.length) {
objectToShow = _creatingObject;
objectToShow!['creating'] = true;
} else {
objectToShow = _objects[index];
}
Map<String,dynamic> object = objectToShow!;
Widget leader;
String type;
if (object['isImage'] == true) {
type = 'Image';
if (object['url'] != null) {
leader = getNetworkImageBox(object['url']!);
}
else {
leader = getIconBox(Icon(Icons.photo));
}
}
else if (object['type'] == 'pattern') {
type = 'Weaving pattern';
if (object['previewUrl'] != null) {
leader = getNetworkImageBox(object['previewUrl']!);
}
else {
leader = getIconBox(Icon(Icons.pattern));
}
}
else if (object['type'] == 'file') {
type = 'File';
leader = getIconBox(Icon(Icons.insert_drive_file));
}
else {
type = 'Unknown';
leader = getIconBox(Icon(Icons.file_present));
}
if (object['creating'] == true) {
leader = CircularProgressIndicator();
}
return new Card(
child: InkWell(
onTap: () {
context.push('/' + username + '/' + projectPath + '/' + object['_id']);
},
child: ListTile(
leading: leader,
trailing: Icon(Icons.keyboard_arrow_right),
title: Text(object['name']),
subtitle: Text(type),
),
)
);
}
Widget getBody() {
if (_loading || project == null)
return CircularProgressIndicator();
else if ((_objects != null && _objects.length > 0) || _creatingObject != null)
return ListView.builder(
itemCount: _objects.length + (_creatingObject != null ? 1 : 0),
itemBuilder: (BuildContext context, int index) {
return getObjectCard(index);
},
);
else
return EmptyBox('This project is currently empty', description: 'If this is your project, you can add a pattern file, an image, or something else to this project using the + button below.');
}
@override
Widget build(BuildContext context) {
AppModel model = Provider.of<AppModel>(context);
User? user = model.user;
return Scaffold(
appBar: AppBar(
title: Text(project?['name'] ?? 'Project'),
actions: <Widget>[
IconButton(
icon: Icon(Icons.ios_share),
onPressed: () {
_shareProject();
},
),
onUpdate != null ? IconButton(
icon: Icon(Icons.settings),
onPressed: () {
showSettingsModal();
},
) : SizedBox(width: 0),
]
),
body: Container(
margin: const EdgeInsets.all(10.0),
alignment: Alignment.center,
child: getBody(),
),
floatingActionButtonLocation: ExpandableFab.location,
floatingActionButton: Util.canEditProject(user, project) ? ExpandableFab(
distance: 70,
type: ExpandableFabType.up,
openButtonBuilder: RotateFloatingActionButtonBuilder(
child: const Icon(Icons.add),
),
children: [
Row(children:[
Container(
padding: EdgeInsets.all(5),
decoration: BoxDecoration(
color: Colors.grey[800],
borderRadius: BorderRadius.all(Radius.circular(12)),
),
child: Text('Add an image', style: TextStyle(fontSize: 15, color: Colors.white)),
),
SizedBox(width: 10),
FloatingActionButton(
heroTag: null,
onPressed: _chooseImage,
child: Icon(Icons.image_outlined),
),
]),
Row(children:[
Container(
padding: EdgeInsets.all(5),
decoration: BoxDecoration(
color: Colors.grey[800],
borderRadius: BorderRadius.all(Radius.circular(12)),
),
child: Text('Add a WIF or other file', style: TextStyle(fontSize: 15, color: Colors.white)),
),
SizedBox(width: 10),
FloatingActionButton(
heroTag: null,
child: const Icon(Icons.insert_drive_file_outlined),
onPressed: _chooseFile,
),
]),
],
) : null,
);
}
}
class ProjectScreen extends StatefulWidget {
final String username;
final String projectPath;
final Map<String,dynamic>? project;
final Function? onUpdate;
final Function? onDelete;
ProjectScreen(this.username, this.projectPath, {this.project, this.onUpdate, this.onDelete}) { }
@override
_ProjectScreenState createState() => _ProjectScreenState(username, projectPath, project: project, onUpdate: onUpdate, onDelete: onDelete);
}
import 'object.dart';
class _ProjectSettingsDialog extends StatelessWidget {
final String fullPath;
final Map<String,dynamic> project;
final Map<String,dynamic> _project;
final Function _onDelete;
final Function _onUpdateProject;
final Api api = Api();
_ProjectSettingsDialog(this.project, this._onDelete, this._onUpdateProject) :
fullPath = project['owner']['username'] + '/' + project['path'];
void _renameProject(BuildContext context) async {
TextEditingController renameController = TextEditingController();
showDialog(
context: context,
builder: (context) {
return AlertDialog(
title: Text('Rename your project'),
content: TextField(
autofocus: true,
controller: renameController,
decoration: InputDecoration(hintText: "Enter a new name for the project"),
),
actions: <Widget>[
TextButton(
child: Text('CANCEL'),
onPressed: () {
context.pop();
},
),
TextButton(
child: Text('OK'),
onPressed: () async {
var data = await api.request('PUT', '/projects/' + fullPath, {'name': renameController.text});
if (data['success']) {
context.pop();
_onUpdateProject(data['payload']);
}
context.pop();
},
),
],
);
},
);
}
_ProjectSettingsDialog(this._project, this._onDelete, this._onUpdateProject) {}
void _toggleVisibility(BuildContext context, bool checked) async {
var data = await api.request('PUT', '/projects/' + fullPath, {'visibility': checked ? 'private': 'public'});
var data = await api.request('PUT', '/projects/' + _project['owner']['username'] + '/' + _project['path'], {'visibility': checked ? 'private': 'public'});
if (data['success']) {
context.pop();
Navigator.pop(context);
_onUpdateProject(data['payload']);
}
}
void _deleteProject(BuildContext context, BuildContext modalContext) async {
var data = await api.request('DELETE', '/projects/' + fullPath);
var data = await api.request('DELETE', '/projects/' + _project['owner']['username'] + '/' + _project['path']);
if (data['success']) {
context.pop();
context.pop();
Navigator.pop(context);
Navigator.pop(modalContext);
_onDelete();
}
}
@ -442,7 +40,7 @@ class _ProjectSettingsDialog extends StatelessWidget {
CupertinoDialogAction(
isDefaultAction: true,
child: Text('No'),
onPressed: () => context.pop(),
onPressed: () => Navigator.pop(context),
),
CupertinoDialogAction(
isDestructiveAction: true,
@ -459,7 +57,7 @@ class _ProjectSettingsDialog extends StatelessWidget {
return CupertinoActionSheet(
title: Text('Manage this project'),
cancelButton: CupertinoActionSheetAction(
onPressed: () => context.pop(),
onPressed: () => Navigator.of(context).pop(),
child: Text('Cancel')
),
actions: [
@ -469,7 +67,7 @@ class _ProjectSettingsDialog extends StatelessWidget {
mainAxisAlignment: MainAxisAlignment.center,
children: [
CupertinoSwitch(
value: project?['visibility'] == 'private',
value: _project['visibility'] == 'private',
onChanged: (c) => _toggleVisibility(context, c),
),
SizedBox(width: 10),
@ -477,10 +75,6 @@ class _ProjectSettingsDialog extends StatelessWidget {
]
)
),
CupertinoActionSheetAction(
onPressed: () { _renameProject(context); },
child: Text('Rename project'),
),
CupertinoActionSheetAction(
onPressed: () { _confirmDeleteProject(context); },
child: Text('Delete project'),
@ -490,3 +84,243 @@ class _ProjectSettingsDialog extends StatelessWidget {
);
}
}
class _ProjectScreenState extends State<ProjectScreen> {
final Function _onDelete;
final picker = ImagePicker();
final Api api = Api();
Map<String,dynamic> _project;
List<dynamic> _objects = [];
bool _loading = false;
bool _creating = false;
_ProjectScreenState(this._project, this._onDelete) { }
@override
initState() {
super.initState();
getObjects(_project['fullName']);
}
void getObjects(String fullName) async {
setState(() => _loading = true);
print(fullName);
var data = await api.request('GET', '/projects/' + fullName + '/objects');
if (data['success'] == true) {
setState(() {
_objects = data['payload']['objects'];
_loading = false;
});
}
}
void _onDeleteProject() {
Navigator.pop(context);
_onDelete(_project['_id']);
}
void _onUpdateProject(project) {
setState(() {
_project = project;
});
}
void _onDeleteObject(String id) {
List<dynamic> _newObjects = _objects.where((p) => p['_id'] != id).toList();
setState(() {
_objects = _newObjects;
});
}
void _chooseImage() async {
File file;
try {
final imageFile = await picker.getImage(source: ImageSource.gallery);
if (imageFile == null) return;
file = File(imageFile.path);
}
on Exception {
showDialog(
context: context,
builder: (BuildContext context) => CupertinoAlertDialog(
title: Text('Treadl needs access'),
content: Text('To add objects to this project you need to give Treadl access to your photos in your phone\'s settings.'),
actions: <Widget>[
CupertinoDialogAction(
isDefaultAction: true,
child: Text('OK'),
onPressed: () => Navigator.pop(context),
),
],
)
);
return;
}
final int size = await file.length();
final String forId = _project['_id'];
final String fullPath = _project['owner']['username'] + '/' + _project['path'];
final String name = file.path.split('/').last;
final String ext = name.split('.').last;
final String type = 'image/jpeg';//$ext';
setState(() => _creating = true);
var data = await api.request('GET', '/uploads/file/request?name=$name&size=$size&type=$type&forType=project&forId=$forId');
print(data);
if (!data['success']) {
setState(() => _creating = false);
return;
}
var uploadSuccess = await api.putFile(data['payload']['signedRequest'], file, type);
print(uploadSuccess);
if (!uploadSuccess) {
setState(() => _creating = false);
return;
}
var newObjectData = {
'name': name,
'storedName': data['payload']['fileName'],
'type': 'file',
};
var objectData = await api.request('POST', '/projects/$fullPath/objects', newObjectData);
setState(() => _creating = false);
if (objectData['success']) {
List<dynamic> newObjects = _objects;
newObjects.add(objectData['payload']);
setState(() {
_objects = newObjects;
});
}
}
void showSettingsModal() {
Widget settingsDialog = new _ProjectSettingsDialog(_project, _onDeleteProject, _onUpdateProject);
showCupertinoModalPopup(context: context, builder: (BuildContext context) => settingsDialog);
}
Widget getImageBox(data, [bool isMemory, bool isNetwork]) {
return new AspectRatio(
aspectRatio: 1 / 1,
child: new Container(
decoration: new BoxDecoration(
image: new DecorationImage(
fit: BoxFit.fitWidth,
alignment: FractionalOffset.topCenter,
image: isMemory == true ? new MemoryImage(data) : new NetworkImage(data)
)
),
),
);
}
Widget getIconBox(Icon icon) {
return new AspectRatio(
aspectRatio: 1 / 1,
child: icon
);
}
Widget getObjectCard(int index) {
if (index >= _objects.length) {
return new Card(
child: Container(
padding: EdgeInsets.all(10),
child: Center(child:CircularProgressIndicator())
)
);
}
var object = _objects[index];
Widget leader;
String type;
if (object['isImage'] == true) {
type = 'Image';
leader = getImageBox(object['url']);
}
else if (object['type'] == 'pattern' && object['preview'] != null) {
type = 'Weaving pattern';
var dat = Uri.parse(object['preview']).data;
leader = getImageBox(dat.contentAsBytes(), true);
}
else if (object['type'] == 'file') {
type = 'File';
leader = getIconBox(Icon(Icons.insert_drive_file));
}
return new Card(
child: InkWell(
onTap: () {
Navigator.push(
context,
MaterialPageRoute(
builder: (context) => ObjectScreen(object, _onDeleteObject),
),
);
},
child: Column(
mainAxisSize: MainAxisSize.min,
children: <Widget>[
new ListTile(
leading: leader,
trailing: Icon(Icons.keyboard_arrow_right),
title: Text(object['name']),
subtitle: Text(type),
),
]
)
)
);
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: Text(_project['name']),
actions: <Widget>[
IconButton(
icon: Icon(Icons.settings),
onPressed: () {
showSettingsModal();
},
),
]
),
body: _loading ?
Container(
margin: const EdgeInsets.all(10.0),
alignment: Alignment.center,
child: CircularProgressIndicator()
)
: Container(
margin: const EdgeInsets.all(10.0),
child: ((_objects != null && _objects.length > 0) || _creating) ?
ListView.builder(
itemCount: _objects.length + (_creating ? 1 : 0),
itemBuilder: (BuildContext context, int index) {
return getObjectCard(index);
},
)
:
Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
mainAxisAlignment: MainAxisAlignment.center,
children: [
Text('This project is currently empty', style: TextStyle(fontSize: 20), textAlign: TextAlign.center),
Image(image: AssetImage('assets/empty.png'), width: 300),
Text('Add something to this project using the button below.', textAlign: TextAlign.center),
])
),
floatingActionButton: FloatingActionButton(
onPressed: _chooseImage,
child: Icon(Icons.cloud_upload),
backgroundColor: Colors.pink[500],
),
);
}
}
class ProjectScreen extends StatefulWidget {
final Map<String,dynamic> _project;
final Function _onDelete;
ProjectScreen(this._project, this._onDelete) { }
@override
_ProjectScreenState createState() => _ProjectScreenState(_project, _onDelete);
}

View File

@ -1,170 +1,10 @@
import 'package:flutter/material.dart';
import 'package:flutter/cupertino.dart';
import 'package:provider/provider.dart';
import 'package:go_router/go_router.dart';
import 'routeArguments.dart';
import 'api.dart';
import 'model.dart';
import 'lib.dart';
class _ProjectsTabState extends State<ProjectsTab> {
List<dynamic> _projects = [];
bool _loading = false;
bool _creatingProject = false;
final Api api = Api();
@override
initState() {
super.initState();
getProjects();
}
void getProjects() async {
AppModel model = Provider.of<AppModel>(context, listen: false);
if (model.user == null) return;
setState(() {
_loading = true;
});
var data = await api.request('GET', '/users/me/projects');
if (data['success'] == true) {
setState(() {
_projects = data['payload']['projects'];
_loading = false;
});
}
}
void _onCreatingProject() {
setState(() {
_creatingProject = true;
});
}
void _onCreateProject(newProject) {
List<dynamic> _newProjects = _projects;
_newProjects.insert(0, newProject);
setState(() {
_projects = _newProjects;
_creatingProject = false;
});
}
void _onUpdateProject(String id, Map<String,dynamic> update) {
List<dynamic> _newProjects = _projects.map((p) {
if (p['_id'] == id) {
p.addAll(update);
}
return p;
}).toList();
setState(() {
_projects = _newProjects;
});
}
void _onDeleteProject(String id) {
List<dynamic> _newProjects = _projects.where((p) => p['_id'] != id).toList();
setState(() {
_projects = _newProjects;
});
}
void showNewProjectDialog() async {
Widget simpleDialog = new _NewProjectDialog(_onCreatingProject, _onCreateProject);
showDialog(context: context, builder: (BuildContext context) => simpleDialog);
}
Widget buildProjectCard(Map<String,dynamic> project) {
String description = project['description'] != null ? project['description'].replaceAll("\n", " ") : '';
if (description != null && description.length > 80) {
description = description.substring(0, 77) + '...';
}
if (project['visibility'] == 'public') {
description = "PUBLIC PROJECT\n" + description;
}
else description = "PRIVATE PROJECT\n" + description;
return new Card(
child: InkWell(
onTap: () {
context.push('/' + project['owner']['username'] + '/' + project['path']);
},
child: Container(
padding: EdgeInsets.all(5),
child: ListTile(
leading: new AspectRatio(
aspectRatio: 1 / 1,
child: new Container(
decoration: new BoxDecoration(
color: Colors.grey[100],
borderRadius: BorderRadius.circular(10.0),
),
child: Icon(Icons.folder, color: Colors.pink[300])
),
),
trailing: Icon(Icons.keyboard_arrow_right),
title: Text(project['name'] != null ? project['name'] : 'Untitled project'),
subtitle: Text(description),
),
))
)
;
}
Widget getBody() {
AppModel model = Provider.of<AppModel>(context);
if (model.user == null)
return LoginNeeded(text: 'Once logged in, you\'ll find your own projects shown here.');
if (_loading)
return CircularProgressIndicator();
else if (_projects != null && _projects.length > 0)
return ListView.builder(
itemCount: _projects.length,
itemBuilder: (BuildContext context, int index) {
return buildProjectCard(_projects[index]);
},
);
else return Column(
crossAxisAlignment: CrossAxisAlignment.center,
mainAxisAlignment: MainAxisAlignment.center,
children: [
Text('Create your first project', style: TextStyle(fontSize: 20), textAlign: TextAlign.center),
Image(image: AssetImage('assets/reading.png'), width: 300),
Text('Projects contain all the files and patterns that make up a piece of work. Create one using the + button below.', textAlign: TextAlign.center),
]
);
}
@override
Widget build(BuildContext context) {
AppModel model = Provider.of<AppModel>(context);
User? user = model.user;
return Scaffold(
appBar: AppBar(
title: Text('My Projects'),
actions: <Widget>[
IconButton(
icon: Icon(Icons.info_outline),
onPressed: () {
context.push('/settings');
},
),
]
),
body: Container(
margin: const EdgeInsets.all(10.0),
alignment: Alignment.center,
child: getBody()
),
floatingActionButton: user != null ? FloatingActionButton(
onPressed: showNewProjectDialog,
child: _creatingProject ? CircularProgressIndicator(backgroundColor: Colors.white) : Icon(Icons.add),
backgroundColor: Colors.pink[500],
) : null,
);
}
}
class ProjectsTab extends StatefulWidget {
@override
_ProjectsTabState createState() => _ProjectsTabState();
}
import 'project.dart';
import 'settings.dart';
class _NewProjectDialogState extends State<_NewProjectDialog> {
final TextEditingController _newProjectNameController = TextEditingController();
@ -189,7 +29,7 @@ class _NewProjectDialogState extends State<_NewProjectDialog> {
var data = await api.request('POST', '/projects', {'name': name, 'visibility': priv ? 'private' : 'public'});
if (data['success'] == true) {
_onComplete(data['payload']);
context.pop();
Navigator.of(context).pop();
}
}
@ -225,7 +65,7 @@ class _NewProjectDialogState extends State<_NewProjectDialog> {
SizedBox(height: 10),
CupertinoButton(
onPressed: () {
context.pop();
Navigator.of(context).pop();
},
child: Text('Cancel'),
)
@ -242,3 +82,155 @@ class _NewProjectDialog extends StatefulWidget {
@override
_NewProjectDialogState createState() => _NewProjectDialogState(_onStart, _onComplete);
}
class _ProjectsTabState extends State<ProjectsTab> {
List<dynamic> _projects = [];
bool _loading = false;
bool _creatingProject = false;
final Api api = Api();
@override
initState() {
super.initState();
getProjects();
}
void getProjects() async {
setState(() {
_loading = true;
});
var data = await api.request('GET', '/users/me/projects');
if (data['success'] == true) {
setState(() {
_projects = data['payload']['projects'];
_loading = false;
});
}
}
void _onCreatingProject() {
setState(() {
_creatingProject = true;
});
}
void _onCreateProject(newProject) {
List<dynamic> _newProjects = _projects;
_newProjects.insert(0, newProject);
setState(() {
_projects = _newProjects;
_creatingProject = false;
});
}
void _onDeleteProject(String id) {
List<dynamic> _newProjects = _projects.where((p) => p['_id'] != id).toList();
setState(() {
_projects = _newProjects;
});
}
void showNewProjectDialog() async {
Widget simpleDialog = new _NewProjectDialog(_onCreatingProject, _onCreateProject);
showDialog(context: context, builder: (BuildContext context) => simpleDialog);
}
Widget buildProjectCard(Map<String,dynamic> project) {
String description = project['description'] != null ? project['description'] : '';
if (description != null && description.length > 80) {
description = description.substring(0, 77) + '...';
}
return new Card(
child: InkWell(
onTap: () {
Navigator.push(
context,
MaterialPageRoute(
builder: (context) => ProjectScreen(project, _onDeleteProject),
),
);
},
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.center,
children: <Widget>[
new ListTile(
leading: Icon(Icons.folder_open),
trailing: Icon(Icons.keyboard_arrow_right),
title: Text(project['name'] != null ? project['name'] : 'Untitled project'),
subtitle: Text(description.replaceAll("\n", " ")),
),
/*ButtonBar(
children: <Widget>[
FlatButton(
child: const Text('VIEW'),
onPressed: () {
}
),
],
),*/
]
),
)
)
;
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: Text('Your Projects'),
actions: <Widget>[
IconButton(
icon: Icon(Icons.info_outline),
onPressed: () {
Navigator.push(
context,
MaterialPageRoute(
builder: (context) => SettingsScreen(),
),
);
},
),
]
),
body: _loading ?
Container(
margin: const EdgeInsets.all(10.0),
alignment: Alignment.center,
child: CircularProgressIndicator()
)
: Container(
margin: const EdgeInsets.all(10.0),
alignment: Alignment.center,
child: (_projects != null && _projects.length > 0) ?
ListView.builder(
itemCount: _projects.length,
itemBuilder: (BuildContext context, int index) {
return buildProjectCard(_projects[index]);
},
)
:
Column(
crossAxisAlignment: CrossAxisAlignment.center,
mainAxisAlignment: MainAxisAlignment.center,
children: [
Text('Create your first project', style: TextStyle(fontSize: 20), textAlign: TextAlign.center),
Image(image: AssetImage('assets/reading.png'), width: 300),
Text('Projects contain all the files and patterns that make up a piece of work. Create one using the + button below.', textAlign: TextAlign.center),
])
),
floatingActionButton: FloatingActionButton(
onPressed: showNewProjectDialog,
child: _creatingProject ? CircularProgressIndicator(backgroundColor: Colors.white) : Icon(Icons.add),
backgroundColor: Colors.pink[500],
),
);
}
}
class ProjectsTab extends StatefulWidget {
@override
_ProjectsTabState createState() => _ProjectsTabState();
}

View File

@ -1,11 +1,9 @@
import 'package:flutter/material.dart';
import 'package:flutter/cupertino.dart';
import 'package:flutter/gestures.dart';
import 'package:shared_preferences/shared_preferences.dart';
import 'package:url_launcher/url_launcher.dart';
import 'package:provider/provider.dart';
import 'package:go_router/go_router.dart';
import 'api.dart';
import 'model.dart';
class _RegisterScreenState extends State<RegisterScreen> {
final TextEditingController _usernameController = TextEditingController();
@ -19,9 +17,10 @@ class _RegisterScreenState extends State<RegisterScreen> {
var data = await api.request('POST', '/accounts/register', {'username': _usernameController.text, 'email': _emailController.text, 'password': _passwordController.text});
setState(() => _registering = false);
if (data['success'] == true) {
AppModel model = Provider.of<AppModel>(context, listen: false);
model.setToken(data['payload']['token']);
context.go('/onboarding');
String token = data['payload']['token'];
SharedPreferences prefs = await SharedPreferences.getInstance();
prefs.setString('apiToken', token);
Navigator.of(context).pushNamedAndRemoveUntil('/onboarding', (Route<dynamic> route) => false);
}
else {
showDialog(
@ -33,7 +32,7 @@ class _RegisterScreenState extends State<RegisterScreen> {
CupertinoDialogAction(
isDefaultAction: true,
child: Text('Try again'),
onPressed: () => context.pop(),
onPressed: () => Navigator.pop(context),
),
],
)
@ -48,59 +47,66 @@ class _RegisterScreenState extends State<RegisterScreen> {
title: Text('Register with Treadl'),
),
body: Container(
margin: const EdgeInsets.only(top: 40, left: 10, right: 10),
child: ListView(
children: <Widget>[
TextField(
autofocus: true,
controller: _usernameController,
decoration: InputDecoration(
hintText: 'username', labelText: 'Choose a username',
border: OutlineInputBorder(),
margin: const EdgeInsets.all(10.0),
child: SingleChildScrollView(
child:Column(
mainAxisAlignment: MainAxisAlignment.center,
children: <Widget>[
Image(image: AssetImage('assets/logo.png'), width: 100),
SizedBox(height: 20),
Text('Register a free account.'),
SizedBox(height: 20),
TextField(
autofocus: true,
controller: _usernameController,
decoration: InputDecoration(
hintText: 'username', labelText: 'Choose a username',
border: OutlineInputBorder(),
),
),
),
SizedBox(height: 10),
TextField(
controller: _emailController,
decoration: InputDecoration(
hintText: 'sam@example.com', labelText: 'Your email address', helperText: 'For notifications & password resets - we never share this.',
border: OutlineInputBorder()
SizedBox(height: 10),
TextField(
controller: _emailController,
decoration: InputDecoration(
hintText: 'sam@example.com', labelText: 'Your email address', helperText: 'For notifications & password resets - we never share this.',
border: OutlineInputBorder()
),
),
),
SizedBox(height: 10),
TextField(
onEditingComplete: () => _submit(context),
controller: _passwordController,
obscureText: true,
decoration: InputDecoration(
hintText: 'Type your password', labelText: 'Choose a strong password',
border: OutlineInputBorder()
SizedBox(height: 10),
TextField(
onEditingComplete: () => _submit(context),
controller: _passwordController,
obscureText: true,
decoration: InputDecoration(
hintText: 'Type your password', labelText: 'Choose a strong password',
border: OutlineInputBorder()
),
),
),
SizedBox(height: 20),
RichText(
textAlign: TextAlign.center,
text: TextSpan(
text: 'By registering you agree to Treadl\'s ',
style: Theme.of(context).textTheme.bodyText1,
children: <TextSpan>[
TextSpan(text: 'Terms of Use', style: TextStyle(fontWeight: FontWeight.bold, color: Colors.pink), recognizer: new TapGestureRecognizer()..onTap = () => launch('https://treadl.com/terms-of-use')),
TextSpan(text: ' and '),
TextSpan(text: 'Privacy Policy', style: TextStyle(fontWeight: FontWeight.bold, color: Colors.pink), recognizer: new TapGestureRecognizer()..onTap = () => launch('https://treadl.com/privacy')),
TextSpan(text: '.'),
],
),
),
SizedBox(height: 20),
ElevatedButton(
onPressed: () => _submit(context),
//color: Colors.pink,
child: _registering ? SizedBox(height: 20, width: 20, child:CircularProgressIndicator(backgroundColor: Colors.white)) : Text("Register",
SizedBox(height: 20),
RichText(
textAlign: TextAlign.center,
style: TextStyle(color: Colors.white, fontSize: 15)
)
),
]
text: TextSpan(
text: 'By registering you agree to Treadl\'s ',
style: Theme.of(context).textTheme.bodyText1,
children: <TextSpan>[
TextSpan(text: 'Terms of Use', style: TextStyle(fontWeight: FontWeight.bold, color: Colors.pink), recognizer: new TapGestureRecognizer()..onTap = () => launch('https://treadl.com/terms-of-use')),
TextSpan(text: ' and '),
TextSpan(text: 'Privacy Policy', style: TextStyle(fontWeight: FontWeight.bold, color: Colors.pink), recognizer: new TapGestureRecognizer()..onTap = () => launch('https://treadl.com/privacy')),
TextSpan(text: '.'),
],
),
),
SizedBox(height: 20),
RaisedButton(
onPressed: () => _submit(context),
color: Colors.pink,
child: _registering ? SizedBox(height: 20, width: 20, child:CircularProgressIndicator(backgroundColor: Colors.white)) : Text("Register",
textAlign: TextAlign.center,
style: TextStyle(color: Colors.white, fontSize: 15)
)
),
]
)
)
),
);

View File

@ -0,0 +1,6 @@
class ProjectScreenArguments {
final String projectId;
final String projectName;
final String projectPath;
ProjectScreenArguments(this.projectId, this.projectName, this.projectPath);
}

View File

@ -1,86 +1,21 @@
import 'package:flutter/material.dart';
import 'package:flutter/cupertino.dart';
import 'package:url_launcher/url_launcher.dart';
import 'package:provider/provider.dart';
import 'package:go_router/go_router.dart';
import 'package:shared_preferences/shared_preferences.dart';
import 'api.dart';
import 'model.dart';
class SettingsScreen extends StatelessWidget {
final TextEditingController _passwordController = TextEditingController();
void _logout(BuildContext context) async {
AppModel model = Provider.of<AppModel>(context, listen: false);
Api api = Api();
api.request('POST', '/accounts/logout');
model.setToken(null);
model.setUser(null);
context.pop();
}
void _deleteAccount(BuildContext context) async {
AlertDialog alert = AlertDialog(
title: Text('Delete your Treadl account'),
content: Column(children: [
Text('We will remove your account, along with any projects and items you\'ve created. To continue, please enter your account password.'),
SizedBox(height: 20),
TextField(
controller: _passwordController,
obscureText: true,
decoration: InputDecoration(
hintText: 'Type your password...', labelText: 'Account Password'
)
),
]),
actions: [
TextButton(
child: Text('Cancel'),
onPressed: () => context.pop(),
),
ElevatedButton(
child: Text('Delete Account'),
onPressed: () async {
Api api = Api();
var data = await api.request('DELETE', '/accounts', {'password': _passwordController.text});
if (data['success'] == true) {
AppModel model = Provider.of<AppModel>(context, listen: false);
model.setToken(null);
model.setUser(null);
context.go('/home');
} else {
showDialog(
context: context,
builder: (BuildContext context) => CupertinoAlertDialog(
title: new Text('There was a problem with deleting your account'),
content: new Text(data['message']),
actions: <Widget>[
CupertinoDialogAction(
isDefaultAction: true,
child: Text('OK'),
onPressed: () => context.pop(),
),
],
)
);
}
}
),
],
);
showDialog(
context: context,
builder: (BuildContext context) {
return alert;
},
);
SharedPreferences prefs = await SharedPreferences.getInstance();
prefs.remove('apiToken');
Navigator.of(context).pushNamedAndRemoveUntil('/welcome', (Route<dynamic> route) => false);
}
@override
Widget build(BuildContext context) {
AppModel model = Provider.of<AppModel>(context);
User? user = model.user;
return Scaffold(
appBar: AppBar(
title: Text('About Treadl'),
@ -91,40 +26,42 @@ class SettingsScreen extends StatelessWidget {
Container(
margin: const EdgeInsets.only(top: 10.0, bottom: 10.0),
child:
Text('Thanks for using Treadl', style: Theme.of(context).textTheme.titleLarge),
Text('Thanks for using Treadl', style: Theme.of(context).textTheme.titleMedium),
),
Container(
child: Text("Treadl is an app for managing your projects and for keeping in touch with your weaving communities.\n\nWe're always trying to make Treadl better, so if you have any feedback please let us know!", style: Theme.of(context).textTheme.bodyText1)
child: Text("Treadl is an app for managing your projects and for keeping in touch with your weaving communities.\n\nWe're always trying to make Treadl better for our users, so if you have any feedback please let us know!", style: Theme.of(context).textTheme.bodyText1)
),
SizedBox(height: 30),
user != null ? Column(
children: [
ListTile(
leading: Icon(Icons.exit_to_app),
title: Text('Logout'),
onTap: () => _logout(context),
),
ListTile(
leading: Icon(Icons.delete),
title: Text('Delete Account'),
onTap: () => _deleteAccount(context),
),
]
) : CupertinoButton(
color: Colors.pink,
child: Text('Join Treadl', style: TextStyle(color: Colors.white)),
onPressed: () => context.push('/welcome'),
Container(
margin: const EdgeInsets.only(top: 10.0, bottom: 20.0),
child: RaisedButton(
onPressed: () => launch('https://twitter.com/treadlhq'),
child: new Text("Follow us on Twitter",
textAlign: TextAlign.center,
)
)
),
SizedBox(height: 30),
Container(
margin: const EdgeInsets.only(top: 10.0, bottom: 10.0),
child:
Text('Open development', style: Theme.of(context).textTheme.titleMedium),
),
Container(
child: Text("We develop Treadl with an open roadmap, inviting comments and suggestions for upcoming features.", style: Theme.of(context).textTheme.bodyText1)
),
Container(
margin: const EdgeInsets.only(top: 10.0, bottom: 20.0),
child: RaisedButton(
onPressed: () => launch('https://www.notion.so/ddc7dbc3520b49a0ade2994adbaf27dc?v=9a6e74b785fb403689030a4cabd8122c'),
child: new Text("View roadmap",
textAlign: TextAlign.center,
)
)
),
ListTile(
leading: Icon(Icons.link),
trailing: Icon(Icons.explore),
title: Text('Visit Our Website'),
onTap: () => launch('https://treadl.com'),
leading: Icon(Icons.exit_to_app),
title: Text('Logout'),
onTap: () => _logout(context),
),
ListTile(
leading: Icon(Icons.insert_drive_file),
@ -138,6 +75,8 @@ class SettingsScreen extends StatelessWidget {
title: Text('Privacy Policy'),
onTap: () => launch('https://treadl.com/privacy'),
),
SizedBox(height: 30),
Text('Copyright 2020 Seastorm Limited',textAlign: TextAlign.center)
]
),
);

9
mobile/lib/store.dart Normal file
View File

@ -0,0 +1,9 @@
import 'package:flutter/foundation.dart';
class Store extends ChangeNotifier {
String apiToken;
void setToken(String newToken) {
apiToken = newToken;
}
}

Some files were not shown because too many files have changed in this diff Show More