6 Commits

Author SHA1 Message Date
io
772c12eefd Merge ec890f1e69 into 376f687c38 2025-03-01 06:09:21 +00:00
376f687c38 chore: questionable is not a recognized rating 2025-02-11 21:50:27 +01:00
4fd848abf2 doc: use docker compose instead of docker-compose
The minimum version requirements are rough guesses, in practice any decently modern docker installation should work.
2025-02-11 21:25:10 +01:00
io
ec890f1e69 client-server: cleaner way of getting http status codes 2021-07-09 19:42:29 +00:00
io
05074527ed Implement twitter video embeds
WIP as they get cut off for tall videos
Also do not serve them to Telegram because Telegram prefers twitter:card to og:video
2021-07-09 19:42:08 +00:00
io
dfe952ddaf WIP OpenGraph embeds 2021-07-08 22:07:46 +00:00
9 changed files with 199 additions and 18 deletions

View File

@ -84,7 +84,7 @@ function bundleHtml() {
function minifyHtml(html) {
return require('html-minifier').minify(html, {
removeComments: true,
removeComments: false,
collapseWhitespace: true,
conservativeCollapse: true,
}).trim();

View File

@ -1,5 +1,5 @@
<!DOCTYPE html>
<html>
<html prefix='og: https://ogp.me/ns#'>
<head>
<meta charset='utf-8'/>
<meta name='viewport' content='width=device-width, initial-scale=1.0'>
@ -10,6 +10,7 @@
<meta name="msapplication-TileImage" content="/img/mstile-150x150.png">
<title>Loading...</title>
<!-- Base HTML Placeholder -->
<!-- Embed Placeholder -->
<link href='css/app.min.css' rel='stylesheet' type='text/css'/>
<link href='css/vendor.min.css' rel='stylesheet' type='text/css'/>
<link rel='shortcut icon' type='image/png' href='img/favicon.png'/>

View File

@ -23,6 +23,10 @@ http {
server __BACKEND__:6666;
}
upstream frontend {
server __FRONTEND__:6667;
}
server {
listen 80 default_server;
@ -71,7 +75,7 @@ http {
location / {
root /var/www;
try_files $uri /index.htm;
try_files $uri @frontend;
sendfile on;
tcp_nopush on;
@ -81,6 +85,10 @@ http {
gzip_proxied expired no-cache no-store private auth;
}
location @frontend {
proxy_pass http://frontend;
}
location @unauthorized {
return 403 "Unauthorized";
default_type text/plain;

1
client/requirements.txt Normal file
View File

@ -0,0 +1 @@
requests

172
client/server.py Normal file
View File

@ -0,0 +1,172 @@
import os
import os.path
import html
import urllib.parse
import itertools
from typing import Dict, Any, Callable, Tuple, List, Iterable
from http import HTTPStatus
from pathlib import Path
import requests
class HttpStatus:
def __getattr__(self, name):
return str(int(getattr(HTTPStatus, name)))
http_status = HttpStatus()
FRONTEND_WEB_ROOT = Path(os.environ["SZURUBOORU_WEB_ROOT"])
with open(FRONTEND_WEB_ROOT / "index.htm") as f:
INDEX_HTML = f.read()
BACKEND_BASE_URL = os.environ["SZURUBOORU_BASE_URL"]
PUBLIC_BASE_URL = os.environ["SZURUBOORU_PUBLIC_BASE_URL"]
Metadata = Iterable[Tuple[str, str]]
def general_embed(server_info: Dict[str, Any]) -> Metadata:
yield "og:site_name", server_info["config"]["name"]
def user_embed(username: str) -> Metadata:
yield "og:type", "profile"
yield "profile:username", username
yield "og:title", username
yield "og:image:height", "128"
yield "og:image:width", "128"
user = requests.get(BACKEND_BASE_URL + "/api/user/" + username).json()
yield "og:image:url", urllib.parse.join(PUBLIC_BASE_URL, user["avatarUrl"])
def _image_embed(post: Dict[str, Any], skip_twitter_player=False) -> Metadata:
url = PUBLIC_BASE_URL + post["contentUrl"]
if post["type"] == "video":
prefix = "og:video"
yield "og:image", PUBLIC_BASE_URL + post["thumbnailUrl"]
if not skip_twitter_player:
yield "twitter:card", "player"
yield "twitter:player", PUBLIC_BASE_URL + f"/player/{post['id']}"
yield "twitter:player:width", str(post["canvasWidth"])
yield "twitter:player:height", str(post["canvasHeight"])
else:
prefix = "og:image"
yield "twitter:card", "summary_large_image"
yield prefix + ":width", str(post["canvasWidth"])
yield prefix + ":height", str(post["canvasHeight"])
yield prefix, url
if BACKEND_BASE_URL.startswith('https://'):
yield prefix + ":secure_url", url
yield prefix + ":type", post["mimeType"]
def _author_embed(user: Dict[str, Any]) -> Metadata:
yield "article:author", PUBLIC_BASE_URL + "/user/" + user["name"]
def post_embed(post_id: int, *, skip_twitter_player=False) -> Metadata:
post = requests.get(BACKEND_BASE_URL + f"/api/post/{post_id}").json()
yield "og:type", "article"
yield from _author_embed(post["user"])
yield "og:title", post["user"]["name"]
if post["tags"]:
value = "Tags: " + ", ".join(tag["names"][0] for tag in post["tags"])
yield "og:description", value
yield "description", value
yield from _image_embed(post, skip_twitter_player)
def homepage_embed(server_info: Dict[str, Any], *, skip_twitter_player=False) -> Metadata:
yield "og:title", server_info["config"]["name"]
yield "og:type", "website"
post = server_info["featuredPost"]
if post is not None:
yield from _image_embed(post, skip_twitter_player)
def render_embed(metadata: Metadata) -> str:
out = []
for k, v in metadata:
k, v = html.escape(k), html.escape(v)
out.append(f'<meta property="{k}" content="{v}">')
return ''.join(out)
def serve_twitter_video_player(start_response, post_id: int):
r = requests.get(BACKEND_BASE_URL + f"/api/post/{post_id}")
data = r.json()
if r.status_code != HTTPStatus.OK:
start_response(r.status_code, [("Content-Type", "text/html; charset=utf-8")])
yield f"<h1>{html.escape(data['title'])}</h1><p>{html.escape(data['description'])}</p>".encode("utf-8"),
start_response(http_status.OK, [("Content-Type", "text/html; charset=utf-8")])
post = data
yield b"<!DOCTYPE html><html><head><title>&NegativeMediumSpace;</title>"
yield b"<style type='text/css'>video { width: 100%; max-width: 600px; height: auto; }</style></head><body>"
yield b"<video autoplay controls"
flags = set(post["flags"])
if "loop" in flags:
yield b" loop"
if "sound" not in flags:
yield b" muted"
yield f"><source type='{post['mimeType']}' src='{post['contentUrl']}'>Your browser doesn't support HTML5 videos.".encode("utf-8")
yield b"</video></body></html>"
def application(env: Dict[str, Any], start_response: Callable[[str, Any], Any]) -> Tuple[bytes]:
def serve_file(path):
start_response(http_status.OK, [("X-Accel-Redirect", path)])
return ()
def serve_without_embed():
return serve_file("/index.htm")
method = env["REQUEST_METHOD"]
if method != "GET":
start_response(http_status.BAD_REQUEST, [("Content-Type", "text/plain")])
return (b"Bad request",)
path = env["PATH_INFO"].lstrip("/")
path = path.encode("latin-1").decode("utf-8") # PEP-3333
if path and (FRONTEND_WEB_ROOT / path).exists():
return serve_file("/" + path)
path = "/" + path
path_components = path.split("/")
if path_components[1] not in {"post", "user", "", "player"}:
# serve index.htm like normal
return serve_without_embed()
if path_components[1] == "player" and path_components[2]:
try:
post_id = int(path_components[2])
except ValueError:
pass
else:
return serve_twitter_video_player(start_response, post_id)
server_info = requests.get(BACKEND_BASE_URL + "/api/info").json()
privileges = server_info["config"]["privileges"]
# Telegram prefers twitter:card to og:video, so we need to skip the former in order for videos to play inline
skip_twitter_player = env["HTTP_USER_AGENT"].startswith("TelegramBot")
if path_components[1] == "user":
username = path_components[2]
if privileges["users:view"] != "anonymous" or not username:
return serve_without_embed()
metadata = user_embed(username)
elif path_components[1] == "post":
try:
post_id = int(path_components[2])
except ValueError:
return serve_without_embed()
if privileges["posts:view"] != "anonymous":
return serve_without_embed()
metadata = post_embed(post_id, skip_twitter_player=skip_twitter_player)
elif path_components[1] == "":
metadata = homepage_embed(server_info, skip_twitter_player=skip_twitter_player)
metadata = itertools.chain(general_embed(server_info), metadata)
body = INDEX_HTML.replace("<!-- Embed Placeholder -->", render_embed(metadata)).encode("utf-8")
start_response(http_status.OK, [("Content-Type", "text/html"), ("Content-Length", str(len(body)))])
return (body,)

View File

@ -789,7 +789,7 @@ data.
| `fav-time` | alias of `fav-date` |
| `feature-date` | featured at given date |
| `feature-time` | alias of `feature-time` |
| `safety` | having given safety. `<value>` can be either `safe`, `sketchy` (or `questionable`) or `unsafe`. |
| `safety` | having given safety. `<value>` can be either `safe`, `sketchy` or `unsafe`. |
| `rating` | alias of `safety` |
**Sort style tokens**

View File

@ -1,5 +1,5 @@
This assumes that you have Docker (version 17.05 or greater)
and Docker Compose (version 1.6.0 or greater) already installed.
This assumes that you have Docker (version 19.03 or greater)
and the Docker Compose CLI (version 1.27.0 or greater) already installed.
### Prepare things
@ -38,7 +38,7 @@ and Docker Compose (version 1.6.0 or greater) already installed.
This pulls the latest containers from docker.io:
```console
user@host:szuru$ docker-compose pull
user@host:szuru$ docker compose pull
```
If you have modified the application's source and would like to manually
@ -49,17 +49,17 @@ and Docker Compose (version 1.6.0 or greater) already installed.
For first run, it is recommended to start the database separately:
```console
user@host:szuru$ docker-compose up -d sql
user@host:szuru$ docker compose up -d sql
```
To start all containers:
```console
user@host:szuru$ docker-compose up -d
user@host:szuru$ docker compose up -d
```
To view/monitor the application logs:
```console
user@host:szuru$ docker-compose logs -f
user@host:szuru$ docker compose logs -f
# (CTRL+C to exit)
```
@ -84,13 +84,13 @@ and Docker Compose (version 1.6.0 or greater) already installed.
2. Build the containers:
```console
user@host:szuru$ docker-compose build
user@host:szuru$ docker compose build
```
That will attempt to build both containers, but you can specify `client`
or `server` to make it build only one.
If `docker-compose build` spits out:
If `docker compose build` spits out:
```
ERROR: Service 'server' failed to build: failed to parse platform : "" is an invalid component of "": platform specifier component must match "^[A-Za-z0-9_-]+$": invalid argument
@ -102,7 +102,7 @@ and Docker Compose (version 1.6.0 or greater) already installed.
user@host:szuru$ export DOCKER_BUILDKIT=1; export COMPOSE_DOCKER_CLI_BUILD=1
```
...and run `docker-compose build` again.
...and run `docker compose build` again.
*Note: If your changes are not taking effect in your builds, consider building
with `--no-cache`.*
@ -117,7 +117,7 @@ with `--no-cache`.*
run from docker:
```console
user@host:szuru$ docker-compose run server ./szuru-admin --help
user@host:szuru$ docker compose run server ./szuru-admin --help
```
will give you a breakdown on all available commands.

View File

@ -1,9 +1,7 @@
## Example Docker Compose configuration
##
## Use this as a template to set up docker-compose, or as guide to set up other
## Use this as a template to set up docker compose, or as guide to set up other
## orchestration services
version: '2'
services:
server:

View File

@ -74,7 +74,8 @@ def application(
) -> Tuple[bytes]:
try:
ctx = _create_context(env)
if "application/json" not in ctx.get_header("Accept"):
accept_header = ctx.get_header("Accept")
if accept_header != "*/*" and "application/json" not in accept_header:
raise errors.HttpNotAcceptable(
"ValidationError", "This API only supports JSON responses."
)