diff --git a/.github/workflows/monarch-link-app-service.yml b/.github/workflows/monarch-link-app-service.yml new file mode 100644 index 00000000..4a6e3e6f --- /dev/null +++ b/.github/workflows/monarch-link-app-service.yml @@ -0,0 +1,78 @@ +name: Monarch Link App Service +on: + push: + branches: [ main ] + tags: [ "service-monarch-link-app-[0-9]+.[0-9]+.[0-9]+" ] + paths: [ "services/monarch-link-app/**" ] + pull_request: + branches: [ main ] + paths: [ "services/monarch-link-app/**" ] + workflow_dispatch: +env: + REGISTRY: ghcr.io + IMAGE_NAME: shared-reality-lab/image-service-monarch-link-app +jobs: + lint: + name: PEP 8 style check. + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v3 + - uses: actions/setup-python@v4 + with: + python-version: '3.x' + - name: Install flake8 + run: pip install flake8 + - name: Check with flake8 + run: python -m flake8 ./services/monarch-link-app --show-source + build-and-push-image: + name: Build and Push to Registry + needs: lint + runs-on: ubuntu-latest + permissions: + contents: read + packages: write + steps: + - name: Checkout repository + uses: actions/checkout@v3 + with: + submodules: true + - name: Log into GHCR + uses: docker/login-action@v2 + with: + registry: ${{ env.REGISTRY }} + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + - name: Get Correct Tags + run: | + if [[ ${{ github.ref }} =~ ^refs/tags/service-monarch-link-app-[0-9]+\.[0-9]+\.[0-9]+$ ]]; then + echo "TAGGED=true" >> $GITHUB_ENV + else + echo "TAGGED=false" >> $GITHUB_ENV + fi + - name: Get timestamp + run: echo "timestamp=$(date -u +'%Y-%m-%dT%H.%M')" >> $GITHUB_ENV + - name: Extract metadata + id: meta + uses: docker/metadata-action@v4 + with: + images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }} + flavor: | + latest=${{ env.TAGGED }} + tags: | + type=match,enable=${{ env.TAGGED }},priority=300,pattern=service-monarch-link-app-(\d+.\d+.\d+),group=1 + type=raw,priority=200,value=unstable + type=raw,priority=100,value=${{ env.timestamp }} + labels: | + org.opencontainers.image.title=IMAGE Service Monarch Link App + org.opencontainers.image.description=Service to link Monarch client with tactile authoring tool. + org.opencontainers.image.authors=IMAGE Project + org.opencontainers.image.licenses=AGPL-3.0-or-later + maintainer=IMAGE Project + - name: Build and push + uses: docker/build-push-action@v3 + with: + context: . + file: ./services/monarch-link-app/Dockerfile + push: ${{ github.event_name != 'pull_request' }} + tags: ${{ steps.meta.outputs.tags }} + labels: ${{ steps.meta.outputs.labels }} diff --git a/build.yml b/build.yml index d8573acf..5067dcdc 100644 --- a/build.yml +++ b/build.yml @@ -21,6 +21,11 @@ services: context: . dockerfile: services/espnet-tts-fr/Dockerfile image: "espnet-tts-fr:latest" + monarch-link-app-service: + build: + context: . + dockerfile: services/monarch-link-app/Dockerfile + image: "monarch-link-app:latest" line-charts-preprocessor: build: context: . diff --git a/docker-compose.yml b/docker-compose.yml index 1e479e45..16147a77 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -390,7 +390,13 @@ services: image: ghcr.io/shared-reality-lab/image-handler-svg-action-recognition:${REGISTRY_TAG} labels: ca.mcgill.a11y.image.handler: enable - + + monarch-link-app: + profiles: [test, default] + image: "ghcr.io/shared-reality-lab/image-service-monarch-link-app:${REGISTRY_TAG}" + restart: unless-stopped + networks: + - traefik # end - unicorn exclusive services volumes: sc-store: diff --git a/services/monarch-link-app/Dockerfile b/services/monarch-link-app/Dockerfile new file mode 100644 index 00000000..fdb6124a --- /dev/null +++ b/services/monarch-link-app/Dockerfile @@ -0,0 +1,19 @@ +FROM python:3.13 + +RUN apt-get install libcairo2 + +RUN adduser --disabled-password python +WORKDIR /usr/src/app +ENV PATH="/home/python/.local/bin:${PATH}" + +RUN pip install --upgrade pip +COPY /services/monarch-link-app/requirements.txt /usr/src/app/requirements.txt +RUN pip install -r requirements.txt + +COPY /services/monarch-link-app/ /usr/src/app + +RUN chown -R python:python /usr/src/app + +EXPOSE 80 +USER python +CMD ["gunicorn", "app:app", "-b", "0.0.0.0:80", "--capture-output", "--log-level=debug" ] \ No newline at end of file diff --git a/services/monarch-link-app/README.md b/services/monarch-link-app/README.md new file mode 100644 index 00000000..8c57a42a --- /dev/null +++ b/services/monarch-link-app/README.md @@ -0,0 +1,16 @@ +This is the app used to connect IMAGE-TactileAuthoring to IMAGE-Monarch. + +# Monarch Link App + +![license: AGPL](https://img.shields.io/badge/license-AGPL-success) [GitHub Container Registry Package](https://github.com/Shared-Reality-Lab/IMAGE-server/pkgs/container/image-service-monarch-link-app) + +## Overview + +This is the containerized version of a web app used to publish content from [IMAGE-TactileAuthoring](https://github.com/Shared-Reality-Lab/IMAGE-TactileAuthoring) which can then be fetched by [IMAGE-Monarch client](https://github.com/Shared-Reality-Lab/IMAGE-Monarch). It stores the data in a json file data.json which is recreated each time the container is restarted. + +This container runs on port 80. + +## Endpoints +- POST `https://monarch.unicorn.cim.mcgill.ca/create/` where the body is a JSON object with the key `data` set to the SVG in base64 format, `secret` set to the secret key, and `layer` set to the layer to be shown or None if no default layer is selected. +This can be used to create a new channel `` if one doesn't already exist. The data is stored in data.json. If `` already exists the `data` field corresponding it is changed only if the `secret` field matches the secret key when the channel was created. +- GET `https://monarch.unicorn.cim.mcgill.ca/display/` returns a JSON object with the [tactile svg schema](https://github.com/Shared-Reality-Lab/IMAGE-server/blob/24a41b4f36a8c89b1a94d7c31388703ece8c81c7/renderers/tactilesvg.schema.json). diff --git a/services/monarch-link-app/app.py b/services/monarch-link-app/app.py new file mode 100644 index 00000000..d7233772 --- /dev/null +++ b/services/monarch-link-app/app.py @@ -0,0 +1,187 @@ +# Copyright (c) 2024 IMAGE Project, Shared Reality Lab, McGill University +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU Affero General Public License as +# published by the Free Software Foundation, either version 3 of the +# License, or (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU Affero General Public License for more details. +# You should have received a copy of the GNU Affero General Public License +# and our Additional Terms along with this program. +# If not, see +# . + +from flask import Flask, request, abort, Response +from flask_bcrypt import Bcrypt +from flask_cors import CORS, cross_origin +import logging +import hashlib +import json +import random +import re +import uuid +from werkzeug.routing import BaseConverter, ValidationError + +app = Flask(__name__) +bcrypt = Bcrypt(app) +logging.basicConfig(level=logging.DEBUG) + + +CORS( + app, resources={r"/*": {"origins": "*"}} +) # CORS allowed for all domains on all routes + +try: + with open("data.json", 'x') as file: + json.dump(dict(), file) +except FileExistsError: + logging.debug("The file already exists") + + +def write_data(svgData): + with open("data.json", "w") as outfile: + json.dump(svgData, outfile) + + +def read_data(): + with open('data.json', 'r') as openfile: + try: + return json.load(openfile) + except Exception: + # return a dict if the file is empty + return dict() + + +# generate an id that does not already exist +def generate_code(svgData): + code = ''.join([str(random.randint(1, 8)) for i in range(6)]) + while code in svgData: + code = generate_code(svgData) + return code + + +# custom converter to validate that the id +# in the url is a valid id +class CodeConverter(BaseConverter): + def to_python(self, value): + # has six digits between 1 and 8 + pattern = re.compile("^[1-8]{6}$") + # also has a length of six + if not pattern.match(value): + logging.debug('Received request with invalid ID value') + raise ValidationError('Invalid id value') + return value + + def to_url(self, value): + return value + + +app.url_map.converters['code'] = CodeConverter + + +@app.route("/create", methods=["POST"]) +@cross_origin() +def create(): + if request.method == "POST": + logging.debug('Create request received') + try: + req_data = request.get_json() + svgData = read_data() + id = generate_code(svgData) + secret = uuid.uuid4().hex + svgData[id] = {"secret": bcrypt.generate_password_hash(secret) + .decode('utf-8'), + "data": req_data["data"], + "title": req_data["title"], + "layer": req_data["layer"]} + write_data(svgData) + logging.debug('Created new channel with code '+id) + return {"id": id, "secret": secret} + except KeyError: + logging.debug("Unexpected JSON format. Returning 400") + return "Unexpected JSON format", 400 + except Exception as e: + logging.debug(e) + abort(Response(response=e)) + + +@app.route("/update/", methods=["POST"]) +@cross_origin() +def update(id): + if request.method == "POST": + try: + logging.debug('Update request received') + req_data = request.get_json() + svgData = read_data() + if id in svgData: + if bcrypt.check_password_hash((svgData[id])["secret"], + req_data["secret"]): + svgData[id] = {"secret": + bcrypt.generate_password_hash( + req_data["secret"]).decode('utf-8'), + "data": req_data["data"], + "title": req_data["title"], + "layer": req_data["layer"]} + write_data(svgData) + logging.debug('Updated graphic') + return "Graphic in channel "+id+" has been updated!" + else: + logging.debug('Unauthorized access to existing channel!') + return "Unauthorized access to existing channel!", 401 + else: + svgData[id] = {"secret": + bcrypt.generate_password_hash( + req_data["secret"]).decode('utf-8'), + "data": req_data["data"], + "title": req_data["title"], + "layer": req_data["layer"]} + write_data(svgData) + logging.debug('TEMP: Created new channel using update!') + return ("New channel created with code "+id + + ". Creating new ids using update is" + + " only intended for testing!") + except KeyError: + logging.debug("Unexpected JSON format. Returning 400") + return "Unexpected JSON format", 400 + except Exception as e: + logging.debug(e) + abort(Response(response=e)) + + +@app.route("/display/", methods=["GET"]) +@cross_origin() +def display(id): + if request.method == "GET": + logging.debug('Display request received') + svgData = read_data() + if id in svgData: + try: + response = Response() + response.mimetype = "application/json" + response.set_data(json.dumps({"renderings": [ + {"data": {"graphic": svgData[id]["data"], + "layer": svgData[id]["layer"]}}]})) + response.add_etag(hashlib.md5( + (svgData[id]["data"]+svgData[id]["layer"]).encode())) + response.make_conditional(request) + logging.debug('Sending tactile response') + return response + except Exception as e: + logging.debug(e) + abort(Response(response=e)) + else: + logging.debug('ID does not exist') + return abort(404) + + +@app.route("/", methods=["POST", "GET"]) +@cross_origin() +def home(): + return "Hi" + + +if __name__ == "__main__": + app.run(host="0.0.0.0", port=80, debug=True) diff --git a/services/monarch-link-app/requirements.txt b/services/monarch-link-app/requirements.txt new file mode 100644 index 00000000..8c6fb883 --- /dev/null +++ b/services/monarch-link-app/requirements.txt @@ -0,0 +1,12 @@ +bcrypt>=4.2.0,<5.0.0 +blinker>=1.8.2,<2.0.0 +click>=8.1.7,<9.0.0 +colorama>=0.4.6,<1.0.0 +Flask==3.0.3 +Flask-Bcrypt==1.0.1 +Flask-Cors==5.0.0 +itsdangerous>=2.2.0,<3.0.0 +Jinja2>=3.1.4,<4.0.0 +MarkupSafe>=3.0.1,<4.0.0 +Werkzeug>=3.0.4,<4.0.0 +gunicorn \ No newline at end of file diff --git a/test-docker-compose.yml b/test-docker-compose.yml index cbf2f2c1..7c12c415 100644 --- a/test-docker-compose.yml +++ b/test-docker-compose.yml @@ -104,6 +104,15 @@ services: device_ids: ['0'] capabilities: ["gpu", "utility", "compute"] +monarch-link-app: + labels: + - "traefik.enable=true" + - "traefik.http.routers.monarch-link-app.rule=Host(`monarch.unicorn.cim.mcgill.ca`)" + - "traefik.http.routers.monarch-link-app.tls.certresolver=myresolver" + - traefik.docker.network=traefik + environment: + - SERVER_NAME=unicorn.cim.mcgill.ca + object-grouping: healthcheck: test: ["CMD", "curl", "-f", "http://localhost:5000/health"]