Skip to content
Snippets Groups Projects
Commit 12e1cb26 authored by Tulir Asokan's avatar Tulir Asokan
Browse files

Make some basic pack creating endpoints work

parent 0b15a448
No related branches found
No related tags found
No related merge requests found
......@@ -8,3 +8,4 @@ attrs
setuptools
aiodns
ruamel.yaml
jsonschema
......@@ -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"]}
)
......@@ -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",
......
{
"$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"
]
}
......@@ -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)
......@@ -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():
......
......@@ -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)
......@@ -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)
)""")
......@@ -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)
0% Loading or .
You are about to add 0 people to the discussion. Proceed with caution.
Please register or to comment