From 12e1cb265d1b72267d72c04592de21d80919b54e Mon Sep 17 00:00:00 2001
From: Tulir Asokan <tulir@maunium.net>
Date: Sun, 1 Nov 2020 15:21:43 +0200
Subject: [PATCH] Make some basic pack creating endpoints work

---
 optional-requirements.txt           |   1 +
 setup.py                            |   2 +-
 sticker/server/api/errors.py        |  11 ++-
 sticker/server/api/pack.schema.json | 126 ++++++++++++++++++++++++++++
 sticker/server/api/setup.py         |  77 ++++++++++++++++-
 sticker/server/database/pack.py     |  12 ++-
 sticker/server/database/sticker.py  |  16 ++--
 sticker/server/database/upgrade.py  |   7 +-
 sticker/server/database/user.py     |  13 ++-
 9 files changed, 245 insertions(+), 20 deletions(-)
 create mode 100644 sticker/server/api/pack.schema.json

diff --git a/optional-requirements.txt b/optional-requirements.txt
index ac71e85..a5b5d87 100644
--- a/optional-requirements.txt
+++ b/optional-requirements.txt
@@ -8,3 +8,4 @@ attrs
 setuptools
 aiodns
 ruamel.yaml
+jsonschema
diff --git a/setup.py b/setup.py
index 6e8cb41..c25d48d 100644
--- a/setup.py
+++ b/setup.py
@@ -71,5 +71,5 @@ setuptools.setup(
 
         "frontend/index.html", "frontend/setup/index.html",
         "frontend/src/*", "frontend/lib/*/*.js", "frontend/res/*", "frontend/style/*.css",
-    ]}
+    ], "sticker.server.api": ["pack.schema.json"]}
 )
diff --git a/sticker/server/api/errors.py b/sticker/server/api/errors.py
index f84df57..68b5d5a 100644
--- a/sticker/server/api/errors.py
+++ b/sticker/server/api/errors.py
@@ -13,7 +13,8 @@
 #
 # You should have received a copy of the GNU Affero General Public License
 # along with this program.  If not, see <https://www.gnu.org/licenses/>.
-from typing import Dict
+from typing import Dict, Optional
+from collections import deque
 import json
 
 from aiohttp import web
@@ -99,6 +100,14 @@ class _ErrorMeta:
         return web.HTTPNotFound(**self._make_error("NET.MAUNIUM_PACK_NOT_FOUND",
                                                    "Sticker pack not found"))
 
+    def schema_error(self, message: str, path: Optional[deque] = None) -> web.HTTPException:
+        if path:
+            path_str = "in " + " → ".join(str(part) for part in path)
+        else:
+            path_str = "at top level"
+        return web.HTTPBadRequest(**self._make_error(
+            "M_BAD_REQUEST", f"Schema validation error {path_str}: {message}"))
+
     @property
     def client_well_known_error(self) -> web.HTTPException:
         return web.HTTPForbidden(**self._make_error("NET.MAUNIUM_CLIENT_WELL_KNOWN_ERROR",
diff --git a/sticker/server/api/pack.schema.json b/sticker/server/api/pack.schema.json
new file mode 100644
index 0000000..ccb45c8
--- /dev/null
+++ b/sticker/server/api/pack.schema.json
@@ -0,0 +1,126 @@
+{
+  "$schema": "http://json-schema.org/draft-07/schema#",
+  "type": "object",
+  "description": "A sticker pack compatible with maunium-stickerpicker",
+  "properties": {
+    "id": {
+      "type": "string",
+      "description": "An unique identifier for the sticker pack",
+      "readOnly": true
+    },
+    "title": {
+      "type": "string",
+      "description": "The title of the sticker pack"
+    },
+    "stickers": {
+      "type": "array",
+      "description": "The stickers in the pack",
+      "items": {
+        "type": "object",
+        "description": "A single sticker",
+        "properties": {
+          "id": {
+            "type": "string",
+            "description": "An unique identifier for the sticker"
+          },
+          "url": {
+            "type": "string",
+            "description": "The Matrix content URI to the sticker",
+            "pattern": "mxc://.+?/.+"
+          },
+          "body": {
+            "type": "string",
+            "description": "The description text for the sticker"
+          },
+          "info": {
+            "type": "object",
+            "description": "Matrix media info",
+            "properties": {
+              "w": {
+                "type": "integer",
+                "description": "The intended display width of the sticker"
+              },
+              "h": {
+                "type": "integer",
+                "description": "The intended display height of the sticker"
+              },
+              "size": {
+                "type": "integer",
+                "description": "The size of the sticker image in bytes"
+              },
+              "mimetype": {
+                "type": "string",
+                "description": "The mime type of the sticker image"
+              }
+            },
+            "additionalProperties": true,
+            "required": [
+              "w",
+              "h",
+              "size",
+              "mimetype"
+            ]
+          },
+          "net.maunium.telegram.sticker": {
+            "type": "object",
+            "description": "Telegram metadata about the sticker",
+            "properties": {
+              "pack": {
+                "type": "string",
+                "description": "Information about the pack the sticker is in",
+                "properties": {
+                  "id": {
+                    "type": "string",
+                    "description": "The ID of the sticker pack"
+                  },
+                  "short_name": {
+                    "type": "string",
+                    "description": "The short name of the Telegram sticker pack from t.me/addstickers/<shortname>"
+                  }
+                }
+              },
+              "id": {
+                "type": "string",
+                "description": "The ID of the sticker document"
+              },
+              "emoticons": {
+                "type": "array",
+                "description": "Emojis that are associated with the sticker",
+                "items": {
+                  "type": "string",
+                  "description": "A single unicode emoji"
+                }
+              }
+            }
+          }
+        },
+        "required": [
+          "id",
+          "url",
+          "body",
+          "info"
+        ],
+        "additionalProperties": true
+      }
+    },
+    "net.maunium.telegram.pack": {
+      "type": "object",
+      "description": "Telegram metadata about the pack",
+      "properties": {
+        "short_name": {
+          "type": "string",
+          "description": "The short name of the Telegram sticker pack from t.me/addstickers/<shortname>"
+        },
+        "hash": {
+          "type": "string",
+          "description": "The Telegram-specified hash of the stickerpack that can be used to quickly check if it has changed"
+        }
+      }
+    }
+  },
+  "additionalProperties": true,
+  "required": [
+    "title",
+    "stickers"
+  ]
+}
diff --git a/sticker/server/api/setup.py b/sticker/server/api/setup.py
index 3a60001..dcb38bc 100644
--- a/sticker/server/api/setup.py
+++ b/sticker/server/api/setup.py
@@ -13,12 +13,22 @@
 #
 # You should have received a copy of the GNU Affero General Public License
 # along with this program.  If not, see <https://www.gnu.org/licenses/>.
+from typing import Any
+import random
+import string
+import json
+
 from aiohttp import web
+from pkg_resources import resource_stream
+import jsonschema
 
-from ..database import User, AccessToken
+from ..database import User, AccessToken, Pack, Sticker
+from .errors import Error
 
 routes = web.RouteTableDef()
 
+pack_schema = json.load(resource_stream("sticker.server.api", "pack.schema.json"))
+
 
 @routes.get("/whoami")
 async def whoami(req: web.Request) -> web.Response:
@@ -30,3 +40,68 @@ async def whoami(req: web.Request) -> web.Response:
         "homeserver_url": user.homeserver_url,
         "last_seen": int(token.last_seen_date.timestamp() / 60) * 60,
     })
+
+
+@routes.get("/packs")
+async def packs(req: web.Request) -> web.Response:
+    user: User = req["user"]
+    packs = await user.get_packs()
+    return web.json_response([pack.to_dict() for pack in packs])
+
+
+async def get_json(req: web.Request, schema: str) -> Any:
+    try:
+        data = await req.json()
+    except json.JSONDecodeError:
+        raise Error.request_not_json
+    try:
+        jsonschema.validate(data, schema)
+    except jsonschema.ValidationError as e:
+        raise Error.schema_error(e.message, e.path)
+    return data
+
+
+@routes.post("/packs/create")
+async def upload_pack(req: web.Request) -> web.Response:
+    data = await get_json(req, pack_schema)
+    user: User = req["user"]
+    title = data.pop("title")
+    raw_stickers = data.pop("stickers")
+    pack_id_suffix = data.pop("id", "".join(random.choices(string.ascii_lowercase, k=12)))
+    pack = Pack(id=f"{user.id}_{pack_id_suffix}", owner=user.id, title=title, meta=data)
+    stickers = [Sticker(pack_id=pack.id, id=sticker.pop("id"), url=sticker.pop("url"),
+                        body=sticker.pop("body"), meta=sticker) for sticker in raw_stickers]
+    await pack.insert()
+    await pack.set_stickers(stickers)
+    await user.add_pack(pack)
+
+    return web.json_response({
+        **pack.to_dict(),
+        "stickers": [sticker.to_dict() for sticker in stickers],
+    })
+
+
+@routes.get("/pack/{pack_id}")
+async def get_pack(req: web.Request) -> web.Response:
+    user: User = req["user"]
+    pack = await user.get_pack(req.match_info["pack_id"])
+    if pack is None:
+        raise Error.pack_not_found
+    return web.json_response({
+        **pack.to_dict(),
+        "stickers": [sticker.to_dict() for sticker in await pack.get_stickers()],
+    })
+
+
+@routes.delete("/pack/{pack_id}")
+async def delete_pack(req: web.Request) -> web.Response:
+    user: User = req["user"]
+    pack = await user.get_pack(req.match_info["pack_id"])
+    if pack is None:
+        raise Error.pack_not_found
+
+    if pack.owner != user.id:
+        await user.remove_pack(pack)
+    else:
+        await pack.delete()
+    return web.Response(status=204)
diff --git a/sticker/server/database/pack.py b/sticker/server/database/pack.py
index 3c2e530..4ac0847 100644
--- a/sticker/server/database/pack.py
+++ b/sticker/server/database/pack.py
@@ -14,6 +14,7 @@
 # You should have received a copy of the GNU Affero General Public License
 # along with this program.  If not, see <https://www.gnu.org/licenses/>.
 from typing import List, Dict, Any
+import json
 
 from attr import dataclass
 
@@ -35,15 +36,20 @@ class Pack(Base):
 
     async def insert(self) -> None:
         await self.db.execute("INSERT INTO pack (id, owner, title, meta) VALUES ($1, $2, $3, $4)",
-                              self.id, self.owner, self.title, self.meta)
+                              self.id, self.owner, self.title, json.dumps(self.meta))
+
+    @classmethod
+    def from_data(cls, **data: Any) -> 'Pack':
+        meta = json.loads(data.pop("meta"))
+        return cls(**data, meta=meta)
 
     async def get_stickers(self) -> List[Sticker]:
         res = await self.db.fetch('SELECT id, url, body, meta, "order" '
                                   'FROM sticker WHERE pack_id=$1 ORDER BY "order"', self.id)
-        return [Sticker(**row, pack_id=self.id) for row in res]
+        return [Sticker.from_data(**row, pack_id=self.id) for row in res]
 
     async def set_stickers(self, stickers: List[Sticker]) -> None:
-        data = ((sticker.id, self.id, sticker.url, sticker.body, sticker.meta, order)
+        data = ((sticker.id, self.id, sticker.url, sticker.body, json.dumps(sticker.meta), order)
                 for order, sticker in enumerate(stickers))
         columns = ["id", "pack_id", "url", "body", "meta", "order"]
         async with self.db.acquire() as conn, conn.transaction():
diff --git a/sticker/server/database/sticker.py b/sticker/server/database/sticker.py
index 03aa21c..356935c 100644
--- a/sticker/server/database/sticker.py
+++ b/sticker/server/database/sticker.py
@@ -14,6 +14,7 @@
 # You should have received a copy of the GNU Affero General Public License
 # along with this program.  If not, see <https://www.gnu.org/licenses/>.
 from typing import Dict, Any
+import json
 
 from attr import dataclass
 import attr
@@ -26,20 +27,12 @@ from .base import Base
 @dataclass(kw_only=True)
 class Sticker(Base):
     pack_id: str
-    order: int
+    order: int = 0
     id: str
     url: ContentURI = attr.ib(order=False)
     body: str = attr.ib(order=False)
     meta: Dict[str, Any] = attr.ib(order=False)
 
-    async def delete(self) -> None:
-        await self.db.execute("DELETE FROM sticker WHERE id=$1", self.id)
-
-    async def insert(self) -> None:
-        await self.db.execute('INSERT INTO sticker (id, pack_id, url, body, meta, "order") '
-                              "VALUES ($1, $2, $3, $4, $5, $6)",
-                              self.id, self.pack_id, self.url, self.body, self.meta, self.order)
-
     def to_dict(self) -> Dict[str, Any]:
         return {
             **self.meta,
@@ -47,3 +40,8 @@ class Sticker(Base):
             "url": self.url,
             "id": self.id,
         }
+
+    @classmethod
+    def from_data(cls, **data: Any) -> 'Sticker':
+        meta = json.loads(data.pop("meta"))
+        return cls(**data, meta=meta)
diff --git a/sticker/server/database/upgrade.py b/sticker/server/database/upgrade.py
index b2c98be..c191e25 100644
--- a/sticker/server/database/upgrade.py
+++ b/sticker/server/database/upgrade.py
@@ -47,10 +47,11 @@ async def upgrade_v1(conn: Connection) -> None:
         PRIMARY KEY (user_id, pack_id)
     )""")
     await conn.execute("""CREATE TABLE sticker (
-        id      TEXT  PRIMARY KEY,
-        pack_id TEXT  NOT NULL REFERENCES pack(id) ON DELETE CASCADE,
+        id      TEXT,
+        pack_id TEXT  REFERENCES pack(id) ON DELETE CASCADE,
         url     TEXT  NOT NULL,
         body    TEXT  NOT NULL,
         meta    JSONB NOT NULL,
-        "order" INT   NOT NULL DEFAULT 0
+        "order" INT   NOT NULL DEFAULT 0,
+        PRIMARY KEY (id, pack_id)
     )""")
diff --git a/sticker/server/database/user.py b/sticker/server/database/user.py
index 138efda..552c943 100644
--- a/sticker/server/database/user.py
+++ b/sticker/server/database/user.py
@@ -16,6 +16,7 @@
 from typing import Optional, List, ClassVar
 import random
 import string
+import time
 
 from attr import dataclass
 import asyncpg
@@ -76,7 +77,7 @@ class User(Base):
         res = await self.db.fetch("SELECT id, owner, title, meta FROM user_pack "
                                   "LEFT JOIN pack ON pack.id=user_pack.pack_id "
                                   'WHERE user_id=$1 ORDER BY "order"', self.id)
-        return [Pack(**row) for row in res]
+        return [Pack.from_data(**row) for row in res]
 
     async def get_pack(self, pack_id: str) -> Optional[Pack]:
         row = await self.db.fetchrow("SELECT id, owner, title, meta FROM user_pack "
@@ -84,7 +85,7 @@ class User(Base):
                                      "WHERE user_id=$1 AND pack_id=$2", self.id, pack_id)
         if row is None:
             return None
-        return Pack(**row)
+        return Pack.from_data(**row)
 
     async def set_packs(self, packs: List[Pack]) -> None:
         data = ((self.id, pack.id, order)
@@ -93,3 +94,11 @@ class User(Base):
         async with self.db.acquire() as conn, conn.transaction():
             await conn.execute("DELETE FROM user_pack WHERE user_id=$1", self.id)
             await conn.copy_records_to_table("user_pack", records=data, columns=columns)
+
+    async def add_pack(self, pack: Pack) -> None:
+        q = 'INSERT INTO user_pack (user_id, pack_id, "order") VALUES ($1, $2, $3)'
+        await self.db.execute(q, self.id, pack.id, int(time.time()))
+
+    async def remove_pack(self, pack: Pack) -> None:
+        q = "DELETE FROM user_pack WHERE user_id=$1 AND pack_id=$2"
+        await self.db.execute(q, self.id, pack.id)
-- 
GitLab