コミットを比較
155 コミット
作成者 | SHA1 | 日付 |
---|---|---|
n9k | beafe88324 | |
n9k | a6c31179b6 | |
n9k | ad7cc1c5b1 | |
n9k | 022bebed73 | |
n9k | a97f3254bd | |
n9k | ea2a194c93 | |
n9k | 12338747de | |
n9k | 8426a3490a | |
n9k | 3fca390a30 | |
n9k | 26a86fac7a | |
n9k | 071edaef3a | |
n9k | 6e9ba1a5db | |
n9k | 78753f7e0c | |
n9k | d05c5fec31 | |
n9k | 2a67bee82c | |
n9k | cbd494e3bf | |
n9k | b9c29a6fdd | |
n9k | 4d192392c4 | |
n9k | 2599528ae3 | |
n9k | 72d5a0526c | |
n9k | d7b4717cf5 | |
n9k | 68d6efff4e | |
n9k | 55a3d7fe1f | |
n9k | f3de542e3b | |
n9k | f3d613de3b | |
n9k | 6ddab6c969 | |
n9k | 4a22ca8a92 | |
n9k | e0f3ec0e07 | |
n9k | ddf8811ddc | |
n9k | 777448d83a | |
n9k | ed8ba4aacc | |
n9k | 51ff285067 | |
n9k | b9c2d89a5a | |
n9k | 0750cd180a | |
n9k | c2094f1d89 | |
n9k | 41ba8fd026 | |
n9k | fbd5f0b85f | |
n9k | 5e70fff946 | |
n9k | 41c4d4e2cc | |
n9k | 30a6991beb | |
n9k | bab82df319 | |
n9k | ffca068c86 | |
n9k | 2fe67815c6 | |
n9k | 47f0b529bf | |
n9k | 910c60a13e | |
n9k | 8915003d7f | |
n9k | 4b259d4a38 | |
n9k | a77a36d81b | |
n9k | e64a22457e | |
n9k | bce381376a | |
n9k | 32533110d2 | |
n9k | b9aa55babb | |
n9k | 3b18ea2a45 | |
n9k | c45031277c | |
n9k | 0a54efefab | |
n9k | f538410016 | |
n9k | 7f1c4b3fcd | |
n9k | b1f5bbdecd | |
n9k | 03887f4a63 | |
n9k | 96e78f2754 | |
n9k | f36840a9a6 | |
n9k | c93afdeccf | |
n9k | 2df92bb488 | |
n9k | 208ef9abc7 | |
n9k | b46b3c88d5 | |
n9k | 2a814c9816 | |
n9k | ab0ba513bf | |
n9k | 4f34a4a0e7 | |
n9k | e68bf56c9b | |
n9k | 9edeea1491 | |
n9k | 70036ca234 | |
n9k | 85ccd7b96e | |
n9k | 466298696b | |
n9k | ba5b21e5ed | |
n9k | cadc4076c5 | |
n9k | a7d5ff49cf | |
n9k | 69d2866001 | |
n9k | 81de4e4ccd | |
n9k | 21df8a5aa5 | |
n9k | 00f9c2ad16 | |
n9k | 6d6991e4e4 | |
n9k | 1862b27ac9 | |
n9k | 24ad7d9663 | |
n9k | 1d56bdfdd7 | |
n9k | f87b826b5b | |
n9k | 4a69b0a4ba | |
n9k | 8164c5971e | |
n9k | 6e8d8dc8e9 | |
n9k | 3733a213f0 | |
n9k | 0bd68e140a | |
n9k | 4a68759806 | |
n9k | 90e40701f8 | |
n9k | 9ad069ad08 | |
n9k | 8ab206d3c6 | |
n9k | 6bfd4e7446 | |
n9k | 5a647f2fb7 | |
n9k | 2c51e99f66 | |
n9k | 2a31d433b9 | |
n9k | 283c630b82 | |
n9k | 63e656f65b | |
n9k | ad02c7a6d8 | |
n9k | fc613737e0 | |
n9k | 2a15002e7b | |
n9k | 059504d0ad | |
n9k | 322dc9b361 | |
n9k | e10f6793b6 | |
n9k | 492078f6ce | |
n9k | 1472ce40ad | |
n9k | 8d4fe45c74 | |
n9k | 179f4390c0 | |
n9k | b63bd3096d | |
n9k | 19b926a3e5 | |
n9k | d91b49fb29 | |
n9k | 4034b6a79e | |
n9k | dd94bc7233 | |
n9k | 728181c415 | |
n9k | 084e91f285 | |
n9k | 0dd53569df | |
n9k | eb25b6d8a8 | |
n9k | a2ef88aff0 | |
n9k | b09c396d1c | |
n9k | b0cae89fad | |
n9k | 082fbf76d4 | |
n9k | 5a5585ff0d | |
n9k | 4a8d8966aa | |
n9k | 1ad67fd9cf | |
n9k | 54b34ce503 | |
n9k | 2344500d8f | |
n9k | 45965fc1db | |
n9k | abf7574ea9 | |
n9k | c6117a6f36 | |
n9k | 2a16f6a835 | |
n9k | 3bb2a81c5a | |
n9k | 45224e0779 | |
n9k | 77e0183c15 | |
n9k | 4992317ec9 | |
n9k | 76af3afd05 | |
n9k | c3237890ad | |
n9k | 95a940a14f | |
n9k | 6046598ed8 | |
n9k | 4a76fb023e | |
n9k | 0548065b1d | |
n9k | 35ce606d64 | |
n9k | 9143acafd1 | |
n9k | 4c5faf7dba | |
n9k | 6ae87be229 | |
n9k | a41f0d4f14 | |
n9k | 46f9b0ec08 | |
n9k | 22c84bc230 | |
n9k | 90e1e2099a | |
n9k | 1581e6ac89 | |
n9k | 1d5b446291 | |
n9k | 0b78a79111 | |
n9k | 893c4273b0 | |
n9k | fc4a528b04 |
44
README.md
44
README.md
|
@ -5,7 +5,7 @@ Recipe for livestreaming over Tor
|
|||
## Repo
|
||||
|
||||
The canonical location of this repo is
|
||||
<https://git.076.ne.jp/ninya9k/anonstream>.
|
||||
<https://gitler.moe/ninya9k/anonstream>.
|
||||
|
||||
These mirrors also exist:
|
||||
* <https://gitlab.com/ninya9k/anonstream>
|
||||
|
@ -13,12 +13,12 @@ These mirrors also exist:
|
|||
|
||||
## Setup
|
||||
|
||||
You must have Python 3.10 at a minimum. You can check your version of Python
|
||||
with `python --version`.
|
||||
You must have Python 3.10 at a minimum. You can check your version of
|
||||
Python with `python --version`.
|
||||
|
||||
Clone the repo:
|
||||
```sh
|
||||
git clone https://git.076.ne.jp/ninya9k/anonstream.git
|
||||
git clone https://gitler.moe/ninya9k/anonstream.git
|
||||
cd anonstream
|
||||
```
|
||||
|
||||
|
@ -29,14 +29,15 @@ source venv/bin/activate
|
|||
python -m pip install -r requirements.txt
|
||||
```
|
||||
|
||||
Before you run it you may want to edit the config ([/config.toml][config]).
|
||||
Most of the defaults are probably okay, but here are some that you might want
|
||||
to know what they do:
|
||||
This is all the setup needed to run the application, but before you do
|
||||
you may want to edit [the config](config.toml). Most of the defaults
|
||||
are probably okay, but here are some that you might want to know what
|
||||
they do:
|
||||
|
||||
* `secret_key`:
|
||||
used for cryptography, make it any long random string (e.g.
|
||||
`$ dd if=/dev/urandom bs=16 count=1 | base64`), definitely set this
|
||||
yourself before running in "production" (whatever that is for you)
|
||||
yourself before running in "production"
|
||||
|
||||
* `segments/directory`:
|
||||
directory containing stream segments, the default is `stream/` in
|
||||
|
@ -50,13 +51,17 @@ to know what they do:
|
|||
locations of fonts for the captcha, leaving it blank will use the
|
||||
default font
|
||||
|
||||
* `access/captcha`:
|
||||
if true, users must complete a captcha before accessing the site
|
||||
proper
|
||||
|
||||
Run it:
|
||||
```sh
|
||||
python -m anonstream
|
||||
```
|
||||
|
||||
This will start a webserver listening on the local host at port 5051 (use
|
||||
`--port PORT` to override).
|
||||
This will start a webserver listening on the local host at port 5051
|
||||
(use `--port PORT` to override).
|
||||
|
||||
If you go to `http://localhost:5051` in a web browser now you should see
|
||||
the site. When you started the webserver some credentials were printed
|
||||
|
@ -64,8 +69,9 @@ in the terminal; you can log in with those at
|
|||
`http://localhost:5051/login`.
|
||||
|
||||
The only things left are (1) streaming, and (2) letting other people
|
||||
access your stream. [/STREAMING.md][streaming] has instructions for
|
||||
setting up OBS Studio and a Tor onion service. If you want to use
|
||||
access your stream. [OBS.md](doc/guide/OBS.md) has instructions for
|
||||
setting up OBS Studio and [ONIONSITE.md](doc/guide/ONIONSITE.md) has
|
||||
instructions for creating a Tor onion service. If you want to use
|
||||
different streaming software and put your stream on the Internet some
|
||||
other way, read those instructions and copy the gist.
|
||||
|
||||
|
@ -86,21 +92,20 @@ python -m uvicorn asgi:create_app --factory --port 5051
|
|||
In either case you can explicitly set the location of the config file
|
||||
using the `ANONSTREAM_CONFIG` environment variable.
|
||||
|
||||
|
||||
## Hacking
|
||||
|
||||
anonstream has APIs for accessing internal state and hooking into
|
||||
internal events. They can be used by humans and other programs. See
|
||||
[/HACKING.md][hacking].
|
||||
[HACKING.md](/doc/HACKING.md).
|
||||
|
||||
## Copying
|
||||
|
||||
anonstream is AGPL 3.0 or later, see
|
||||
[/LICENSES/AGPL-3.0-or-later.md][licence].
|
||||
[LICENSES/AGPL-3.0-or-later.md][licence].
|
||||
|
||||
### Assets
|
||||
|
||||
* [/anonstream/static/settings.svg][settings.svg]:
|
||||
* [anonstream/static/settings.svg][settings.svg]:
|
||||
[setting](https://thenounproject.com/icon/setting-685325/) by
|
||||
[ulimicon](https://thenounproject.com/unlimicon/) is licensed under
|
||||
[CC BY 3.0](https://creativecommons.org/licenses/by/3.0/).
|
||||
|
@ -131,11 +136,8 @@ anonstream is AGPL 3.0 or later, see
|
|||
* werkzeug <https://github.com/pallets/werkzeug>
|
||||
([BSD 3-Clause][werkzeug])
|
||||
|
||||
[config]: https://git.076.ne.jp/ninya9k/anonstream/src/branch/master/config.toml
|
||||
[hacking]: https://git.076.ne.jp/ninya9k/anonstream/src/branch/master/HACKING.md
|
||||
[licence]: https://git.076.ne.jp/ninya9k/anonstream/src/branch/master/LICENSES/AGPL-3.0-or-later.md
|
||||
[settings.svg]: https://git.076.ne.jp/ninya9k/anonstream/src/branch/master/anonstream/static/settings.svg
|
||||
[streaming]: https://git.076.ne.jp/ninya9k/anonstream/src/branch/master/STREAMING.md
|
||||
[licence]: https://gitler.moe/ninya9k/anonstream/src/branch/master/LICENSES/AGPL-3.0-or-later.md
|
||||
[settings.svg]: https://gitler.moe/ninya9k/anonstream/src/branch/master/anonstream/static/settings.svg
|
||||
|
||||
[aiofiles]: https://github.com/Tinche/aiofiles/blob/master/LICENSE
|
||||
[captcha]: https://github.com/lepture/captcha/blob/master/LICENSE
|
||||
|
|
202
STREAMING.md
202
STREAMING.md
|
@ -1,202 +0,0 @@
|
|||
### Tor
|
||||
|
||||
Install tor. On Linux you can probably install a package called `tor` and
|
||||
be done, otherwise [compile it][tor]. On Windows download this binary:
|
||||
<https://www.torproject.org/download/tor/>.
|
||||
|
||||
Find your [torrc][torrc]. On Linux it is probably at `/etc/tor/torrc`.
|
||||
On Windows it might be somewhere in `%appdata%\tor` or something.
|
||||
|
||||
#### Background
|
||||
|
||||
A Tor hidden service is a regular TCP service that you talk to via a
|
||||
6-hop circuit created inside the Tor network. You initiate the creation
|
||||
of this circuit by providing tor with the service's hostname, a long
|
||||
base32-encoded string ending in ".onion". This hostname is derived from
|
||||
a pair of cryptographic keys generated by the hidden service operator.
|
||||
|
||||
A TCP service is a computer program you interact with over the Internet
|
||||
using TCP. TCP is a low-level networking protocol that sits above IP
|
||||
and creates a reliable "connection" between two computers. It handles
|
||||
the reordering and resending of packets that are shuffled or lost in
|
||||
transit on the Internet, such that the bytes sent from one computer will
|
||||
match exactly the bytes that arrive at the other (barring active
|
||||
interference (MITM), TCP is not secure). Getting reliability for free
|
||||
greatly simplifies the creation of network applications, and for this
|
||||
reason and other historical reasons TCP is ubiquitous on the Internet to
|
||||
this day. Many applications use TCP, for example IRC, SSH, RTMP,
|
||||
Minecraft, and HTTP (like us here).
|
||||
|
||||
#### Configuration
|
||||
|
||||
We are now going to create a hidden service. We need to give tor a
|
||||
directory to store the keys it generates, the location of our existing
|
||||
TCP service, and a virtual TCP port to listen on. There are two
|
||||
directives we have to add to our torrc: `HiddenServiceDir` and
|
||||
`HiddenServicePort`. (There is a commented-out section in the default
|
||||
torrc for hidden services, you may wish to make these changes there.)
|
||||
|
||||
##### `HiddenServiceDir`
|
||||
|
||||
`HiddenServiceDir` sets the directory for the hidden service's keys and
|
||||
other data. You could choose any directory, but you should make sure
|
||||
it's owned by the user the tor daemon runs as, and the directory's
|
||||
permissions are `0700/drwx------` (`rwx` for user, `---` for group and
|
||||
everyone else).
|
||||
|
||||
If you configure this in a way tor doesn't like, tor will kill itself
|
||||
and complain in one of these two ways:
|
||||
```
|
||||
Jun 11 23:21:17.000 [warn] Directory /home/n9k/projects/anonstream/hidden_service cannot be read: Permission denied
|
||||
```
|
||||
```
|
||||
Jun 12 02:37:51.036 [warn] Permissions on directory /var/lib/tor/anonstream are too permissive.
|
||||
```
|
||||
|
||||
The simplest option is to copy the examples provided in the torrc, on
|
||||
Linux that would probably be a directory inside `/var/lib/tor`, e.g.
|
||||
`HiddenServiceDir /var/lib/tor/anonstream`. tor will create this
|
||||
directory itself with the uid, gid, and permissions that it likes, which
|
||||
for me are these:
|
||||
```
|
||||
Access: (0700/drwx------) Uid: ( 42/ tor) Gid: ( 42/ tor)
|
||||
```
|
||||
|
||||
###### `HiddenServiceDir` troubleshooting
|
||||
|
||||
If you created the directory yourself and gave it the wrong permissions
|
||||
or uid or gid, delete the directory and let tor create it itself, or do
|
||||
this:
|
||||
```
|
||||
# chown -R tor:tor /var/lib/tor/anonstream
|
||||
# chmod 0700 /var/lib/tor/anonstream
|
||||
# chmod 0600 /var/lib/tor/anonstream/*
|
||||
# chmod 0700 /var/lib/tor/anonstream/*/
|
||||
```
|
||||
|
||||
If the user and group `tor` do not exist, your tor daemon runs as some
|
||||
other user. There may be a `User` directive in your torrc or in a file
|
||||
included by your torrc, for example on Debian it's `User debian-tor`.
|
||||
This means that a tor process running as root will immediately drop
|
||||
privileges by switching to the user `debian-tor`. The user's primary
|
||||
group should have the same name, check like this as root:
|
||||
`# id debian-tor`.
|
||||
|
||||
On Linux, if tor is already running you can see what user and group it is
|
||||
running as like this:
|
||||
```
|
||||
$ ps -C tor -o uid,gid,cmd
|
||||
UID GID CMD
|
||||
42 42 tor --quiet --runasdaemon 0
|
||||
$ cat /etc/passwd | grep :42: | cut -f 1 -d : # 42 is the UID here
|
||||
tor
|
||||
$ cat /etc/group | grep :42: | cut -f 1 -d : # 42 is the GID here
|
||||
tor
|
||||
```
|
||||
|
||||
Alternatively you could specify a directory inside the cloned
|
||||
repository, e.g. `/home/delphine/Documents/anonstream/hidden_service`
|
||||
or something like that. This will only work if the tor daemon has `rwx`
|
||||
permissions on the directory and at least `r-x` permissions on all the
|
||||
directories above it. This is probably not the case for you since your
|
||||
home folder might have `0700/drwx------` permissions. If you
|
||||
installed tor as a package, the daemon probably runs as its own user
|
||||
(e.g. `debian-tor` on Debian, `tor` on Arch/Gentoo). If you want to
|
||||
figure this out yourself go ahead. I would advise just using
|
||||
`/var/lib/tor/anonstream` though.
|
||||
|
||||
##### `HiddenServicePort`
|
||||
|
||||
Include this line verbatim directly below the `HiddenServiceDir` line:
|
||||
```
|
||||
HiddenServicePort 80 127.0.0.1:5051
|
||||
```
|
||||
|
||||
tor will listen for connections to our onion address at virtual port 80
|
||||
(the conventional HTTP port), and it will forward traffic to the TCP
|
||||
service at 127.0.0.1:5051, which is our webserver.
|
||||
|
||||
##### Finish
|
||||
|
||||
Example configuration:
|
||||
```
|
||||
HiddenServiceDir /var/lib/tor/anonstream
|
||||
HiddenServicePort 80 127.0.0.1:5051
|
||||
```
|
||||
|
||||
Reload tor to make it reread its torrc: `# pkill -HUP tor`. With
|
||||
systemd you can alternatively do `# systemctl reload tor`. If
|
||||
everything went well, the directory will have been created and your
|
||||
onion address will be in `$HIDDEN_SERVICE_DIR/hostname`.
|
||||
|
||||
### OBS Studio
|
||||
|
||||
Install OBS Studio. If the autoconfiguration wizard prompts you to
|
||||
choose a third-party service, ignore it since we're not going to be
|
||||
using a third-party service.
|
||||
|
||||
Click `Settings` and set these:
|
||||
|
||||
* Advanced
|
||||
* Recording
|
||||
* Filename Formatting: `stream`
|
||||
* Overwrite if file exists: yes
|
||||
* Video
|
||||
* Output (Scaled) Resolution: `960x540` or lower, or whatever you want
|
||||
* Common FPS Values: any integer framerate (e.g. 30 or 60)
|
||||
* Output
|
||||
* Output Mode: `Advanced`
|
||||
* Recording:
|
||||
```
|
||||
+----------------------------+-------------------------------------+
|
||||
| Field | Value |
|
||||
+============================+=====================================+
|
||||
| Type | `Custom Output (FFmpeg)` |
|
||||
+----------------------------+-------------------------------------+
|
||||
| FFmpeg Output Type | `Output to File` |
|
||||
+----------------------------+-------------------------------------+
|
||||
| File path or URL | same as the `segments/directory` |
|
||||
| | option in config.toml, but make it |
|
||||
| | an absolute path |
|
||||
+----------------------------+-------------------------------------+
|
||||
| Container Format | `hls` |
|
||||
+----------------------------+-------------------------------------+
|
||||
| Muxer Settings (if any) | `hls_init_time=0 hls_time=2 ` |
|
||||
| | `hls_list_size=120 ` |
|
||||
| | `hls_flags=delete_segments ` |
|
||||
| | `hls_segment_type=fmp4` |
|
||||
+----------------------------+-------------------------------------+
|
||||
| Video bitrate | `420 Kbps` or lower, or whatever |
|
||||
| | you want |
|
||||
+----------------------------+-------------------------------------+
|
||||
| Keyframe interval (frames) | `framerate` * `hls_time`, e.g. for |
|
||||
| | 60fps and an `hls_time` of 2 |
|
||||
| | seconds, set this to 120 |
|
||||
+----------------------------+-------------------------------------+
|
||||
| Video Encoder | libx264, or an H.264 hardware |
|
||||
| | encoder (e.g. `h264_nvenc` for |
|
||||
| | Nvidia, [see here][ffmpeg]) |
|
||||
+----------------------------+-------------------------------------+
|
||||
| Audio Bitrate | `96 Kbps`, or whatever you want |
|
||||
+----------------------------+-------------------------------------+
|
||||
| Audio Encoder | `aac` |
|
||||
+----------------------------+-------------------------------------+
|
||||
```
|
||||
|
||||
> *If this table looks garbled, read this file as plaintext or [click
|
||||
> here][plaintext] and scroll to the bottom.*
|
||||
|
||||
To start streaming click `Start Recording`.
|
||||
|
||||
When it is recording, segments older than four minutes will be regularly
|
||||
deleted, and when it stops recording the last four minutes worth of
|
||||
segments will remain the segments directory. (You can change the number
|
||||
of kept segments by modifying the `hls_list_size` option in the muxer
|
||||
settings.) When it is not recording, you can delete the files in the
|
||||
segments directory without consequence. Old segments will never be sent
|
||||
over the network even if they are not deleted.
|
||||
|
||||
[tor]: https://gitlab.torproject.org/tpo/core/tor
|
||||
[torrc]: https://support.torproject.org/#tbb-editing-torrc
|
||||
[ffmpeg]: https://trac.ffmpeg.org/wiki/HWAccelIntro
|
||||
[plaintext]: https://git.076.ne.jp/ninya9k/anonstream/raw/branch/master/STREAMING.md
|
|
@ -1,33 +1,40 @@
|
|||
# SPDX-FileCopyrightText: 2022 n9k <https://git.076.ne.jp/ninya9k>
|
||||
# SPDX-FileCopyrightText: 2022 n9k <https://gitler.moe/ninya9k>
|
||||
# SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
from collections import OrderedDict
|
||||
|
||||
from quart_compress import Compress
|
||||
|
||||
from anonstream.config import update_flask_from_toml
|
||||
from anonstream.utils.captcha import create_captcha_factory, create_captcha_signer
|
||||
from anonstream.emote import load_emote_schema
|
||||
from anonstream.quart import Quart
|
||||
from anonstream.utils.captcha import create_captcha_factory, create_captcha_signer
|
||||
from anonstream.utils.user import generate_blank_allowedness
|
||||
|
||||
compress = Compress()
|
||||
__version__ = '1.6.9'
|
||||
|
||||
def create_app(toml_config):
|
||||
app = Quart('anonstream')
|
||||
app.jinja_options['trim_blocks'] = True
|
||||
app.jinja_options['lstrip_blocks'] = True
|
||||
app = Quart('anonstream', static_folder=None)
|
||||
app.version = __version__
|
||||
|
||||
auth_password = update_flask_from_toml(toml_config, app.config)
|
||||
print('Broadcaster username:', app.config['AUTH_USERNAME'])
|
||||
print('Broadcaster password:', auth_password)
|
||||
|
||||
# Nicer whitespace in templates
|
||||
app.jinja_options['trim_blocks'] = True
|
||||
app.jinja_options['lstrip_blocks'] = True
|
||||
|
||||
# Compress some responses
|
||||
compress.init_app(app)
|
||||
Compress(app)
|
||||
app.config.update({
|
||||
"COMPRESS_MIN_SIZE": 2048,
|
||||
"COMPRESS_LEVEL": 9,
|
||||
'COMPRESS_MIN_SIZE': 2048,
|
||||
'COMPRESS_LEVEL': 9,
|
||||
})
|
||||
|
||||
# Global state: messages, users, captchas
|
||||
# Global state: messages, users, captchas, etc.
|
||||
app.messages_by_id = OrderedDict()
|
||||
app.messages = app.messages_by_id.values()
|
||||
|
||||
|
@ -38,6 +45,15 @@ def create_app(toml_config):
|
|||
app.captcha_factory = create_captcha_factory(app.config['CAPTCHA_FONTS'])
|
||||
app.captcha_signer = create_captcha_signer(app.config['SECRET_KEY'])
|
||||
|
||||
app.failures = OrderedDict() # access captcha failures
|
||||
app.allowedness = generate_blank_allowedness()
|
||||
|
||||
# Read emote schema
|
||||
try:
|
||||
app.emotes = load_emote_schema(app.config['EMOTE_SCHEMA'])
|
||||
except (OSError, json.JSONDecodeError) as e:
|
||||
raise AssertionError(f'couldn\'t load emote schema: {e!r}') from e
|
||||
|
||||
# State for tasks
|
||||
app.users_update_buffer = set()
|
||||
app.stream_title = None
|
||||
|
@ -45,20 +61,26 @@ def create_app(toml_config):
|
|||
app.stream_viewership = None
|
||||
app.last_info_task = None
|
||||
|
||||
# Background tasks' asyncio.sleep tasks, cancelled on shutdown
|
||||
app.background_sleep = set()
|
||||
# asyncio tasks to be cancelled on shutdown
|
||||
app.tasks = set()
|
||||
|
||||
# Queues for event socket clients
|
||||
app.event_queues = set()
|
||||
|
||||
@app.after_serving
|
||||
async def shutdown():
|
||||
# Force all background tasks to finish
|
||||
for task in app.background_sleep:
|
||||
# Cancel started asyncio tasks that would otherwise block shutdown
|
||||
# The asyncio tasks we create are:
|
||||
# * quart background tasks awaiting asyncio.sleep()
|
||||
for task in app.tasks:
|
||||
task.cancel()
|
||||
|
||||
@app.before_serving
|
||||
async def startup():
|
||||
# Create routes and background tasks
|
||||
import anonstream.routes
|
||||
import anonstream.tasks
|
||||
|
||||
# Start control server
|
||||
if app.config['SOCKET_CONTROL_ENABLED']:
|
||||
from anonstream.control.server import start_control_server_at
|
||||
|
@ -73,13 +95,8 @@ def create_app(toml_config):
|
|||
from anonstream.events import start_event_server_at
|
||||
async def start_event_server():
|
||||
return await start_event_server_at(
|
||||
app.config['SOCKET_EVENT_ADDRESS']
|
||||
app.config['SOCKET_EVENT_ADDRESS']
|
||||
)
|
||||
app.add_background_task(start_event_server)
|
||||
|
||||
|
||||
# Create routes and background tasks
|
||||
import anonstream.routes
|
||||
import anonstream.tasks
|
||||
|
||||
return app
|
||||
|
|
|
@ -1,3 +1,6 @@
|
|||
# SPDX-FileCopyrightText: 2022 n9k <https://gitler.moe/ninya9k>
|
||||
# SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import argparse
|
||||
import os
|
||||
|
||||
|
@ -31,7 +34,7 @@ parser = argparse.ArgumentParser(
|
|||
parser.add_argument(
|
||||
'--config', '-c',
|
||||
metavar='FILE',
|
||||
default=os.environ.get('ANONSTREAM_CONFIG', 'config.toml'),
|
||||
default=os.environ.get('ANONSTREAM_CONFIG', DEFAULT_CONFIG),
|
||||
help=(
|
||||
'location of config.toml '
|
||||
f'(default: $ANONSTREAM_CONFIG or {want_rel(DEFAULT_CONFIG)})'
|
||||
|
@ -48,4 +51,4 @@ args = parser.parse_args()
|
|||
with open(args.config) as fp:
|
||||
config = toml.load(fp)
|
||||
app = create_app(config)
|
||||
uvicorn.run(app, port=args.port)
|
||||
uvicorn.run(app, port=args.port, server_header=False)
|
||||
|
|
|
@ -0,0 +1,26 @@
|
|||
# SPDX-FileCopyrightText: 2022 n9k <https://gitler.moe/ninya9k>
|
||||
# SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import time
|
||||
|
||||
from quart import current_app
|
||||
|
||||
CONFIG = current_app.config
|
||||
FAILURES = current_app.failures
|
||||
|
||||
def add_failure(message):
|
||||
timestamp = time.time_ns() // 1_000_000
|
||||
while timestamp in FAILURES:
|
||||
timestamp += 1
|
||||
FAILURES[timestamp] = message
|
||||
|
||||
while len(FAILURES) > CONFIG['MAX_FAILURES']:
|
||||
FAILURES.popitem(last=False)
|
||||
|
||||
return timestamp
|
||||
|
||||
def pop_failure(failure_id):
|
||||
try:
|
||||
return FAILURES.pop(failure_id)
|
||||
except KeyError:
|
||||
return None
|
|
@ -1,4 +1,4 @@
|
|||
# SPDX-FileCopyrightText: 2022 n9k <https://git.076.ne.jp/ninya9k>
|
||||
# SPDX-FileCopyrightText: 2022 n9k <https://gitler.moe/ninya9k>
|
||||
# SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
from quart import current_app
|
||||
|
|
|
@ -1,4 +1,4 @@
|
|||
# SPDX-FileCopyrightText: 2022 n9k <https://git.076.ne.jp/ninya9k>
|
||||
# SPDX-FileCopyrightText: 2022 n9k <https://gitler.moe/ninya9k>
|
||||
# SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import secrets
|
||||
|
@ -33,8 +33,6 @@ def get_random_captcha_digest():
|
|||
solution = generate_random_captcha_solution()
|
||||
digest = generate_captcha_digest(CAPTCHA_SIGNER, salt, solution)
|
||||
CAPTCHAS[digest] = {'solution': solution}
|
||||
while len(CAPTCHAS) >= CONFIG['MAX_CAPTCHAS']:
|
||||
CAPTCHAS.popitem(last=False)
|
||||
|
||||
return digest
|
||||
|
||||
|
|
|
@ -1,4 +1,4 @@
|
|||
# SPDX-FileCopyrightText: 2022 n9k <https://git.076.ne.jp/ninya9k>
|
||||
# SPDX-FileCopyrightText: 2022 n9k <https://gitler.moe/ninya9k>
|
||||
# SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import time
|
||||
|
@ -9,6 +9,7 @@ from quart import current_app, escape
|
|||
from anonstream.broadcast import broadcast, broadcast_users_update
|
||||
from anonstream.events import notify_event_sockets
|
||||
from anonstream.helpers.chat import generate_nonce_hash, get_scrollback
|
||||
from anonstream.helpers.emote import insert_emotes
|
||||
from anonstream.utils.chat import get_message_for_websocket, get_approx_linespan
|
||||
|
||||
CONFIG = current_app.config
|
||||
|
@ -30,9 +31,9 @@ def get_all_messages_for_websocket():
|
|||
))
|
||||
|
||||
def add_chat_message(user, nonce, comment, ignore_empty=False):
|
||||
# Special case: if the comment is empty, do nothing and return
|
||||
# Special case: if the comment is empty, do nothing and return None
|
||||
if ignore_empty and len(comment) == 0:
|
||||
return False
|
||||
return None
|
||||
|
||||
timestamp_ms = time.time_ns() // 1_000_000
|
||||
timestamp = timestamp_ms // 1000
|
||||
|
@ -93,7 +94,7 @@ def add_chat_message(user, nonce, comment, ignore_empty=False):
|
|||
else:
|
||||
seq = last_message['seq'] + 1
|
||||
dt = datetime.utcfromtimestamp(timestamp)
|
||||
markup = escape(comment)
|
||||
markup = insert_emotes(escape(comment))
|
||||
message = {
|
||||
'id': message_id,
|
||||
'seq': seq,
|
||||
|
@ -107,8 +108,15 @@ def add_chat_message(user, nonce, comment, ignore_empty=False):
|
|||
}
|
||||
MESSAGES_BY_ID[message_id] = message
|
||||
|
||||
# Limit number of stored messages
|
||||
while len(MESSAGES_BY_ID) > CONFIG['MAX_CHAT_MESSAGES']:
|
||||
MESSAGES_BY_ID.pop(last=False)
|
||||
MESSAGES_BY_ID.popitem(last=False)
|
||||
|
||||
# Deverify user every n messages
|
||||
if CONFIG['CHAT_DEVERIFY_CLOCK'] is not None:
|
||||
user['clock'] = (user['clock'] + 1) % CONFIG['CHAT_DEVERIFY_CLOCK']
|
||||
if user['clock'] == 0 and not user['broadcaster']:
|
||||
user['verified'] = False
|
||||
|
||||
# Notify event sockets that a chat message was added
|
||||
notify_event_sockets({
|
||||
|
@ -129,7 +137,7 @@ def add_chat_message(user, nonce, comment, ignore_empty=False):
|
|||
},
|
||||
)
|
||||
|
||||
return True
|
||||
return seq
|
||||
|
||||
def delete_chat_messages(seqs):
|
||||
seq_set = set(seqs)
|
||||
|
|
|
@ -1,4 +1,4 @@
|
|||
# SPDX-FileCopyrightText: 2022 n9k <https://git.076.ne.jp/ninya9k>
|
||||
# SPDX-FileCopyrightText: 2022 n9k <https://gitler.moe/ninya9k>
|
||||
# SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import os
|
||||
|
@ -19,6 +19,7 @@ def update_flask_from_toml(toml_config, flask_config):
|
|||
'AUTH_USERNAME': toml_config['auth']['username'],
|
||||
'AUTH_PWHASH': auth_pwhash,
|
||||
'AUTH_TOKEN': generate_token(),
|
||||
'ACCESS_CAPTCHA': toml_config['access']['captcha'],
|
||||
})
|
||||
for flask_section in toml_to_flask_sections(toml_config):
|
||||
flask_config.update(flask_section)
|
||||
|
@ -38,6 +39,7 @@ def toml_to_flask_sections(config):
|
|||
toml_to_flask_section_flood,
|
||||
toml_to_flask_section_captcha,
|
||||
toml_to_flask_section_nojs,
|
||||
toml_to_flask_section_emote,
|
||||
)
|
||||
for toml_to_flask_section in TOML_TO_FLASK_SECTIONS:
|
||||
yield toml_to_flask_section(config)
|
||||
|
@ -83,11 +85,14 @@ def toml_to_flask_section_names(config):
|
|||
def toml_to_flask_section_memory(config):
|
||||
cfg = config['memory']
|
||||
assert cfg['states'] >= 0
|
||||
assert cfg['captchas'] >= 1
|
||||
assert cfg['failures'] >= 0
|
||||
assert cfg['chat_scrollback'] >= 0
|
||||
assert cfg['chat_messages'] >= cfg['chat_scrollback']
|
||||
return {
|
||||
'MAX_STATES': cfg['states'],
|
||||
'MAX_CAPTCHAS': cfg['captchas'],
|
||||
'MAX_FAILURES': cfg['failures'],
|
||||
'MAX_CHAT_MESSAGES': cfg['chat_messages'],
|
||||
'MAX_CHAT_SCROLLBACK': cfg['chat_scrollback'],
|
||||
}
|
||||
|
@ -115,6 +120,7 @@ def toml_to_flask_section_presence(config):
|
|||
|
||||
def toml_to_flask_section_chat(config):
|
||||
cfg = config['chat']
|
||||
assert cfg['force_captcha_every'] >= 0
|
||||
return {
|
||||
'CHAT_COMMENT_MAX_LENGTH': cfg['max_comment_length'],
|
||||
'CHAT_COMMENT_MAX_LINES': cfg['max_comment_lines'],
|
||||
|
@ -123,6 +129,7 @@ def toml_to_flask_section_chat(config):
|
|||
'CHAT_BACKGROUND_COLOUR': color_to_colour(cfg['background_color']),
|
||||
'CHAT_TRIPCODE_PASSWORD_MAX_LENGTH': cfg['max_tripcode_password_length'],
|
||||
'CHAT_LEGACY_TRIPCODE_ALGORITHM': cfg['legacy_tripcode_algorithm'],
|
||||
'CHAT_DEVERIFY_CLOCK': cfg['force_captcha_every'] or None,
|
||||
}
|
||||
|
||||
def toml_to_flask_section_flood(config):
|
||||
|
@ -158,3 +165,9 @@ def toml_to_flask_section_nojs(config):
|
|||
'NOJS_REFRESH_USERS': round(cfg['refresh_users']),
|
||||
'NOJS_TIMEOUT_CHAT': round(cfg['timeout_chat']),
|
||||
}
|
||||
|
||||
def toml_to_flask_section_emote(config):
|
||||
cfg = config['emote']
|
||||
return {
|
||||
'EMOTE_SCHEMA': cfg['schema'],
|
||||
}
|
||||
|
|
|
@ -1,4 +1,4 @@
|
|||
# SPDX-FileCopyrightText: 2022 n9k <https://git.076.ne.jp/ninya9k>
|
||||
# SPDX-FileCopyrightText: 2022 n9k <https://gitler.moe/ninya9k>
|
||||
# SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
class ControlSocketExit(Exception):
|
||||
|
|
|
@ -1,20 +1,28 @@
|
|||
# SPDX-FileCopyrightText: 2022 n9k <https://git.076.ne.jp/ninya9k>
|
||||
# SPDX-FileCopyrightText: 2022 n9k <https://gitler.moe/ninya9k>
|
||||
# SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
from anonstream.control.spec import ParseException, Parsed
|
||||
from anonstream.control.spec.common import Str
|
||||
from anonstream.control.spec.methods.allowedness import SPEC as SPEC_ALLOWEDNESS
|
||||
from anonstream.control.spec.methods.chat import SPEC as SPEC_CHAT
|
||||
from anonstream.control.spec.methods.exit import SPEC as SPEC_EXIT
|
||||
from anonstream.control.spec.methods.config import SPEC as SPEC_CONFIG
|
||||
from anonstream.control.spec.methods.emote import SPEC as SPEC_EMOTE
|
||||
from anonstream.control.spec.methods.help import SPEC as SPEC_HELP
|
||||
from anonstream.control.spec.methods.quit import SPEC as SPEC_QUIT
|
||||
from anonstream.control.spec.methods.title import SPEC as SPEC_TITLE
|
||||
from anonstream.control.spec.methods.tripcode import SPEC as SPEC_TRIPCODE
|
||||
from anonstream.control.spec.methods.user import SPEC as SPEC_USER
|
||||
|
||||
SPEC = Str({
|
||||
'help': SPEC_HELP,
|
||||
'exit': SPEC_EXIT,
|
||||
'quit': SPEC_QUIT,
|
||||
'title': SPEC_TITLE,
|
||||
'chat': SPEC_CHAT,
|
||||
'user': SPEC_USER,
|
||||
'allowednesss': SPEC_ALLOWEDNESS,
|
||||
'emote': SPEC_EMOTE,
|
||||
'config': SPEC_CONFIG,
|
||||
'tripcode': SPEC_TRIPCODE,
|
||||
})
|
||||
|
||||
async def parse(request):
|
||||
|
|
|
@ -1,4 +1,4 @@
|
|||
# SPDX-FileCopyrightText: 2022 n9k <https://git.076.ne.jp/ninya9k>
|
||||
# SPDX-FileCopyrightText: 2022 n9k <https://gitler.moe/ninya9k>
|
||||
# SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import asyncio
|
||||
|
|
|
@ -1,4 +1,4 @@
|
|||
# SPDX-FileCopyrightText: 2022 n9k <https://git.076.ne.jp/ninya9k>
|
||||
# SPDX-FileCopyrightText: 2022 n9k <https://gitler.moe/ninya9k>
|
||||
# SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
class ParseException(Exception):
|
||||
|
|
|
@ -1,12 +1,19 @@
|
|||
# SPDX-FileCopyrightText: 2022 n9k <https://git.076.ne.jp/ninya9k>
|
||||
# SPDX-FileCopyrightText: 2022 n9k <https://gitler.moe/ninya9k>
|
||||
# SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import json
|
||||
|
||||
from anonstream.control.spec import Spec, NoParse, Ambiguous, Parsed
|
||||
from quart import current_app
|
||||
|
||||
from anonstream.control.spec import Spec, NoParse, Ambiguous, BadArgument, Parsed
|
||||
from anonstream.control.spec.utils import get_item, startswith
|
||||
|
||||
USERS_BY_TOKEN = current_app.users_by_token
|
||||
USERS = current_app.users
|
||||
|
||||
class Str(Spec):
|
||||
AS_ARG = False
|
||||
|
||||
def __init__(self, directives):
|
||||
self.directives = directives
|
||||
|
||||
|
@ -33,7 +40,10 @@ class Str(Spec):
|
|||
f'bad word at position {index} {word!r}: ambiguous '
|
||||
f'abbreviation: {set(candidates)}'
|
||||
)
|
||||
return self.directives[directive], 1, []
|
||||
args = []
|
||||
if self.AS_ARG:
|
||||
args.append(directive)
|
||||
return self.directives[directive], 1, args
|
||||
|
||||
class End(Spec):
|
||||
def __init__(self, fn):
|
||||
|
@ -48,6 +58,9 @@ class Args(Spec):
|
|||
def __init__(self, spec):
|
||||
self.spec = spec
|
||||
|
||||
class ArgsStr(Str):
|
||||
AS_ARG = True
|
||||
|
||||
class ArgsInt(Args):
|
||||
def consume(self, words, index):
|
||||
try:
|
||||
|
@ -102,6 +115,14 @@ class ArgsJson(Args):
|
|||
obj = self.transform_obj(obj)
|
||||
return self.spec, 1, [obj]
|
||||
|
||||
class ArgsJsonBoolean(ArgsJson):
|
||||
def assert_obj(self, index, obj_json, obj):
|
||||
if not isinstance(obj, bool):
|
||||
raise NoParse(
|
||||
f'bad argument at position {index} {obj_json!r}: '
|
||||
f'could not decode json boolean'
|
||||
)
|
||||
|
||||
class ArgsJsonString(ArgsJson):
|
||||
def assert_obj(self, index, obj_json, obj):
|
||||
if not isinstance(obj, str):
|
||||
|
@ -109,3 +130,47 @@ class ArgsJsonString(ArgsJson):
|
|||
f'bad argument at position {index} {obj_json!r}: '
|
||||
f'could not decode json string'
|
||||
)
|
||||
|
||||
class ArgsJsonArray(ArgsJson):
|
||||
def assert_obj(self, index, obj_json, obj):
|
||||
if not isinstance(obj, list):
|
||||
raise NoParse(
|
||||
f'bad argument at position {index} {obj_json!r}: '
|
||||
f'could not decode json array'
|
||||
)
|
||||
|
||||
class ArgsJsonStringArray(ArgsJson):
|
||||
def assert_obj(self, index, obj_json, obj):
|
||||
if not isinstance(obj, list):
|
||||
raise NoParse(
|
||||
f'bad argument at position {index} {obj_json!r}: '
|
||||
f'could not decode json array'
|
||||
)
|
||||
if any(not isinstance(item, str) for item in obj):
|
||||
raise NoParse(
|
||||
f'bad argument at position {index} {obj_json!r}: '
|
||||
f'could not decode json array of strings'
|
||||
)
|
||||
|
||||
class ArgsJsonTokenUser(ArgsJsonString):
|
||||
def transform_obj(self, token):
|
||||
try:
|
||||
user = USERS_BY_TOKEN[token]
|
||||
except KeyError:
|
||||
raise BadArgument(f'no user with token {token!r}')
|
||||
return user
|
||||
|
||||
class ArgsJsonHashUser(ArgsString):
|
||||
def transform_string(self, token_hash):
|
||||
for user in USERS:
|
||||
if user['token_hash'] == token_hash:
|
||||
break
|
||||
else:
|
||||
raise BadArgument(f'no user with token_hash {token_hash!r}')
|
||||
return user
|
||||
|
||||
def ArgsUser(spec):
|
||||
return Str({
|
||||
'token': ArgsJsonTokenUser(spec),
|
||||
'hash': ArgsJsonHashUser(spec),
|
||||
})
|
||||
|
|
|
@ -0,0 +1,105 @@
|
|||
# SPDX-FileCopyrightText: 2022 n9k <https://gitler.moe/ninya9k>
|
||||
# SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import json
|
||||
|
||||
from quart import current_app
|
||||
|
||||
from anonstream.control.exceptions import CommandFailed
|
||||
from anonstream.control.spec.common import Str, End, ArgsStr, ArgsJsonBoolean, ArgsJsonString, ArgsJsonStringArray
|
||||
from anonstream.control.spec.utils import json_dumps_contiguous
|
||||
|
||||
ALLOWEDNESS = current_app.allowedness
|
||||
|
||||
async def cmd_allowedness_help():
|
||||
normal = ['allowedness', 'help']
|
||||
response = (
|
||||
'Usage: allowedness [show | set default BOOLEAN | add LIST KEYTUPLE VALUE | remove LIST KEYTUPLE VALUE]\n'
|
||||
'Commands:\n'
|
||||
' allowedness [show]........................\n'
|
||||
' allowedness setdefault BOOLEAN............set the default allowedness\n'
|
||||
#' allowedness clear LIST all................\n'
|
||||
#' allowedness clear LIST keytuple KEYTUPLE..\n'
|
||||
' allowedness add LIST KEYTUPLE STRING......add to the blacklist/whitelist\n'
|
||||
' allowedness remove LIST KEYTUPLE STRING...remove from the blacklist/whitelist\n'
|
||||
'Definitions:\n'
|
||||
' BOOLEAN...................................={true | false}\n'
|
||||
' LIST......................................={blacklist | whitelist}\n'
|
||||
' KEYTUPLE..................................keys to burrow into a user with, e.g. (\'tripcode\', \'digest\'), encoded as a json array\n'
|
||||
' STRING....................................a json string\n'
|
||||
)
|
||||
return normal, response
|
||||
|
||||
async def cmd_allowedness_show():
|
||||
allowedness_for_json = {
|
||||
'blacklist': {},
|
||||
'whitelist': {},
|
||||
'default': ALLOWEDNESS['default'],
|
||||
}
|
||||
for colourlist in ('blacklist', 'whitelist'):
|
||||
for keytuple, values in ALLOWEDNESS[colourlist].items():
|
||||
allowedness_for_json[colourlist]['.'.join(keytuple)] = sorted(values)
|
||||
normal = ['allowedness', 'show']
|
||||
response = json.dumps(allowedness_for_json) + '\n'
|
||||
return normal, response
|
||||
|
||||
async def cmd_allowedness_setdefault(value):
|
||||
ALLOWEDNESS['default'] = value
|
||||
normal = ['allowednesss', 'setdefault', json_dumps_contiguous(value)]
|
||||
response = ''
|
||||
return normal, response
|
||||
|
||||
async def cmd_allowedness_add(colourlist, keytuple_list, value):
|
||||
keytuple = tuple(keytuple_list)
|
||||
try:
|
||||
values = ALLOWEDNESS[colourlist][keytuple]
|
||||
except KeyError:
|
||||
raise CommandFailed(f'no such keytuple {keytuple!r} in list {colourlist!r}')
|
||||
else:
|
||||
values.add(value)
|
||||
normal = [
|
||||
'allowednesss',
|
||||
'add',
|
||||
colourlist,
|
||||
json_dumps_contiguous(keytuple),
|
||||
json_dumps_contiguous(value),
|
||||
]
|
||||
response = ''
|
||||
return normal, response
|
||||
|
||||
async def cmd_allowedness_remove(colourlist, keytuple_list, value):
|
||||
keytuple = tuple(keytuple_list)
|
||||
try:
|
||||
values = ALLOWEDNESS[colourlist][keytuple]
|
||||
except KeyError:
|
||||
raise CommandFailed(f'no such keytuple {keytuple!r} in list {colourlist!r}')
|
||||
else:
|
||||
try:
|
||||
values.remove(value)
|
||||
except KeyError:
|
||||
pass
|
||||
normal = [
|
||||
'allowednesss',
|
||||
'remove',
|
||||
colourlist,
|
||||
json_dumps_contiguous(keytuple),
|
||||
json_dumps_contiguous(value),
|
||||
]
|
||||
response = ''
|
||||
return normal, response
|
||||
|
||||
SPEC = Str({
|
||||
None: End(cmd_allowedness_show),
|
||||
'help': End(cmd_allowedness_help),
|
||||
'show': End(cmd_allowedness_show),
|
||||
'setdefault': ArgsJsonBoolean(End(cmd_allowedness_setdefault)),
|
||||
#'clear': cmd_allowedness_clear,
|
||||
'add': ArgsStr({
|
||||
'blacklist': ArgsJsonStringArray(ArgsJsonString(End(cmd_allowedness_add))),
|
||||
'whitelist': ArgsJsonStringArray(ArgsJsonString(End(cmd_allowedness_add))),
|
||||
}),
|
||||
'remove': ArgsStr({
|
||||
'blacklist': ArgsJsonStringArray(ArgsJsonString(End(cmd_allowedness_remove))),
|
||||
'whitelist': ArgsJsonStringArray(ArgsJsonString(End(cmd_allowedness_remove))),
|
||||
}),
|
||||
})
|
|
@ -1,12 +1,19 @@
|
|||
# SPDX-FileCopyrightText: 2022 n9k <https://git.076.ne.jp/ninya9k>
|
||||
# SPDX-FileCopyrightText: 2022 n9k <https://gitler.moe/ninya9k>
|
||||
# SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import itertools
|
||||
import json
|
||||
|
||||
from quart import current_app
|
||||
|
||||
from anonstream.chat import delete_chat_messages
|
||||
from anonstream.control.exceptions import CommandFailed
|
||||
from anonstream.control.spec import NoParse
|
||||
from anonstream.control.spec.common import Str, End, Args
|
||||
from anonstream.control.spec.common import Str, End, Args, ArgsJsonString, ArgsUser
|
||||
from anonstream.control.spec.utils import get_item, json_dumps_contiguous
|
||||
from anonstream.chat import add_chat_message, Rejected
|
||||
|
||||
MESSAGES = current_app.messages
|
||||
|
||||
class ArgsSeqs(Args):
|
||||
def consume(self, words, index):
|
||||
|
@ -31,25 +38,52 @@ class ArgsSeqs(Args):
|
|||
async def cmd_chat_help():
|
||||
normal = ['chat', 'help']
|
||||
response = (
|
||||
'Usage: chat {show [MESSAGES] | delete SEQS}\n'
|
||||
'Usage: chat {show | delete SEQS | add USER NONCE COMMENT}\n'
|
||||
'Commands:\n'
|
||||
#' chat show [MESSAGES]......show chat messages\n'
|
||||
' chat delete SEQS..........delete chat messages\n'
|
||||
' chat show......................show all chat messages\n'
|
||||
' chat delete SEQS...............delete chat messages\n'
|
||||
' chat add USER NONCE COMMENT....add chat message\n'
|
||||
'Definitions:\n'
|
||||
#' MESSAGES..................undefined\n'
|
||||
' SEQS......................=SEQ [SEQ...]\n'
|
||||
' SEQ.......................a chat message\'s seq, base-10 integer\n'
|
||||
' SEQS.......................=SEQ [SEQ...]\n'
|
||||
' SEQ........................a chat message\'s seq, base-10 integer\n'
|
||||
' USER.......................={token TOKEN | hash HASH}\n'
|
||||
' TOKEN......................a user\'s token, json string\n'
|
||||
' HASH.......................a user\'s token hash\n'
|
||||
' NONCE......................a chat message\'s nonce, json string\n'
|
||||
' COMMENT....................json string\n'
|
||||
)
|
||||
return normal, response
|
||||
|
||||
async def cmd_chat_show():
|
||||
normal = ['chat', 'show']
|
||||
response = json.dumps(tuple(MESSAGES), separators=(',', ':')) + '\n'
|
||||
return normal, response
|
||||
|
||||
async def cmd_chat_delete(*seqs):
|
||||
delete_chat_messages(seqs)
|
||||
normal = ['chat', 'delete', *map(str, seqs)]
|
||||
response = ''
|
||||
return normal, response
|
||||
|
||||
async def cmd_chat_add(user, nonce, comment):
|
||||
try:
|
||||
seq = add_chat_message(user, nonce, comment)
|
||||
except Rejected as e:
|
||||
raise CommandFailed(f'rejected: {e}') from e
|
||||
else:
|
||||
assert seq is not None
|
||||
normal = [
|
||||
'chat', 'add',
|
||||
'token', json_dumps_contiguous(user['token']),
|
||||
json_dumps_contiguous(nonce), json_dumps_contiguous(comment),
|
||||
]
|
||||
response = str(seq) + '\n'
|
||||
return normal, response
|
||||
|
||||
SPEC = Str({
|
||||
None: End(cmd_chat_help),
|
||||
'help': End(cmd_chat_help),
|
||||
'show': End(cmd_chat_show),
|
||||
'delete': ArgsSeqs(End(cmd_chat_delete)),
|
||||
'add': ArgsUser(ArgsJsonString(ArgsJsonString(End(cmd_chat_add)))),
|
||||
})
|
||||
|
|
|
@ -0,0 +1,43 @@
|
|||
# SPDX-FileCopyrightText: 2022 n9k <https://gitler.moe/ninya9k>
|
||||
# SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import json
|
||||
|
||||
from quart import current_app
|
||||
|
||||
from anonstream.control.exceptions import CommandFailed
|
||||
from anonstream.control.spec.common import Str, End, ArgsString
|
||||
|
||||
CONFIG = current_app.config
|
||||
|
||||
async def cmd_config_help():
|
||||
normal = ['config', 'help']
|
||||
response = (
|
||||
'Usage: config show OPTION\n'
|
||||
'Commands:\n'
|
||||
' config show OPTION....show entry in app.config\n'
|
||||
'Definitions:\n'
|
||||
' OPTION................app.config key, re:[A-Z0-9_]+\n'
|
||||
)
|
||||
return normal, response
|
||||
|
||||
async def cmd_config_show(option):
|
||||
if option in {'SECRET_KEY', 'SECRET_KEY_STRING'}:
|
||||
raise CommandFailed('not going to show our secret key')
|
||||
try:
|
||||
value = CONFIG[option]
|
||||
except KeyError:
|
||||
raise CommandFailed(f'no config option with key {option!r}')
|
||||
try:
|
||||
value_json = json.dumps(value)
|
||||
except (TypeError, ValueError):
|
||||
raise CommandFailed(f'value is not json serializable')
|
||||
normal = ['config', 'show']
|
||||
response = value_json + '\n'
|
||||
return normal, response
|
||||
|
||||
SPEC = Str({
|
||||
None: End(cmd_config_help),
|
||||
'help': End(cmd_config_help),
|
||||
'show': ArgsString(End(cmd_config_show)),
|
||||
})
|
|
@ -0,0 +1,61 @@
|
|||
# SPDX-FileCopyrightText: 2022 n9k <https://gitler.moe/ninya9k>
|
||||
# SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import json
|
||||
|
||||
from quart import current_app
|
||||
|
||||
from anonstream.emote import load_emote_schema_async, BadEmote
|
||||
from anonstream.helpers.emote import get_emote_markup
|
||||
from anonstream.control.spec.common import Str, End
|
||||
from anonstream.control.exceptions import CommandFailed
|
||||
|
||||
CONFIG = current_app.config
|
||||
EMOTES = current_app.emotes
|
||||
|
||||
async def cmd_emote_help():
|
||||
normal = ['emote', 'help']
|
||||
response = (
|
||||
'Usage: emote {show | reload}\n'
|
||||
'Commands:\n'
|
||||
' emote show........show all current emotes\n'
|
||||
' emote reload......try to reload the emote schema (existing messages are not modified)\n'
|
||||
)
|
||||
return normal, response
|
||||
|
||||
async def cmd_emote_show():
|
||||
emotes_for_json = [emote.copy() for emote in EMOTES]
|
||||
for emote in emotes_for_json:
|
||||
emote['regex'] = emote['regex'].pattern
|
||||
normal = ['emote', 'show']
|
||||
response = json.dumps(emotes_for_json) + '\n'
|
||||
return normal, response
|
||||
|
||||
async def cmd_emote_reload():
|
||||
try:
|
||||
emotes = await load_emote_schema_async(CONFIG['EMOTE_SCHEMA'])
|
||||
except OSError as e:
|
||||
raise CommandFailed(f'could not read emote schema: {e}') from e
|
||||
except json.JSONDecodeError as e:
|
||||
raise CommandFailed('could not decode emote schema as json') from e
|
||||
except BadEmote as e:
|
||||
error, *_ = e.args
|
||||
raise CommandFailed(error) from e
|
||||
else:
|
||||
# Mutate current_app.emotes in place
|
||||
EMOTES.clear()
|
||||
for emote in emotes:
|
||||
EMOTES.append(emote)
|
||||
# Clear emote markup cache -- emotes by the same name may have changed
|
||||
get_emote_markup.cache_clear()
|
||||
normal = ['emote', 'reload']
|
||||
response = ''
|
||||
return normal, response
|
||||
|
||||
SPEC = Str({
|
||||
None: End(cmd_emote_help),
|
||||
'help': End(cmd_emote_help),
|
||||
'show': End(cmd_emote_show),
|
||||
'reload': End(cmd_emote_reload),
|
||||
})
|
||||
|
|
@ -1,21 +0,0 @@
|
|||
# SPDX-FileCopyrightText: 2022 n9k <https://git.076.ne.jp/ninya9k>
|
||||
# SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
from anonstream.control.spec.common import Str, End
|
||||
from anonstream.control.exceptions import ControlSocketExit
|
||||
|
||||
async def cmd_exit():
|
||||
raise ControlSocketExit
|
||||
|
||||
async def cmd_exit_help():
|
||||
normal = ['exit', 'help']
|
||||
response = (
|
||||
'Usage: exit\n'
|
||||
'close the connection\n'
|
||||
)
|
||||
return normal, response
|
||||
|
||||
SPEC = Str({
|
||||
None: End(cmd_exit),
|
||||
'help': End(cmd_exit_help),
|
||||
})
|
|
@ -1,4 +1,4 @@
|
|||
# SPDX-FileCopyrightText: 2022 n9k <https://git.076.ne.jp/ninya9k>
|
||||
# SPDX-FileCopyrightText: 2022 n9k <https://gitler.moe/ninya9k>
|
||||
# SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
from anonstream.control.spec.common import Str, End
|
||||
|
@ -9,18 +9,27 @@ async def cmd_help():
|
|||
'Usage: METHOD [COMMAND | help]\n'
|
||||
'Examples:\n'
|
||||
' help...........................show this help message\n'
|
||||
' exit...........................close the control connection\n'
|
||||
' quit...........................close the control connection\n'
|
||||
' title [show]...................show the stream title\n'
|
||||
' title set TITLE................set the stream title\n'
|
||||
' user [show]....................show a list of users\n'
|
||||
' user attr USER.................set an attribute of a user\n'
|
||||
' user get USER ATTR.............set an attribute of a user\n'
|
||||
' user set USER ATTR VALUE.......set an attribute of a user\n'
|
||||
#' user kick USERS [FAREWELL].....kick users\n'
|
||||
' user eyes USER [show]..........show a list of active video responses\n'
|
||||
' user eyes USER delete EYES_ID..end a video response\n'
|
||||
#' chat show MESSAGES.............show a list of messages\n'
|
||||
' chat delete SEQS...............delete a set of messages\n'
|
||||
' user add VERIFIED TOKEN........add new user\n'
|
||||
' chat show......................show a list of all chat messages\n'
|
||||
' chat delete SEQS...............delete a set of chat messages\n'
|
||||
' chat add USER NONCE COMMENT....add a chat message\n'
|
||||
' allowedness [show].............show the current allowedness\n'
|
||||
' allowedness setdefault BOOLEAN.set the default allowedness\n'
|
||||
' allowedness add SET STRING.....add to the blacklist/whitelist\n'
|
||||
' allowedness remove SET STRING..remove from the blacklist/whitelist\n'
|
||||
' emote show.....................show all current emotes\n'
|
||||
' emote reload...................try reloading the emote schema\n'
|
||||
' config show OPTION.............show app config option\n'
|
||||
' tripcode generate PASSWORD.....show tripcode for given password\n'
|
||||
)
|
||||
return normal, response
|
||||
|
||||
|
|
|
@ -0,0 +1,22 @@
|
|||
# SPDX-FileCopyrightText: 2022 n9k <https://gitler.moe/ninya9k>
|
||||
# SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
from anonstream.control.spec.common import Str, End
|
||||
from anonstream.control.exceptions import ControlSocketExit
|
||||
|
||||
async def cmd_quit():
|
||||
raise ControlSocketExit
|
||||
|
||||
async def cmd_quit_help():
|
||||
normal = ['quit', 'help']
|
||||
response = (
|
||||
'Usage: quit\n'
|
||||
'Commands:\n'
|
||||
' quit......close the connection\n'
|
||||
)
|
||||
return normal, response
|
||||
|
||||
SPEC = Str({
|
||||
None: End(cmd_quit),
|
||||
'help': End(cmd_quit_help),
|
||||
})
|
|
@ -1,4 +1,4 @@
|
|||
# SPDX-FileCopyrightText: 2022 n9k <https://git.076.ne.jp/ninya9k>
|
||||
# SPDX-FileCopyrightText: 2022 n9k <https://gitler.moe/ninya9k>
|
||||
# SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import json
|
||||
|
@ -12,7 +12,7 @@ from anonstream.stream import get_stream_title, set_stream_title
|
|||
async def cmd_title_help():
|
||||
normal = ['title', 'help']
|
||||
response = (
|
||||
'Usage: title [show | set TITLE]\n'
|
||||
'Usage: title {show | set TITLE}\n'
|
||||
'Commands:\n'
|
||||
' title [show].......show the stream title\n'
|
||||
' title set TITLE....set the stream title to TITLE\n'
|
||||
|
|
|
@ -0,0 +1,42 @@
|
|||
# SPDX-FileCopyrightText: 2022 n9k <https://gitler.moe/ninya9k>
|
||||
# SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import json
|
||||
|
||||
from quart import current_app
|
||||
|
||||
from anonstream.control.exceptions import CommandFailed
|
||||
from anonstream.control.spec.common import Str, End, ArgsJsonString
|
||||
from anonstream.control.spec.utils import json_dumps_contiguous
|
||||
from anonstream.helpers.tripcode import generate_tripcode
|
||||
|
||||
CONFIG = current_app.config
|
||||
|
||||
async def cmd_tripcode_help():
|
||||
normal = ['tripcode', 'help']
|
||||
response = (
|
||||
'Usage: tripcode generate PASSWORD\n'
|
||||
'Commands:\n'
|
||||
' tripcode generate PASSWORD....show tripcode for given password\n'
|
||||
'Definitions:\n'
|
||||
' PASSWORD................json string, max length in config.toml (`chat.max_tripcode_password_length`)\n'
|
||||
)
|
||||
return normal, response
|
||||
|
||||
async def cmd_tripcode_generate(password):
|
||||
if len(password) > CONFIG['CHAT_TRIPCODE_PASSWORD_MAX_LENGTH']:
|
||||
raise CommandFailed(
|
||||
f'password exceeded maximum configured length of '
|
||||
f'{CONFIG["CHAT_TRIPCODE_PASSWORD_MAX_LENGTH"]} '
|
||||
f'characters'
|
||||
)
|
||||
tripcode = generate_tripcode(password)
|
||||
normal = ['tripcode', 'generate', json_dumps_contiguous(password)]
|
||||
response = json.dumps(tripcode) + '\n'
|
||||
return normal, response
|
||||
|
||||
SPEC = Str({
|
||||
None: End(cmd_tripcode_help),
|
||||
'help': End(cmd_tripcode_help),
|
||||
'generate': ArgsJsonString(End(cmd_tripcode_generate)),
|
||||
})
|
|
@ -1,4 +1,4 @@
|
|||
# SPDX-FileCopyrightText: 2022 n9k <https://git.076.ne.jp/ninya9k>
|
||||
# SPDX-FileCopyrightText: 2022 n9k <https://gitler.moe/ninya9k>
|
||||
# SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import json
|
||||
|
@ -6,38 +6,15 @@ import json
|
|||
from quart import current_app
|
||||
|
||||
from anonstream.control.exceptions import CommandFailed
|
||||
from anonstream.control.spec import BadArgument
|
||||
from anonstream.control.spec.common import Str, End, ArgsInt, ArgsString, ArgsJson, ArgsJsonString
|
||||
from anonstream.control.spec.utils import get_item, json_dumps_contiguous
|
||||
from anonstream.control.spec.common import Str, End, ArgsInt, ArgsString, ArgsJson, ArgsJsonBoolean, ArgsJsonString, ArgsUser
|
||||
from anonstream.control.spec.utils import json_dumps_contiguous
|
||||
from anonstream.utils.user import USER_WEBSOCKET_ATTRS
|
||||
from anonstream.routes.wrappers import generate_and_add_user
|
||||
from anonstream.wrappers import get_timestamp
|
||||
|
||||
USERS_BY_TOKEN = current_app.users_by_token
|
||||
USERS = current_app.users
|
||||
USERS_UPDATE_BUFFER = current_app.users_update_buffer
|
||||
|
||||
class ArgsJsonTokenUser(ArgsJsonString):
|
||||
def transform_obj(self, token):
|
||||
try:
|
||||
user = USERS_BY_TOKEN[token]
|
||||
except KeyError:
|
||||
raise BadArgument(f'no user with token {token!r}')
|
||||
return user
|
||||
|
||||
class ArgsJsonHashUser(ArgsString):
|
||||
def transform_string(self, token_hash):
|
||||
for user in USERS:
|
||||
if user['token_hash'] == token_hash:
|
||||
break
|
||||
else:
|
||||
raise BadArgument(f'no user with token_hash {token_hash!r}')
|
||||
return user
|
||||
|
||||
def ArgsUser(spec):
|
||||
return Str({
|
||||
'token': ArgsJsonTokenUser(spec),
|
||||
'hash': ArgsJsonHashUser(spec),
|
||||
})
|
||||
|
||||
async def cmd_user_help():
|
||||
normal = ['user', 'help']
|
||||
response = (
|
||||
|
@ -46,15 +23,18 @@ async def cmd_user_help():
|
|||
' user [show]...................show all users\' tokens\n'
|
||||
' user attr USER................show names of a user\'s attributes\n'
|
||||
' user get USER ATTR............show an attribute of a user\n'
|
||||
' user set USER ATTR............set an attribute of a user\n'
|
||||
' user set USER ATTR VALUE......set an attribute of a user\n'
|
||||
' user eyes USER [show].........show a user\'s active video responses\n'
|
||||
' user eyes USER delete EYES_ID.end a video response to a user\n'
|
||||
' user add VERIFIED TOKEN.......add new user\n'
|
||||
'Definitions:\n'
|
||||
' USER..........................={token TOKEN | hash HASH}\n'
|
||||
' TOKEN.........................a token, json string\n'
|
||||
' HASH..........................a token hash\n'
|
||||
' ATTR..........................a user attribute, re:[a-z0-9_]+\n'
|
||||
' VALUE.........................json value\n'
|
||||
' EYES_ID.......................a user\'s eyes_id, base 10 integer\n'
|
||||
' VERIFIED......................user\'s verified state: true = normal, false = can\'t chat, null = will be kicked to access captcha\n'
|
||||
)
|
||||
return normal, response
|
||||
|
||||
|
@ -78,10 +58,8 @@ async def cmd_user_get(user, attr):
|
|||
except (TypeError, ValueError) as e:
|
||||
raise CommandFailed('value is not representable in json') from e
|
||||
normal = [
|
||||
'user',
|
||||
'get',
|
||||
'token',
|
||||
json_dumps_contiguous(user['token']),
|
||||
'user', 'get',
|
||||
'token', json_dumps_contiguous(user['token']),
|
||||
attr,
|
||||
]
|
||||
response = value_json + '\n'
|
||||
|
@ -94,10 +72,8 @@ async def cmd_user_set(user, attr, value):
|
|||
if attr in USER_WEBSOCKET_ATTRS:
|
||||
USERS_UPDATE_BUFFER.add(user['token'])
|
||||
normal = [
|
||||
'user',
|
||||
'set',
|
||||
'token',
|
||||
json_dumps_contiguous(user['token']),
|
||||
'user', 'set',
|
||||
'token', json_dumps_contiguous(user['token']),
|
||||
attr,
|
||||
json_dumps_contiguous(value),
|
||||
]
|
||||
|
@ -106,11 +82,9 @@ async def cmd_user_set(user, attr, value):
|
|||
|
||||
async def cmd_user_eyes_show(user):
|
||||
normal = [
|
||||
'user',
|
||||
'eyes',
|
||||
'token',
|
||||
json_dumps_contiguous(user['token']),
|
||||
'show'
|
||||
'user', 'eyes',
|
||||
'token', json_dumps_contiguous(user['token']),
|
||||
'show',
|
||||
]
|
||||
response = json.dumps(user['eyes']['current']) + '\n'
|
||||
return normal, response
|
||||
|
@ -121,12 +95,24 @@ async def cmd_user_eyes_delete(user, eyes_id):
|
|||
except KeyError:
|
||||
pass
|
||||
normal = [
|
||||
'user',
|
||||
'eyes',
|
||||
'token',
|
||||
json_dumps_contiguous(user['token']),
|
||||
'delete',
|
||||
str(eyes_id),
|
||||
'user', 'eyes',
|
||||
'token', json_dumps_contiguous(user['token']),
|
||||
'delete', str(eyes_id),
|
||||
]
|
||||
response = ''
|
||||
return normal, response
|
||||
|
||||
async def cmd_user_add(verified, token):
|
||||
if token in USERS_BY_TOKEN:
|
||||
raise CommandFailed(f'user with token {token!r} already exists')
|
||||
_user = generate_and_add_user(
|
||||
timestamp=get_timestamp(),
|
||||
token=token,
|
||||
verified=verified,
|
||||
)
|
||||
normal = [
|
||||
'user', 'add',
|
||||
json_dumps_contiguous(verified), json_dumps_contiguous(token),
|
||||
]
|
||||
response = ''
|
||||
return normal, response
|
||||
|
@ -143,4 +129,5 @@ SPEC = Str({
|
|||
'show': End(cmd_user_eyes_show),
|
||||
'delete': ArgsInt(End(cmd_user_eyes_delete)),
|
||||
})),
|
||||
'add': ArgsJsonBoolean(ArgsJsonString(End(cmd_user_add))),
|
||||
})
|
||||
|
|
|
@ -1,4 +1,4 @@
|
|||
# SPDX-FileCopyrightText: 2022 n9k <https://git.076.ne.jp/ninya9k>
|
||||
# SPDX-FileCopyrightText: 2022 n9k <https://gitler.moe/ninya9k>
|
||||
# SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import json
|
||||
|
|
|
@ -0,0 +1,55 @@
|
|||
import json
|
||||
import re
|
||||
|
||||
import aiofiles
|
||||
from quart import escape
|
||||
|
||||
class BadEmote(Exception):
|
||||
pass
|
||||
|
||||
class BadEmoteName(BadEmote):
|
||||
pass
|
||||
|
||||
def _load_emote_schema(emotes):
|
||||
for key in ('name', 'file', 'width', 'height'):
|
||||
for emote in emotes:
|
||||
if key not in emote:
|
||||
raise BadEmote(f'emotes must have a `{key}`: {emote}')
|
||||
precompute_emote_regex(emotes)
|
||||
return emotes
|
||||
|
||||
def load_emote_schema(filepath):
|
||||
with open(filepath) as fp:
|
||||
emotes = json.load(fp)
|
||||
return _load_emote_schema(emotes)
|
||||
|
||||
async def load_emote_schema_async(filepath):
|
||||
async with aiofiles.open(filepath) as fp:
|
||||
data = await fp.read(8192)
|
||||
return _load_emote_schema(json.loads(data))
|
||||
|
||||
def precompute_emote_regex(schema):
|
||||
for emote in schema:
|
||||
if not emote['name']:
|
||||
raise BadEmoteName(f'emote names cannot be empty: {emote}')
|
||||
if re.search(r'\s', emote['name']):
|
||||
raise BadEmoteName(
|
||||
f'whitespace is not allowed in emote names: {emote["name"]!r}'
|
||||
)
|
||||
for length in (emote['width'], emote['height']):
|
||||
if length is not None and (not isinstance(length, int) or length < 0):
|
||||
raise BadEmoteName(
|
||||
f'emote dimensions must be null or non-negative integers: '
|
||||
f'{emote}'
|
||||
)
|
||||
# If the emote name begins with a word character [a-zA-Z0-9_],
|
||||
# match only if preceded by a non-word character or the empty
|
||||
# string. Similarly for the end of the emote name.
|
||||
# Examples:
|
||||
# * ":joy:" matches "abc :joy:~xyz" and "abc:joy:xyz"
|
||||
# * "JoySi" matches "abc JoySi~xyz" but NOT "abcJoySiabc"
|
||||
onset = r'(?:^|(?<=\W))' if re.fullmatch(r'\w', emote['name'][0]) else r''
|
||||
finish = r'(?:$|(?=\W))' if re.fullmatch(r'\w', emote['name'][-1]) else r''
|
||||
emote['regex'] = re.compile(''.join(
|
||||
(onset, re.escape(escape(emote['name'])), finish)
|
||||
))
|
|
@ -1,4 +1,4 @@
|
|||
# SPDX-FileCopyrightText: 2022 n9k <https://git.076.ne.jp/ninya9k>
|
||||
# SPDX-FileCopyrightText: 2022 n9k <https://gitler.moe/ninya9k>
|
||||
# SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import asyncio
|
||||
|
@ -6,8 +6,8 @@ import json
|
|||
|
||||
from quart import current_app
|
||||
|
||||
async def start_event_server_at(address):
|
||||
return await asyncio.start_unix_server(serve_event_client, address)
|
||||
def start_event_server_at(address):
|
||||
return asyncio.start_unix_server(serve_event_client, address)
|
||||
|
||||
async def serve_event_client(reader, writer):
|
||||
reader.feed_eof()
|
||||
|
|
|
@ -1,4 +1,4 @@
|
|||
# SPDX-FileCopyrightText: 2022 n9k <https://git.076.ne.jp/ninya9k>
|
||||
# SPDX-FileCopyrightText: 2022 n9k <https://gitler.moe/ninya9k>
|
||||
# SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import base64
|
||||
|
|
|
@ -1,4 +1,4 @@
|
|||
# SPDX-FileCopyrightText: 2022 n9k <https://git.076.ne.jp/ninya9k>
|
||||
# SPDX-FileCopyrightText: 2022 n9k <https://gitler.moe/ninya9k>
|
||||
# SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import hashlib
|
||||
|
|
|
@ -0,0 +1,27 @@
|
|||
import markupsafe
|
||||
from functools import lru_cache
|
||||
|
||||
from quart import current_app, escape, url_for, Markup
|
||||
|
||||
EMOTES = current_app.emotes
|
||||
|
||||
@lru_cache
|
||||
def get_emote_markup(emote_name, emote_file, emote_width, emote_height):
|
||||
emote_name_markup = escape(emote_name)
|
||||
width = '' if emote_width is None else f'width="{escape(emote_width)}" '
|
||||
height = '' if emote_height is None else f'height="{escape(emote_height)}" '
|
||||
return Markup(
|
||||
f'''<img class="emote" '''
|
||||
f'''src="{escape(url_for('static', filename=emote_file))}" '''
|
||||
f'''{width}{height}'''
|
||||
f'''alt="{emote_name_markup}" title="{emote_name_markup}">'''
|
||||
)
|
||||
|
||||
def insert_emotes(markup):
|
||||
assert isinstance(markup, markupsafe.Markup)
|
||||
for emote in EMOTES:
|
||||
emote_markup = get_emote_markup(
|
||||
emote['name'], emote['file'], emote['width'], emote['height'],
|
||||
)
|
||||
markup = emote['regex'].sub(emote_markup, markup)
|
||||
return Markup(markup)
|
|
@ -1,4 +1,4 @@
|
|||
# SPDX-FileCopyrightText: 2022 n9k <https://git.076.ne.jp/ninya9k>
|
||||
# SPDX-FileCopyrightText: 2022 n9k <https://gitler.moe/ninya9k>
|
||||
# SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import base64
|
||||
|
|
|
@ -1,4 +1,4 @@
|
|||
# SPDX-FileCopyrightText: 2022 n9k <https://git.076.ne.jp/ninya9k>
|
||||
# SPDX-FileCopyrightText: 2022 n9k <https://gitler.moe/ninya9k>
|
||||
# SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import hashlib
|
||||
|
@ -22,7 +22,10 @@ def generate_token_hash_and_tag(token):
|
|||
|
||||
return token_hash, tag
|
||||
|
||||
def generate_user(timestamp, token, broadcaster, presence):
|
||||
def generate_user(
|
||||
timestamp, token, broadcaster,
|
||||
verified=False, presence=Presence.NOTWATCHING, headers=None,
|
||||
):
|
||||
colour = generate_colour(
|
||||
seed='name\0' + token,
|
||||
bg=CONFIG['CHAT_BACKGROUND_COLOUR'],
|
||||
|
@ -34,7 +37,7 @@ def generate_user(timestamp, token, broadcaster, presence):
|
|||
'token_hash': token_hash,
|
||||
'tag': tag,
|
||||
'broadcaster': broadcaster,
|
||||
'verified': broadcaster,
|
||||
'verified': verified or broadcaster,
|
||||
'websockets': {},
|
||||
'name': None,
|
||||
'color': colour_to_color(colour),
|
||||
|
@ -44,6 +47,8 @@ def generate_user(timestamp, token, broadcaster, presence):
|
|||
'seen': timestamp,
|
||||
'watching': -inf,
|
||||
'eyes': -inf,
|
||||
'reading': -inf,
|
||||
'allowed': -inf,
|
||||
},
|
||||
'presence': presence,
|
||||
'linespan': deque(),
|
||||
|
@ -51,6 +56,8 @@ def generate_user(timestamp, token, broadcaster, presence):
|
|||
'total': 0,
|
||||
'current': {},
|
||||
},
|
||||
'headers': headers,
|
||||
'clock': 0,
|
||||
}
|
||||
|
||||
def get_default_name(user):
|
||||
|
|
|
@ -1,7 +1,7 @@
|
|||
# This file is pretty much entirely based on a snippet from asgi.py in
|
||||
# the Quart repository (MIT, see README.md). That means it takes on the
|
||||
# MIT licence I guess(???) If not then it's the same as every other file
|
||||
# by me: 2022 n9k <https://git.076.ne.jp/ninya9k>, AGPL 3.0 or any later
|
||||
# by me: 2022 n9k <https://gitler.moe/ninya9k>, AGPL 3.0 or any later
|
||||
# version.
|
||||
|
||||
import asyncio
|
||||
|
|
|
@ -1,6 +1,7 @@
|
|||
# SPDX-FileCopyrightText: 2022 n9k <https://git.076.ne.jp/ninya9k>
|
||||
# SPDX-FileCopyrightText: 2022 n9k <https://gitler.moe/ninya9k>
|
||||
# SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import anonstream.routes.error
|
||||
import anonstream.routes.core
|
||||
import anonstream.routes.websocket
|
||||
import anonstream.routes.nojs
|
||||
|
|
|
@ -1,66 +1,153 @@
|
|||
# SPDX-FileCopyrightText: 2022 n9k <https://git.076.ne.jp/ninya9k>
|
||||
# SPDX-FileCopyrightText: 2022 n9k <https://gitler.moe/ninya9k>
|
||||
# SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import math
|
||||
import re
|
||||
from urllib.parse import quote
|
||||
|
||||
from quart import current_app, request, render_template, abort, make_response, redirect, url_for, abort
|
||||
from werkzeug.exceptions import TooManyRequests
|
||||
from quart import current_app, request, render_template, abort, make_response, redirect, url_for, send_from_directory
|
||||
from werkzeug.exceptions import Forbidden, NotFound, TooManyRequests
|
||||
|
||||
from anonstream.captcha import get_captcha_image
|
||||
from anonstream.access import add_failure, pop_failure
|
||||
from anonstream.captcha import get_captcha_image, get_random_captcha_digest
|
||||
from anonstream.segments import segments, StopSendingSegments
|
||||
from anonstream.stream import is_online, get_stream_uptime
|
||||
from anonstream.user import watched, create_eyes, renew_eyes, EyesException, RatelimitedEyes
|
||||
from anonstream.routes.wrappers import with_user_from, auth_required
|
||||
from anonstream.user import watching, create_eyes, renew_eyes, EyesException, RatelimitedEyes, TooManyEyes, ensure_allowedness, Blacklisted, SecretClub
|
||||
from anonstream.routes.wrappers import with_user_from, auth_required, generate_and_add_user, clean_cache_headers, etag_conditional
|
||||
from anonstream.helpers.captcha import check_captcha_digest, Answer
|
||||
from anonstream.utils.security import generate_csp
|
||||
from anonstream.utils.user import identifying_string
|
||||
from anonstream.wrappers import with_timestamp
|
||||
|
||||
CAPTCHA_SIGNER = current_app.captcha_signer
|
||||
STATIC_DIRECTORY = current_app.root_path / 'static'
|
||||
|
||||
@current_app.route('/')
|
||||
@with_user_from(request)
|
||||
async def home(user):
|
||||
return await render_template(
|
||||
'home.html',
|
||||
csp=generate_csp(),
|
||||
user=user,
|
||||
)
|
||||
@with_user_from(
|
||||
request,
|
||||
fallback_to_token=True,
|
||||
ignore_allowedness=True,
|
||||
redundant_token_redirect=True,
|
||||
)
|
||||
async def home(timestamp, user_or_token):
|
||||
match user_or_token:
|
||||
case str() | None as token:
|
||||
failure_id = request.args.get('failure', type=int)
|
||||
response = await render_template(
|
||||
'captcha.html',
|
||||
csp=generate_csp(),
|
||||
token=token,
|
||||
digest=get_random_captcha_digest(),
|
||||
failure=pop_failure(failure_id),
|
||||
)
|
||||
case dict() as user:
|
||||
try:
|
||||
ensure_allowedness(user, timestamp=timestamp)
|
||||
except Blacklisted:
|
||||
raise Forbidden('You have been blacklisted.')
|
||||
except SecretClub:
|
||||
# TODO allow changing tripcode
|
||||
raise Forbidden('You have not been whitelisted.')
|
||||
else:
|
||||
response = await render_template(
|
||||
'home.html',
|
||||
csp=generate_csp(),
|
||||
user=user,
|
||||
version=current_app.version,
|
||||
)
|
||||
return response
|
||||
|
||||
@current_app.route('/stream.mp4')
|
||||
@with_user_from(request)
|
||||
async def stream(user):
|
||||
async def stream(timestamp, user):
|
||||
if not is_online():
|
||||
return abort(404)
|
||||
|
||||
try:
|
||||
eyes_id = create_eyes(user, dict(request.headers))
|
||||
except RatelimitedEyes as e:
|
||||
retry_after, *_ = e.args
|
||||
return TooManyRequests(), {'Retry-After': math.ceil(retry_after)}
|
||||
except EyesException:
|
||||
return abort(429)
|
||||
|
||||
def segment_read_hook(uri):
|
||||
raise NotFound('The stream is offline.')
|
||||
else:
|
||||
try:
|
||||
renew_eyes(user, eyes_id, just_read_new_segment=True)
|
||||
except EyesException as e:
|
||||
raise StopSendingSegments(f'eyes {eyes_id} not allowed: {e!r}') from e
|
||||
print(f'{uri}: {eyes_id}~{user["token"]}')
|
||||
watched(user)
|
||||
|
||||
generator = segments(segment_read_hook, token=user['token'])
|
||||
response = await make_response(generator)
|
||||
response.headers['Content-Type'] = 'video/mp4'
|
||||
response.timeout = None
|
||||
eyes_id = create_eyes(user, tuple(request.headers))
|
||||
except RatelimitedEyes as e:
|
||||
retry_after, *_ = e.args
|
||||
error = TooManyRequests(
|
||||
f'You have requested the stream recently. '
|
||||
f'Try again in {retry_after:.1f} seconds.'
|
||||
)
|
||||
response = await current_app.handle_http_exception(error)
|
||||
response = await make_response(response)
|
||||
response.headers['Retry-After'] = math.ceil(retry_after)
|
||||
raise abort(response)
|
||||
except TooManyEyes as e:
|
||||
n_eyes, *_ = e.args
|
||||
raise TooManyRequests(
|
||||
f'You have made {n_eyes} concurrent requests for the stream. '
|
||||
f'End one of those before making a new request.'
|
||||
)
|
||||
else:
|
||||
@with_timestamp(precise=True)
|
||||
def segment_read_hook(timestamp, uri):
|
||||
user['last']['seen'] = timestamp
|
||||
try:
|
||||
renew_eyes(timestamp, user, eyes_id, just_read_new_segment=True)
|
||||
except EyesException as e:
|
||||
raise StopSendingSegments(
|
||||
f'eyes {eyes_id} not allowed: {e!r}'
|
||||
) from e
|
||||
else:
|
||||
user['last']['watching'] = timestamp
|
||||
print(f'{uri}: \033[37m{eyes_id}\033[0m~{identifying_string(user)}')
|
||||
generator = segments(segment_read_hook, token=f'\033[35m{user["token"]}\033[0m')
|
||||
response = await make_response(generator)
|
||||
response.headers['Content-Type'] = 'video/mp4'
|
||||
response.timeout = None
|
||||
return response
|
||||
|
||||
@current_app.route('/login')
|
||||
@auth_required
|
||||
async def login():
|
||||
return redirect(url_for('home'))
|
||||
return redirect(url_for('home'), 303)
|
||||
|
||||
@current_app.route('/captcha.jpg')
|
||||
@with_user_from(request)
|
||||
async def captcha(user):
|
||||
@with_user_from(request, fallback_to_token=True)
|
||||
async def captcha(timestamp, user_or_token):
|
||||
digest = request.args.get('digest', '')
|
||||
image = get_captcha_image(digest)
|
||||
if image is None:
|
||||
return abort(410)
|
||||
else:
|
||||
return image, {'Content-Type': 'image/jpeg'}
|
||||
|
||||
@current_app.post('/access')
|
||||
@with_user_from(request, fallback_to_token=True, ignore_allowedness=True)
|
||||
async def access(timestamp, user_or_token):
|
||||
match user_or_token:
|
||||
case str() | None as token:
|
||||
form = await request.form
|
||||
digest = form.get('digest', '')
|
||||
answer = form.get('answer', '')
|
||||
match check_captcha_digest(CAPTCHA_SIGNER, digest, answer):
|
||||
case Answer.MISSING:
|
||||
failure_id = add_failure('Captcha is required')
|
||||
case Answer.BAD:
|
||||
failure_id = add_failure('Captcha was incorrect')
|
||||
case Answer.EXPIRED:
|
||||
failure_id = add_failure('Captcha has expired')
|
||||
case Answer.OK:
|
||||
failure_id = None
|
||||
user = generate_and_add_user(timestamp, token, verified=True)
|
||||
if failure_id is not None:
|
||||
response = redirect(url_for('home', token=token, failure=failure_id), 303)
|
||||
else:
|
||||
response = redirect(url_for('home', token=user['token']), 303)
|
||||
response.headers['Set-Cookie'] = f'token={quote(user["token"])}; path=/'
|
||||
case dict() as user:
|
||||
response = redirect(url_for('home', token=user['token']), 303)
|
||||
return response
|
||||
|
||||
@current_app.route('/static/<path:filename>')
|
||||
@with_user_from(request)
|
||||
@etag_conditional
|
||||
@clean_cache_headers
|
||||
async def static(timestamp, user, filename):
|
||||
response = await send_from_directory(STATIC_DIRECTORY, filename)
|
||||
if filename in {'style.css', 'anonstream.js'}:
|
||||
response.headers['Cache-Control'] = 'no-cache'
|
||||
return response
|
||||
|
|
|
@ -0,0 +1,8 @@
|
|||
from quart import current_app, render_template
|
||||
|
||||
from werkzeug.exceptions import default_exceptions
|
||||
|
||||
for error in default_exceptions:
|
||||
async def handle(error):
|
||||
return await render_template('error.html', error=error), error.code
|
||||
current_app.register_error_handler(error, handle)
|
|
@ -1,4 +1,4 @@
|
|||
# SPDX-FileCopyrightText: 2022 n9k <https://git.076.ne.jp/ninya9k>
|
||||
# SPDX-FileCopyrightText: 2022 n9k <https://gitler.moe/ninya9k>
|
||||
# SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
from quart import current_app, request, render_template, redirect, url_for, escape, Markup
|
||||
|
@ -6,20 +6,21 @@ from quart import current_app, request, render_template, redirect, url_for, esca
|
|||
from anonstream.captcha import get_random_captcha_digest_for
|
||||
from anonstream.chat import add_chat_message, Rejected
|
||||
from anonstream.stream import is_online, get_stream_title, get_stream_uptime_and_viewership
|
||||
from anonstream.user import add_state, pop_state, try_change_appearance, update_presence, get_users_by_presence, Presence, verify, deverify, BadCaptcha
|
||||
from anonstream.user import add_state, pop_state, try_change_appearance, update_presence, get_users_by_presence, Presence, verify, deverify, BadCaptcha, reading
|
||||
from anonstream.routes.wrappers import with_user_from, render_template_with_etag
|
||||
from anonstream.helpers.chat import get_scrollback
|
||||
from anonstream.helpers.user import get_default_name
|
||||
from anonstream.utils.chat import generate_nonce
|
||||
from anonstream.utils.chat import generate_nonce, should_show_initial_date
|
||||
from anonstream.utils.security import generate_csp
|
||||
from anonstream.utils.user import concatenate_for_notice
|
||||
|
||||
CONFIG = current_app.config
|
||||
MESSAGES = current_app.messages
|
||||
USERS_BY_TOKEN = current_app.users_by_token
|
||||
|
||||
@current_app.route('/stream.html')
|
||||
@with_user_from(request)
|
||||
async def nojs_stream(user):
|
||||
async def nojs_stream(timestamp, user):
|
||||
return await render_template(
|
||||
'nojs_stream.html',
|
||||
csp=generate_csp(),
|
||||
|
@ -29,12 +30,12 @@ async def nojs_stream(user):
|
|||
|
||||
@current_app.route('/info.html')
|
||||
@with_user_from(request)
|
||||
async def nojs_info(user):
|
||||
async def nojs_info(timestamp, user):
|
||||
update_presence(user)
|
||||
uptime, viewership = get_stream_uptime_and_viewership()
|
||||
return await render_template(
|
||||
return await render_template_with_etag(
|
||||
'nojs_info.html',
|
||||
csp=generate_csp(),
|
||||
{'csp': generate_csp()},
|
||||
refresh=CONFIG['NOJS_REFRESH_INFO'],
|
||||
user=user,
|
||||
viewership=viewership,
|
||||
|
@ -45,26 +46,30 @@ async def nojs_info(user):
|
|||
|
||||
@current_app.route('/chat/messages.html')
|
||||
@with_user_from(request)
|
||||
async def nojs_chat_messages(user):
|
||||
async def nojs_chat_messages(timestamp, user):
|
||||
reading(user)
|
||||
messages = get_scrollback(MESSAGES)
|
||||
return await render_template_with_etag(
|
||||
'nojs_chat_messages.html',
|
||||
{'csp': generate_csp()},
|
||||
refresh=CONFIG['NOJS_REFRESH_MESSAGES'],
|
||||
user=user,
|
||||
users_by_token=USERS_BY_TOKEN,
|
||||
messages=get_scrollback(current_app.messages),
|
||||
messages=messages,
|
||||
timeout=CONFIG['NOJS_TIMEOUT_CHAT'],
|
||||
get_default_name=get_default_name,
|
||||
show_initial_date=should_show_initial_date(timestamp, messages),
|
||||
)
|
||||
|
||||
@current_app.route('/chat/messages')
|
||||
@with_user_from(request)
|
||||
async def nojs_chat_messages_redirect(user):
|
||||
return redirect(url_for('nojs_chat_messages', token=user['token'], _anchor='end'))
|
||||
async def nojs_chat_messages_redirect(timestamp, user):
|
||||
url = url_for('nojs_chat_messages', token=user['token'], _anchor='end')
|
||||
return redirect(url, 303)
|
||||
|
||||
@current_app.route('/chat/users.html')
|
||||
@with_user_from(request)
|
||||
async def nojs_chat_users(user):
|
||||
async def nojs_chat_users(timestamp, user):
|
||||
users_by_presence = get_users_by_presence()
|
||||
return await render_template_with_etag(
|
||||
'nojs_chat_users.html',
|
||||
|
@ -79,7 +84,7 @@ async def nojs_chat_users(user):
|
|||
|
||||
@current_app.route('/chat/form.html')
|
||||
@with_user_from(request)
|
||||
async def nojs_chat_form(user):
|
||||
async def nojs_chat_form(timestamp, user):
|
||||
state_id = request.args.get('state', type=int)
|
||||
state = pop_state(user, state_id)
|
||||
prefer_chat_form = request.args.get('landing') != 'appearance'
|
||||
|
@ -99,7 +104,7 @@ async def nojs_chat_form(user):
|
|||
|
||||
@current_app.post('/chat/form')
|
||||
@with_user_from(request)
|
||||
async def nojs_chat_form_redirect(user):
|
||||
async def nojs_chat_form_redirect(timestamp, user):
|
||||
comment = (await request.form).get('comment', '')
|
||||
if comment:
|
||||
state_id = add_state(
|
||||
|
@ -108,11 +113,12 @@ async def nojs_chat_form_redirect(user):
|
|||
)
|
||||
else:
|
||||
state_id = None
|
||||
return redirect(url_for('nojs_chat_form', token=user['token'], state=state_id))
|
||||
url = url_for('nojs_chat_form', token=user['token'], state=state_id)
|
||||
return redirect(url, 303)
|
||||
|
||||
@current_app.post('/chat/message')
|
||||
@with_user_from(request)
|
||||
async def nojs_submit_message(user):
|
||||
async def nojs_submit_message(timestamp, user):
|
||||
form = await request.form
|
||||
|
||||
comment = form.get('comment', '')
|
||||
|
@ -132,12 +138,13 @@ async def nojs_submit_message(user):
|
|||
try:
|
||||
# If the comment is empty but the captcha was just solved,
|
||||
# be lenient: don't raise an exception and don't create a notice
|
||||
message_was_added = add_chat_message(
|
||||
seq = add_chat_message(
|
||||
user,
|
||||
nonce,
|
||||
comment,
|
||||
ignore_empty=verification_happened,
|
||||
)
|
||||
message_was_added = seq is not None
|
||||
except Rejected as e:
|
||||
notice, *_ = e.args
|
||||
state_id = add_state(
|
||||
|
@ -148,18 +155,19 @@ async def nojs_submit_message(user):
|
|||
else:
|
||||
state_id = None
|
||||
if message_was_added:
|
||||
deverify(user)
|
||||
deverify(user, timestamp=timestamp)
|
||||
|
||||
return redirect(url_for(
|
||||
url = url_for(
|
||||
'nojs_chat_form',
|
||||
token=user['token'],
|
||||
landing='chat',
|
||||
state=state_id,
|
||||
))
|
||||
)
|
||||
return redirect(url, 303)
|
||||
|
||||
@current_app.post('/chat/appearance')
|
||||
@with_user_from(request)
|
||||
async def nojs_submit_appearance(user):
|
||||
async def nojs_submit_appearance(timestamp, user):
|
||||
form = await request.form
|
||||
|
||||
# Collect form data
|
||||
|
@ -187,9 +195,10 @@ async def nojs_submit_appearance(user):
|
|||
notice = 'Changed appearance'
|
||||
|
||||
state_id = add_state(user, notice=notice, verbose=len(errors) > 1)
|
||||
return redirect(url_for(
|
||||
url = url_for(
|
||||
'nojs_chat_form',
|
||||
token=user['token'],
|
||||
landing='appearance' if errors else 'chat',
|
||||
state=state_id,
|
||||
))
|
||||
)
|
||||
return redirect(url, 303)
|
||||
|
|
|
@ -1,26 +1,35 @@
|
|||
# SPDX-FileCopyrightText: 2022 n9k <https://git.076.ne.jp/ninya9k>
|
||||
# SPDX-FileCopyrightText: 2022 n9k <https://gitler.moe/ninya9k>
|
||||
# SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import asyncio
|
||||
|
||||
from math import inf
|
||||
|
||||
from quart import current_app, websocket
|
||||
|
||||
from anonstream.user import see
|
||||
from anonstream.user import see, ensure_allowedness, AllowednessException
|
||||
from anonstream.websocket import websocket_outbound, websocket_inbound
|
||||
from anonstream.routes.wrappers import with_user_from
|
||||
|
||||
@current_app.websocket('/live')
|
||||
@with_user_from(websocket)
|
||||
async def live(user):
|
||||
queue = asyncio.Queue(maxsize=0)
|
||||
user['websockets'][queue] = -inf
|
||||
@with_user_from(websocket, fallback_to_token=True, ignore_allowedness=True)
|
||||
async def live(timestamp, user_or_token):
|
||||
match user_or_token:
|
||||
case str() | None:
|
||||
await websocket.send_json({'type': 'kick'})
|
||||
await websocket.close(1001)
|
||||
case dict() as user:
|
||||
try:
|
||||
ensure_allowedness(user, timestamp=timestamp)
|
||||
except AllowednessException:
|
||||
await websocket.send_json({'type': 'kick'})
|
||||
await websocket.close(1001)
|
||||
else:
|
||||
queue = asyncio.Queue()
|
||||
user['websockets'][queue] = timestamp
|
||||
user['last']['reading'] = timestamp
|
||||
|
||||
producer = websocket_outbound(queue, user)
|
||||
consumer = websocket_inbound(queue, user)
|
||||
try:
|
||||
await asyncio.gather(producer, consumer)
|
||||
finally:
|
||||
see(user)
|
||||
user['websockets'].pop(queue)
|
||||
producer = websocket_outbound(queue, user)
|
||||
consumer = websocket_inbound(queue, user)
|
||||
try:
|
||||
await asyncio.gather(producer, consumer)
|
||||
finally:
|
||||
see(user)
|
||||
user['websockets'].pop(queue)
|
||||
|
|
|
@ -1,20 +1,22 @@
|
|||
# SPDX-FileCopyrightText: 2022 n9k <https://git.076.ne.jp/ninya9k>
|
||||
# SPDX-FileCopyrightText: 2022 n9k <https://gitler.moe/ninya9k>
|
||||
# SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import hashlib
|
||||
import hmac
|
||||
import re
|
||||
import string
|
||||
import time
|
||||
from functools import wraps
|
||||
from urllib.parse import quote, unquote
|
||||
|
||||
from quart import current_app, request, abort, make_response, render_template, request
|
||||
from quart import current_app, request, make_response, render_template, redirect, url_for, Markup
|
||||
from werkzeug.exceptions import BadRequest, Unauthorized, Forbidden
|
||||
from werkzeug.security import check_password_hash
|
||||
|
||||
from anonstream.broadcast import broadcast
|
||||
from anonstream.user import see
|
||||
from anonstream.user import ensure_allowedness, Blacklisted, SecretClub
|
||||
from anonstream.helpers.user import generate_user
|
||||
from anonstream.utils.user import generate_token, Presence
|
||||
from anonstream.wrappers import get_timestamp
|
||||
|
||||
CONFIG = current_app.config
|
||||
MESSAGES = current_app.messages
|
||||
|
@ -31,13 +33,19 @@ TOKEN_ALPHABET = (
|
|||
)
|
||||
RE_TOKEN = re.compile(r'[%s]{1,256}' % re.escape(TOKEN_ALPHABET))
|
||||
|
||||
def try_unquote(string):
|
||||
if string is None:
|
||||
return None
|
||||
else:
|
||||
return unquote(string)
|
||||
|
||||
def check_auth(context):
|
||||
auth = context.authorization
|
||||
return (
|
||||
auth is not None
|
||||
and auth.type == "basic"
|
||||
and auth.username == CONFIG["AUTH_USERNAME"]
|
||||
and check_password_hash(CONFIG["AUTH_PWHASH"], auth.password)
|
||||
and auth.type == 'basic'
|
||||
and auth.username == CONFIG['AUTH_USERNAME']
|
||||
and check_password_hash(CONFIG['AUTH_PWHASH'], auth.password)
|
||||
)
|
||||
|
||||
def auth_required(f):
|
||||
|
@ -50,64 +58,134 @@ def auth_required(f):
|
|||
'their terminal.'
|
||||
)
|
||||
if request.authorization is None:
|
||||
body = (
|
||||
f'<!doctype html>\n'
|
||||
f'<p>{hint}</p>\n'
|
||||
)
|
||||
description = hint
|
||||
else:
|
||||
body = (
|
||||
f'<!doctype html>\n'
|
||||
f'<p>Wrong username or password. Refresh the page to try again.</p>\n'
|
||||
f'<p>{hint}</p>\n'
|
||||
description = Markup(
|
||||
f'Wrong username or password. Refresh the page to try again. '
|
||||
f'<br>'
|
||||
f'{hint}'
|
||||
)
|
||||
return body, 401, {'WWW-Authenticate': 'Basic'}
|
||||
|
||||
error = Unauthorized(description)
|
||||
response = await current_app.handle_http_exception(error)
|
||||
response = await make_response(response)
|
||||
response.headers['WWW-Authenticate'] = 'Basic'
|
||||
return response
|
||||
return wrapper
|
||||
|
||||
def with_user_from(context):
|
||||
def generate_and_add_user(
|
||||
timestamp, token=None, broadcaster=False, verified=False, headers=None,
|
||||
):
|
||||
token = token or generate_token()
|
||||
user = generate_user(
|
||||
timestamp=timestamp,
|
||||
token=token,
|
||||
broadcaster=broadcaster,
|
||||
verified=verified,
|
||||
headers=headers,
|
||||
)
|
||||
USERS_BY_TOKEN[token] = user
|
||||
USERS_UPDATE_BUFFER.add(token)
|
||||
return user
|
||||
|
||||
def with_user_from(
|
||||
context,
|
||||
fallback_to_token=False,
|
||||
ignore_allowedness=False,
|
||||
redundant_token_redirect=False,
|
||||
):
|
||||
def with_user_from_context(f):
|
||||
@wraps(f)
|
||||
async def wrapper(*args, **kwargs):
|
||||
timestamp = int(time.time())
|
||||
timestamp = get_timestamp()
|
||||
|
||||
# Check if broadcaster
|
||||
# Get token
|
||||
broadcaster = check_auth(context)
|
||||
token_from_args = context.args.get('token')
|
||||
token_from_cookie = try_unquote(context.cookies.get('token'))
|
||||
token_from_context = token_from_args or token_from_cookie
|
||||
if broadcaster:
|
||||
token = CONFIG['AUTH_TOKEN']
|
||||
elif CONFIG['ACCESS_CAPTCHA']:
|
||||
token = token_from_context
|
||||
else:
|
||||
token = (
|
||||
context.args.get('token')
|
||||
or context.cookies.get('token')
|
||||
or generate_token()
|
||||
)
|
||||
if hmac.compare_digest(token, CONFIG['AUTH_TOKEN']):
|
||||
raise abort(401)
|
||||
token = token_from_context or generate_token()
|
||||
|
||||
# Reject invalid tokens
|
||||
if not RE_TOKEN.fullmatch(token):
|
||||
raise abort(400)
|
||||
if isinstance(token, str) and not RE_TOKEN.fullmatch(token):
|
||||
raise BadRequest(Markup(
|
||||
f'Your token contains disallowed characters or is too '
|
||||
f'long. Tokens must match this regular expression: <br>'
|
||||
f'<code>{RE_TOKEN.pattern}</code>'
|
||||
))
|
||||
|
||||
# Update / create user
|
||||
# Only logged in broadcaster may have the broadcaster's token
|
||||
if (
|
||||
not broadcaster
|
||||
and isinstance(token, str)
|
||||
and hmac.compare_digest(token, CONFIG['AUTH_TOKEN'])
|
||||
):
|
||||
raise Unauthorized(Markup(
|
||||
f"You are using the broadcaster's token but you are "
|
||||
f"not logged in. The broadcaster should "
|
||||
f"<a href=\"{url_for('login')}\" target=\"_top\">"
|
||||
f"click here"
|
||||
f"</a> "
|
||||
f"and log in with the credentials printed in their "
|
||||
f"terminal when they started anonstream."
|
||||
))
|
||||
|
||||
# If token from the client's cookie is same as the token in the URL
|
||||
# query string, the client supports cookies. If we want, we can
|
||||
# redirect the client to this same URL path but with the token
|
||||
# parameter removed, since we'll pick up their token from their
|
||||
# cookie anyway.
|
||||
if (
|
||||
redundant_token_redirect
|
||||
and token_from_context is not None
|
||||
and token_from_args == token_from_cookie
|
||||
):
|
||||
return redirect(context.path, 303)
|
||||
|
||||
# Create response
|
||||
user = USERS_BY_TOKEN.get(token)
|
||||
if user is not None:
|
||||
see(user)
|
||||
if CONFIG['ACCESS_CAPTCHA'] and not broadcaster:
|
||||
if user is not None:
|
||||
user['last']['seen'] = timestamp
|
||||
user['headers'] = tuple(context.headers)
|
||||
if not ignore_allowedness:
|
||||
assert_allowedness(timestamp, user)
|
||||
if user is not None and user['verified'] is not None:
|
||||
response = await f(timestamp, user, *args, **kwargs)
|
||||
elif fallback_to_token:
|
||||
#assert not broadcaster
|
||||
response = await f(timestamp, token, *args, **kwargs)
|
||||
else:
|
||||
raise Forbidden(Markup(
|
||||
f"You have not solved the access captcha. "
|
||||
f"<a href=\"{url_for('home', token=token)}\" target=\"_top\">"
|
||||
f"Click here."
|
||||
f"</a>"
|
||||
))
|
||||
else:
|
||||
user = generate_user(
|
||||
timestamp=timestamp,
|
||||
token=token,
|
||||
broadcaster=broadcaster,
|
||||
presence=Presence.NOTWATCHING,
|
||||
)
|
||||
USERS_BY_TOKEN[token] = user
|
||||
|
||||
# Add to the users update buffer
|
||||
USERS_UPDATE_BUFFER.add(token)
|
||||
if user is not None:
|
||||
user['last']['seen'] = timestamp
|
||||
user['headers'] = tuple(context.headers)
|
||||
else:
|
||||
user = generate_and_add_user(
|
||||
timestamp,
|
||||
token,
|
||||
broadcaster,
|
||||
headers=tuple(context.headers),
|
||||
)
|
||||
if not ignore_allowedness:
|
||||
assert_allowedness(timestamp, user)
|
||||
response = await f(timestamp, user, *args, **kwargs)
|
||||
|
||||
# Set cookie
|
||||
response = await f(user, *args, **kwargs)
|
||||
if context.cookies.get('token') != token:
|
||||
if token_from_cookie != token:
|
||||
response = await make_response(response)
|
||||
response.headers['Set-Cookie'] = f'token={token}; path=/'
|
||||
response.headers['Set-Cookie'] = f'token={quote(token)}; path=/'
|
||||
|
||||
return response
|
||||
|
||||
return wrapper
|
||||
|
@ -127,3 +205,51 @@ async def render_template_with_etag(template, deferred_kwargs, **kwargs):
|
|||
**kwargs,
|
||||
)
|
||||
return rendered_template, {'ETag': etag}
|
||||
|
||||
def clean_cache_headers(f):
|
||||
@wraps(f)
|
||||
async def wrapper(*args, **kwargs):
|
||||
response = await f(*args, **kwargs)
|
||||
|
||||
# Remove Last-Modified
|
||||
try:
|
||||
response.headers.pop('Last-Modified')
|
||||
except KeyError:
|
||||
pass
|
||||
|
||||
# Obfuscate ETag
|
||||
try:
|
||||
original_etag = response.headers['ETag']
|
||||
except KeyError:
|
||||
pass
|
||||
else:
|
||||
parts = CONFIG['SECRET_KEY'] + b'etag\0' + original_etag.encode()
|
||||
tag = hashlib.sha256(parts).hexdigest()
|
||||
response.headers['ETag'] = f'"{tag}"'
|
||||
|
||||
return response
|
||||
|
||||
return wrapper
|
||||
|
||||
def etag_conditional(f):
|
||||
@wraps(f)
|
||||
async def wrapper(*args, **kwargs):
|
||||
response = await f(*args, **kwargs)
|
||||
etag = response.headers.get('ETag')
|
||||
if etag is not None:
|
||||
if match := re.fullmatch(r'"(?P<tag>.+)"', etag):
|
||||
tag = match.group('tag')
|
||||
if tag in request.if_none_match:
|
||||
return '', 304, {'ETag': etag}
|
||||
|
||||
return response
|
||||
|
||||
return wrapper
|
||||
|
||||
def assert_allowedness(timestamp, user):
|
||||
try:
|
||||
ensure_allowedness(user, timestamp=timestamp)
|
||||
except Blacklisted as e:
|
||||
raise Forbidden('You have been blacklisted.')
|
||||
except SecretClub as e:
|
||||
raise Forbidden('You have not been whitelisted.')
|
||||
|
|
|
@ -1,4 +1,4 @@
|
|||
# SPDX-FileCopyrightText: 2022 n9k <https://git.076.ne.jp/ninya9k>
|
||||
# SPDX-FileCopyrightText: 2022 n9k <https://gitler.moe/ninya9k>
|
||||
# SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import asyncio
|
||||
|
@ -91,7 +91,7 @@ async def get_segment_uris(token):
|
|||
except Offline as e:
|
||||
reason, *_ = e.args
|
||||
print(
|
||||
f'[debug @ {time.time():.3f}: {token=}] '
|
||||
f'[debug @ {time.time():.3f}: token={token}] '
|
||||
f'stream went offline before we could find any segments ({reason})'
|
||||
)
|
||||
return
|
||||
|
@ -109,7 +109,7 @@ async def get_segment_uris(token):
|
|||
except Offline as e:
|
||||
reason, *_ = e.args
|
||||
print(
|
||||
f'[debug @ {time.time():.3f}: {token=}] '
|
||||
f'[debug @ {time.time():.3f}: token={token}] '
|
||||
f'stream went offline while looking for the '
|
||||
f'segment following {segment.uri!r} ({reason})'
|
||||
)
|
||||
|
@ -120,7 +120,7 @@ async def get_segment_uris(token):
|
|||
break
|
||||
elif time.monotonic() - t0 >= CONFIG['SEGMENT_SEARCH_TIMEOUT']:
|
||||
print(
|
||||
f'[debug @ {time.time():.3f}: {token=}] '
|
||||
f'[debug @ {time.time():.3f}: token={token}] '
|
||||
f'timed out looking for the segment following '
|
||||
f'{segment.uri!r} '
|
||||
f'(timeout={CONFIG["SEGMENT_SEARCH_TIMEOUT"]}s)'
|
||||
|
@ -138,15 +138,15 @@ def path_for(uri):
|
|||
return path
|
||||
|
||||
async def segments(segment_read_hook=lambda uri: None, token=None):
|
||||
print(f'[debug @ {time.time():.3f}: {token=}] entering segment generator')
|
||||
print(f'[debug @ {time.time():.3f}: token={token}] entering segment generator')
|
||||
async for uri in get_segment_uris(token):
|
||||
#print(f'[debug @ {time.time():.3f}: {token=}] {uri=}')
|
||||
#print(f'[debug @ {time.time():.3f}: token={token}] {uri=}')
|
||||
try:
|
||||
path = path_for(uri)
|
||||
except UnsafePath as e:
|
||||
unsafe_path, *_ = e.args
|
||||
print(
|
||||
f'[debug @ {time.time():.3f}: {token=}] '
|
||||
f'[debug @ {time.time():.3f}: token={token}] '
|
||||
f'segment {uri=} has {unsafe_path=}'
|
||||
)
|
||||
break
|
||||
|
@ -156,7 +156,7 @@ async def segments(segment_read_hook=lambda uri: None, token=None):
|
|||
except StopSendingSegments as e:
|
||||
reason, *_ = e.args
|
||||
print(
|
||||
f'[debug @ {time.time():.3f}: {token=}] '
|
||||
f'[debug @ {time.time():.3f}: token={token}] '
|
||||
f'told to stop sending segments: {reason}'
|
||||
)
|
||||
break
|
||||
|
@ -166,14 +166,14 @@ async def segments(segment_read_hook=lambda uri: None, token=None):
|
|||
yield chunk
|
||||
except FileNotFoundError:
|
||||
print(
|
||||
f'[debug @ {time.time():.3f}: {token=}] '
|
||||
f'[debug @ {time.time():.3f}: token={token}] '
|
||||
f'segment {uri=} at {path=} unexpectedly does not exist'
|
||||
)
|
||||
break
|
||||
except OSError as e:
|
||||
print(
|
||||
f'[debug @ {time.time():.3f}: {token=}] '
|
||||
f'[debug @ {time.time():.3f}: token={token}] '
|
||||
f'segment {uri=} at {path=} cannot be read: {e}'
|
||||
)
|
||||
break
|
||||
print(f'[debug @ {time.time():.3f}: {token=}] exiting segment generator')
|
||||
print(f'[debug @ {time.time():.3f}: token={token}] exiting segment generator')
|
||||
|
|
|
@ -1,5 +1,5 @@
|
|||
/**
|
||||
* SPDX-FileCopyrightText: 2022 n9k [https://git.076.ne.jp/ninya9k]
|
||||
* SPDX-FileCopyrightText: 2022 n9k <https://git.076.ne.jp/ninya9k>
|
||||
* SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
**/
|
||||
|
||||
|
@ -189,6 +189,7 @@ const create_chat_message = (object) => {
|
|||
chat_message.classList.add("chat-message");
|
||||
chat_message.dataset.seq = object.seq;
|
||||
chat_message.dataset.tokenHash = object.token_hash;
|
||||
chat_message.dataset.date = object.date;
|
||||
|
||||
const chat_message_time = document.createElement("time");
|
||||
chat_message_time.classList.add("chat-message__time");
|
||||
|
@ -256,23 +257,65 @@ const create_chat_user_components = (user) => {
|
|||
result.push(...[chat_user_name, chat_user_tripcode_nbsp, chat_user_tripcode]);
|
||||
return result;
|
||||
}
|
||||
const zeropad = (n) => ("0" + n).slice(-2);
|
||||
const datestamp = () => {
|
||||
const date = new Date();
|
||||
return `${date.getUTCFullYear()}-${zeropad(date.getUTCMonth() + 1)}-${zeropad(date.getUTCDate())}`;
|
||||
}
|
||||
const create_and_add_chat_message = (object) => {
|
||||
// date
|
||||
last_chat_message = chat_messages.querySelector(".chat-message:last-of-type");
|
||||
if (last_chat_message === null || last_chat_message.dataset.date !== object.date) {
|
||||
const chat_date = document.createElement("li");
|
||||
chat_date.classList.add("chat-date");
|
||||
chat_date.dataset.date = object.date;
|
||||
|
||||
const chat_date_hr = document.createElement("hr");
|
||||
const chat_date_div = document.createElement("div");
|
||||
|
||||
const chat_date_div_time = document.createElement("time");
|
||||
chat_date_div_time.datetime = object.date;
|
||||
chat_date_div_time.innerText = object.date;
|
||||
|
||||
chat_date_div.insertAdjacentElement("beforeend", chat_date_div_time);
|
||||
chat_date.insertAdjacentElement("beforeend", chat_date_hr);
|
||||
chat_date.insertAdjacentElement("beforeend", chat_date_div);
|
||||
if (last_chat_message === null && object.date === datestamp())
|
||||
chat_date.dataset.hidden = "";
|
||||
chat_messages.insertAdjacentElement("beforeend", chat_date);
|
||||
}
|
||||
|
||||
// message
|
||||
const chat_message = create_chat_message(object);
|
||||
chat_messages.insertAdjacentElement("beforeend", chat_message);
|
||||
while (chat_messages.children.length > max_chat_scrollback) {
|
||||
chat_messages.children[0].remove();
|
||||
const first_chat_message = chat_messages.querySelector(".chat-message");
|
||||
if (first_chat_message !== null) {
|
||||
const first_chat_date = chat_messages.querySelector(".chat-date");
|
||||
if (first_chat_date !== null && first_chat_date.hasAttribute("data-hidden") && (object.date !== first_chat_message.dataset.date || object.date !== datestamp()))
|
||||
first_chat_date.removeAttribute("data-hidden");
|
||||
}
|
||||
const string_seqs = new Set();
|
||||
for (const this_chat_message of chat_messages.querySelectorAll(".chat-message")) {
|
||||
if (chat_messages.querySelectorAll(".chat-message").length - string_seqs.size > max_chat_scrollback)
|
||||
string_seqs.add(this_chat_message.dataset.seq);
|
||||
else
|
||||
break;
|
||||
}
|
||||
delete_chat_messages({string_seqs});
|
||||
}
|
||||
const delete_chat_messages = (seqs) => {
|
||||
string_seqs = new Set(seqs.map(n => n.toString()));
|
||||
to_delete = [];
|
||||
for (const chat_message of chat_messages.children) {
|
||||
if (string_seqs.has(chat_message.dataset.seq))
|
||||
to_delete.push(chat_message);
|
||||
const delete_chat_messages = ({string_seqs, keep=false}) => {
|
||||
const keep_dates = new Set();
|
||||
for (const chat_message of chat_messages.querySelectorAll(".chat-message")) {
|
||||
if (string_seqs.has(chat_message.dataset.seq) === keep)
|
||||
keep_dates.add(chat_message.dataset.date);
|
||||
}
|
||||
for (const chat_message of to_delete) {
|
||||
chat_message.remove();
|
||||
const to_delete = [];
|
||||
for (const child of chat_messages.children) {
|
||||
if (child.classList.contains("chat-date") && !keep_dates.has(child.dataset.date) || child.classList.contains("chat-message") && string_seqs.has(child.dataset.seq) !== keep)
|
||||
to_delete.push(child);
|
||||
}
|
||||
for (const element of to_delete)
|
||||
element.remove();
|
||||
}
|
||||
|
||||
let users = {};
|
||||
|
@ -340,7 +383,7 @@ const get_user_name = ({user=null, token_hash}) => {
|
|||
}
|
||||
const update_user_names = (token_hash=null) => {
|
||||
const token_hashes = token_hash === null ? Object.keys(users) : [token_hash];
|
||||
for (const chat_message of chat_messages.children) {
|
||||
for (const chat_message of chat_messages.querySelectorAll(".chat-message")) {
|
||||
const this_token_hash = chat_message.dataset.tokenHash;
|
||||
if (token_hashes.includes(this_token_hash)) {
|
||||
const user = users[this_token_hash];
|
||||
|
@ -422,7 +465,7 @@ const update_user_tripcodes = (token_hash=null) => {
|
|||
}
|
||||
|
||||
// update inner texts
|
||||
for (const chat_message of chat_messages.children) {
|
||||
for (const chat_message of chat_messages.querySelectorAll(".chat-message")) {
|
||||
const this_token_hash = chat_message.dataset.tokenHash;
|
||||
const tripcode = users[this_token_hash].tripcode;
|
||||
if (token_hashes.includes(this_token_hash)) {
|
||||
|
@ -594,7 +637,7 @@ const show_offline_screen = () => {
|
|||
stream.dataset.offline = "";
|
||||
}
|
||||
|
||||
const on_websocket_message = (event) => {
|
||||
const on_websocket_message = async (event) => {
|
||||
//console.log("websocket message", event);
|
||||
const receipt = JSON.parse(event.data);
|
||||
switch (receipt.type) {
|
||||
|
@ -622,6 +665,11 @@ const on_websocket_message = (event) => {
|
|||
info_button.dataset.visible = "";
|
||||
}
|
||||
|
||||
// form input maxlengths
|
||||
chat_form_comment.maxLength = receipt.maxlength.comment;
|
||||
chat_appearance_form_name.maxLength = receipt.maxlength.name;
|
||||
chat_appearance_form_password.maxLength = receipt.maxlength.password;
|
||||
|
||||
// chat form nonce
|
||||
chat_form_nonce.value = receipt.nonce;
|
||||
|
||||
|
@ -631,18 +679,9 @@ const on_websocket_message = (event) => {
|
|||
// chat form submit button
|
||||
chat_form_submit.disabled = false;
|
||||
|
||||
// remove messages the server isn't acknowledging the existance of
|
||||
const seqs = new Set(receipt.messages.map((message) => {return message.seq;}));
|
||||
const to_delete = [];
|
||||
for (const chat_message of chat_messages.children) {
|
||||
const chat_message_seq = parseInt(chat_message.dataset.seq);
|
||||
if (!seqs.has(chat_message_seq)) {
|
||||
to_delete.push(chat_message);
|
||||
}
|
||||
}
|
||||
for (const chat_message of to_delete) {
|
||||
chat_message.remove();
|
||||
}
|
||||
// remove messages the server isn't acknowledging the existence of
|
||||
const string_seqs = new Set(receipt.messages.map(message => message.seq.toString()));
|
||||
delete_chat_messages({string_seqs, keep: true});
|
||||
|
||||
// settings
|
||||
default_name = receipt.default;
|
||||
|
@ -665,7 +704,7 @@ const on_websocket_message = (event) => {
|
|||
left: 0,
|
||||
top: chat_messages.scrollTopMax,
|
||||
behavior: "instant",
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// appearance form default values
|
||||
|
@ -677,7 +716,8 @@ const on_websocket_message = (event) => {
|
|||
chat_appearance_form_color.setAttribute("value", user.color);
|
||||
|
||||
// insert new messages
|
||||
const last = chat_messages.children.length == 0 ? null : chat_messages.children[chat_messages.children.length - 1];
|
||||
const chat_messages_messages = chat_messages.querySelectorAll(".chat-message");
|
||||
const last = chat_messages_messages.length == 0 ? null : chat_messages_messages[chat_messages_messages.length - 1];
|
||||
const last_seq = last === null ? null : parseInt(last.dataset.seq);
|
||||
for (const message of receipt.messages) {
|
||||
if (message.seq > last_seq) {
|
||||
|
@ -744,7 +784,7 @@ const on_websocket_message = (event) => {
|
|||
|
||||
case "delete":
|
||||
console.log("ws delete", receipt);
|
||||
delete_chat_messages(receipt.seqs);
|
||||
delete_chat_messages({string_seqs: new Set(receipt.seqs.map(n => n.toString()))});
|
||||
break;
|
||||
|
||||
case "set-users":
|
||||
|
@ -831,6 +871,11 @@ const on_websocket_message = (event) => {
|
|||
ws.send(JSON.stringify(payload));
|
||||
break;
|
||||
|
||||
case "kick":
|
||||
console.log("ws kick");
|
||||
window.location.reload();
|
||||
break;
|
||||
|
||||
default:
|
||||
console.log("incomprehensible websocket message", receipt);
|
||||
}
|
||||
|
@ -846,6 +891,7 @@ const connect_websocket = () => {
|
|||
}
|
||||
chat_live_ball.style.borderColor = "gold";
|
||||
chat_live_status.innerHTML = "<span data-verbose='true'>Connecting to chat...</span><span data-verbose='false'>···</span>";
|
||||
ws = null;
|
||||
ws = new WebSocket(`ws://${document.domain}:${location.port}/live?token=${encodeURIComponent(TOKEN)}`);
|
||||
ws.addEventListener("open", (event) => {
|
||||
console.log("websocket open", event);
|
||||
|
@ -895,7 +941,7 @@ info_button.addEventListener("click", (event) => {
|
|||
info_button.removeAttribute("data-visible");
|
||||
});
|
||||
video.addEventListener("error", (event) => {
|
||||
if (video.error !== null && video.error.message === "404: Not Found") {
|
||||
if (video.error !== null && video.networkState === video.NETWORK_NO_SOURCE) {
|
||||
show_offline_screen();
|
||||
}
|
||||
if (stats !== null) {
|
||||
|
@ -963,6 +1009,14 @@ chat_messages_unlock.addEventListener("click", (event) => {
|
|||
chat_messages.scrollTop = chat_messages.scrollTopMax;
|
||||
});
|
||||
|
||||
/* show initial chat date if a day has passed */
|
||||
const show_initial_date = () => {
|
||||
const chat_date = chat_messages.querySelector(".chat-date:first-child");
|
||||
if (chat_date !== null && chat_date.hasAttribute("data-hidden") && chat_date.dataset.date !== datestamp())
|
||||
chat_date.removeAttribute("data-hidden");
|
||||
}
|
||||
setInterval(show_initial_date, 30000);
|
||||
|
||||
/* close websocket after prolonged absence of pings */
|
||||
|
||||
const rotate_websocket = () => {
|
||||
|
|
|
@ -1,6 +1,6 @@
|
|||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!--
|
||||
SPDX-FileCopyrightText: 2016 ulimicon [https://thenounproject.com/unlimicon/]
|
||||
SPDX-FileCopyrightText: 2016 ulimicon <https://thenounproject.com/unlimicon/>
|
||||
SPDX-License-Identifier: CC-BY-3.0
|
||||
-->
|
||||
<svg width="243.55pt" height="243.55pt" version="1.1" viewBox="0 0 243.55 243.55" xmlns="http://www.w3.org/2000/svg">
|
||||
|
|
変更前 幅: | 高さ: | サイズ: 1.1 KiB 変更後 幅: | 高さ: | サイズ: 1.1 KiB |
|
@ -1,5 +1,5 @@
|
|||
/**
|
||||
* SPDX-FileCopyrightText: 2022 n9k [https://git.076.ne.jp/ninya9k]
|
||||
* SPDX-FileCopyrightText: 2022 n9k <https://git.076.ne.jp/ninya9k>
|
||||
* SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
**/
|
||||
|
||||
|
@ -263,6 +263,9 @@ noscript {
|
|||
overflow-wrap: anywhere;
|
||||
line-height: 1.3125;
|
||||
}
|
||||
.emote {
|
||||
vertical-align: middle;
|
||||
}
|
||||
.tripcode {
|
||||
padding: 0 5px;
|
||||
border-radius: 7px;
|
||||
|
@ -270,6 +273,29 @@ noscript {
|
|||
font-size: 9pt;
|
||||
cursor: default;
|
||||
}
|
||||
.chat-date {
|
||||
text-align: center;
|
||||
position: relative;
|
||||
display: grid;
|
||||
align-items: center;
|
||||
margin: 8px 0;
|
||||
color: #b2b2b3;
|
||||
cursor: default;
|
||||
}
|
||||
.chat-date[data-hidden] {
|
||||
display: none;
|
||||
}
|
||||
.chat-date > hr {
|
||||
margin: 0;
|
||||
position: absolute;
|
||||
width: 100%;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
.chat-date > :not(hr) > time {
|
||||
padding: 0 1ch;
|
||||
background-color: #232327;
|
||||
position: relative;
|
||||
}
|
||||
#chat__body__users {
|
||||
background-color: #121214;
|
||||
mask-image: linear-gradient(black calc(100% - 0.625rem), transparent calc(100% - 0.125rem));
|
||||
|
|
|
@ -1,4 +1,4 @@
|
|||
# SPDX-FileCopyrightText: 2022 n9k <https://git.076.ne.jp/ninya9k>
|
||||
# SPDX-FileCopyrightText: 2022 n9k <https://gitler.moe/ninya9k>
|
||||
# SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import itertools
|
||||
|
|
|
@ -1,4 +1,4 @@
|
|||
# SPDX-FileCopyrightText: 2022 n9k <https://git.076.ne.jp/ninya9k>
|
||||
# SPDX-FileCopyrightText: 2022 n9k <https://gitler.moe/ninya9k>
|
||||
# SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import asyncio
|
||||
|
@ -9,7 +9,7 @@ from quart import current_app, websocket
|
|||
|
||||
from anonstream.broadcast import broadcast, broadcast_users_update
|
||||
from anonstream.stream import is_online, get_stream_title, get_stream_uptime_and_viewership
|
||||
from anonstream.user import get_sunsettable_users
|
||||
from anonstream.user import get_absent_users, get_sunsettable_users, deverify, ensure_allowedness, AllowednessException
|
||||
from anonstream.wrappers import with_timestamp
|
||||
|
||||
CONFIG = current_app.config
|
||||
|
@ -19,14 +19,13 @@ USERS = current_app.users
|
|||
CAPTCHAS = current_app.captchas
|
||||
CAPTCHA_SIGNER = current_app.captcha_signer
|
||||
|
||||
async def sleep_and_collect_task(delay):
|
||||
coro = asyncio.sleep(delay)
|
||||
async def cancel_on_shutdown(coro):
|
||||
task = asyncio.create_task(coro)
|
||||
current_app.background_sleep.add(task)
|
||||
current_app.tasks.add(task)
|
||||
try:
|
||||
await task
|
||||
finally:
|
||||
current_app.background_sleep.remove(task)
|
||||
current_app.tasks.remove(task)
|
||||
|
||||
def with_period(period):
|
||||
def periodically(f):
|
||||
|
@ -35,7 +34,7 @@ def with_period(period):
|
|||
for iteration in itertools.count():
|
||||
await f(iteration, *args, **kwargs)
|
||||
try:
|
||||
await sleep_and_collect_task(period)
|
||||
await cancel_on_shutdown(asyncio.sleep(period))
|
||||
except asyncio.CancelledError:
|
||||
break
|
||||
|
||||
|
@ -64,6 +63,19 @@ async def t_sunset_users(timestamp, iteration):
|
|||
if iteration == 0:
|
||||
return
|
||||
|
||||
# Revoke access for absent users (except the broadcaster)
|
||||
absent_users = tuple(filter(
|
||||
lambda user: not user['broadcaster'],
|
||||
get_absent_users(timestamp)
|
||||
))
|
||||
for user in absent_users:
|
||||
user['verified'] = None
|
||||
# Absent users should have no connected websockets,
|
||||
# so in normal operation this should always be a no-op
|
||||
broadcast(users=absent_users, payload={'type': 'kick'})
|
||||
|
||||
# Remove as many absent users as possible
|
||||
|
||||
# Broadcast a users update, in case any users being
|
||||
# removed have been mutated or are new.
|
||||
broadcast_users_update()
|
||||
|
@ -110,6 +122,12 @@ async def t_close_websockets(timestamp, iteration):
|
|||
else:
|
||||
for user in USERS:
|
||||
for queue in user['websockets']:
|
||||
# Check allowedness
|
||||
try:
|
||||
ensure_allowedness(user, timestamp=timestamp)
|
||||
except AllowednessException:
|
||||
queue.put_nowait({'type': 'kick'})
|
||||
# Check expiry
|
||||
last_pong = user['websockets'][queue]
|
||||
last_pong_ago = timestamp - last_pong
|
||||
if last_pong_ago > THRESHOLD:
|
||||
|
|
|
@ -0,0 +1,68 @@
|
|||
{##
|
||||
# SPDX-FileCopyrightText: 2022 n9k <https://git.076.ne.jp/ninya9k>
|
||||
# SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
##}
|
||||
<!doctype html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<meta http-equiv="content-security-policy" content="default-src 'none'; img-src 'self'; style-src 'nonce-{{ csp }}';">
|
||||
<style nonce="{{ csp }}">
|
||||
body {
|
||||
background-color: #232327;
|
||||
color: #ddd;
|
||||
font-family: sans-serif;
|
||||
font-size: 14pt;
|
||||
display: grid;
|
||||
grid-template-rows: calc(50% - 10vh + 2rem) 1fr;
|
||||
height: 100vh;
|
||||
margin: 0;
|
||||
padding: 1rem;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
img {
|
||||
margin: auto auto 1rem;
|
||||
border: 1px solid #222;
|
||||
}
|
||||
form {
|
||||
margin: 0 auto auto;
|
||||
display: grid;
|
||||
grid-template-columns: auto auto;
|
||||
grid-gap: 0.5rem;
|
||||
}
|
||||
input[name="answer"] {
|
||||
background-color: #47474a;
|
||||
border: 1px solid #777;
|
||||
border-radius: 2px;
|
||||
color: #ddd;
|
||||
font-size: 14pt;
|
||||
padding: 5px 6px;
|
||||
width: 10ch;
|
||||
transition: 0.25s;
|
||||
}
|
||||
input[name="answer"]:focus {
|
||||
background-color: black;
|
||||
border-color: #3584e4;
|
||||
}
|
||||
input[type="submit"] {
|
||||
font-size: 14pt;
|
||||
padding-left: 8px;
|
||||
padding-right: 8px;
|
||||
}
|
||||
p {
|
||||
grid-column: 1 / span 2;
|
||||
text-align: center;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<img src="{{ url_for('captcha', digest=digest) }}" width="72" height="30">
|
||||
<form action="{{ url_for('access', token=token) }}" method="post">
|
||||
<input type="hidden" name="digest" value="{{ digest }}">
|
||||
<input name="answer" placeholder="Captcha" required autofocus>
|
||||
<input type="submit" value="Submit">
|
||||
{% if failure is not none %}<p>{{ failure }}</p>{% endif %}
|
||||
</form>
|
||||
</body>
|
||||
</html>
|
|
@ -0,0 +1,72 @@
|
|||
{##
|
||||
# SPDX-FileCopyrightText: 2022 n9k <https://git.076.ne.jp/ninya9k>
|
||||
# SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
##}
|
||||
<!doctype html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>{{ error.code }} {{ error.name }}</title>
|
||||
<style>
|
||||
body {
|
||||
background-color: #232327;
|
||||
color: #ddd;
|
||||
font-family: sans-serif;
|
||||
font-size: 14pt;
|
||||
margin: 3pt 6pt;
|
||||
text-align: center;
|
||||
text-shadow: 2px 0px 1px orangered;
|
||||
}
|
||||
main {
|
||||
margin: auto;
|
||||
max-width: 52rem;
|
||||
}
|
||||
h1 {
|
||||
font-size: 32pt;
|
||||
margin: 0 0 8pt;
|
||||
}
|
||||
p {
|
||||
margin: 0;
|
||||
}
|
||||
a {
|
||||
color: #42a5d7;
|
||||
}
|
||||
code {
|
||||
background-color: #333;
|
||||
padding: 2px;
|
||||
border-radius: 2px;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
@media (min-height: 128px) {
|
||||
body {
|
||||
margin: 18pt 16pt;
|
||||
}
|
||||
h1 {
|
||||
margin: revert;
|
||||
}
|
||||
p {
|
||||
margin: revert;
|
||||
}
|
||||
}
|
||||
@media (min-height: 192px) {
|
||||
body {
|
||||
margin: 24pt 16pt;
|
||||
}
|
||||
}
|
||||
@media (min-width: 400px) and (min-height: 128px) {
|
||||
body {
|
||||
background-color: #18181a;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<main>
|
||||
<h1>{{ error.name }}</h1>
|
||||
{% if error.description != error.__class__.description %}
|
||||
<p>{{ error.description }}</p>
|
||||
{% endif %}
|
||||
</main>
|
||||
</body>
|
||||
</html>
|
|
@ -1,5 +1,5 @@
|
|||
{##
|
||||
# SPDX-FileCopyrightText: 2022 n9k [https://git.076.ne.jp/ninya9k]
|
||||
# SPDX-FileCopyrightText: 2022 n9k <https://git.076.ne.jp/ninya9k>
|
||||
# SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
##}
|
||||
<!doctype html>
|
||||
|
@ -41,7 +41,7 @@
|
|||
<a href="#chat">chat</a>
|
||||
<a href="#both">both</a>
|
||||
</nav>
|
||||
<footer>anonstream 1.2.3 — <a href="https://git.076.ne.jp/ninya9k/anonstream" target="_blank">source</a></footer>
|
||||
<footer>anonstream {{ version }} — <a href="https://gitler.moe/ninya9k/anonstream" target="_blank">source</a></footer>
|
||||
<script src="{{ url_for('static', filename='anonstream.js') }}" type="text/javascript"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
|
|
@ -1,5 +1,5 @@
|
|||
{##
|
||||
# SPDX-FileCopyrightText: 2022 n9k [https://git.076.ne.jp/ninya9k]
|
||||
# SPDX-FileCopyrightText: 2022 n9k <https://git.076.ne.jp/ninya9k>
|
||||
# SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
##}
|
||||
{%
|
||||
|
|
|
@ -1,5 +1,5 @@
|
|||
{##
|
||||
# SPDX-FileCopyrightText: 2022 n9k [https://git.076.ne.jp/ninya9k]
|
||||
# SPDX-FileCopyrightText: 2022 n9k <https://git.076.ne.jp/ninya9k>
|
||||
# SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
##}
|
||||
<!doctype html>
|
||||
|
@ -21,6 +21,7 @@
|
|||
height: 100%;
|
||||
color: #ddd;
|
||||
font-family: sans-serif;
|
||||
background-color: #232327;
|
||||
}
|
||||
a {
|
||||
color: var(--link-color);
|
||||
|
|
|
@ -1,5 +1,5 @@
|
|||
{##
|
||||
# SPDX-FileCopyrightText: 2022 n9k [https://git.076.ne.jp/ninya9k]
|
||||
# SPDX-FileCopyrightText: 2022 n9k <https://git.076.ne.jp/ninya9k>
|
||||
# SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
##}
|
||||
{% from 'macros/user.html' import appearance with context %}
|
||||
|
@ -8,9 +8,9 @@
|
|||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<meta http-equiv="content-security-policy" content="default-src 'none'; style-src 'nonce-{{ csp }}';">
|
||||
<meta http-equiv="refresh" content="{{ refresh }}">
|
||||
<meta http-equiv="refresh" content="{{ refresh + 1 }}; url={{ url_for('nojs_chat_messages_redirect', token=user.token) }}">
|
||||
<meta http-equiv="content-security-policy" content="default-src 'none'; style-src 'nonce-{{ csp }}'; img-src 'self';">
|
||||
<!--<meta http-equiv="refresh" content="{{ refresh }}">-->
|
||||
<meta http-equiv="refresh" content="{{ refresh }}; url={{ url_for('nojs_chat_messages_redirect', token=user.token) }}">
|
||||
<style nonce="{{ csp }}">
|
||||
html {
|
||||
height: 100%;
|
||||
|
@ -24,6 +24,7 @@
|
|||
font-family: sans-serif;
|
||||
font-size: 11pt;
|
||||
transform: rotate(180deg);
|
||||
background-color: #232327;
|
||||
}
|
||||
|
||||
.button {
|
||||
|
@ -133,6 +134,9 @@
|
|||
overflow-wrap: anywhere;
|
||||
line-height: 1.3125;
|
||||
}
|
||||
.emote {
|
||||
vertical-align: middle;
|
||||
}
|
||||
.tripcode {
|
||||
padding: 0 5px;
|
||||
border-radius: 7px;
|
||||
|
@ -140,6 +144,27 @@
|
|||
font-size: 9pt;
|
||||
cursor: default;
|
||||
}
|
||||
.chat-date {
|
||||
transform: rotate(-180deg);
|
||||
text-align: center;
|
||||
position: relative;
|
||||
display: grid;
|
||||
align-items: center;
|
||||
margin: 8px 0;
|
||||
color: #b2b2b3;
|
||||
cursor: default;
|
||||
}
|
||||
.chat-date > hr {
|
||||
margin: 0;
|
||||
position: absolute;
|
||||
width: 100%;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
.chat-date > :not(hr) > time {
|
||||
padding: 0 1ch;
|
||||
background-color: #232327;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
{% for token in messages | map(attribute='token') | list | unique %}
|
||||
{% with this_user = users_by_token[token] %}
|
||||
|
@ -168,7 +193,7 @@
|
|||
<ol id="chat-messages">
|
||||
{% for message in messages | reverse %}
|
||||
{% with this_user = users_by_token[message.token] %}
|
||||
<li class="chat-message" data-seq="{{ message.seq }}" data-token-hash="{{ this_user.token_hash }}">
|
||||
<li class="chat-message" data-seq="{{ message.seq }}" data-token-hash="{{ this_user.token_hash }}" data-date="{{ message.date }}">
|
||||
<time class="chat-message__time" datetime="{{ message.date }}T{{ message.time_seconds }}Z" title="{{ message.date }} {{ message.time_seconds }}">{{ message.time_minutes }}</time>
|
||||
{{- ' ' | safe -}}
|
||||
{{ appearance(this_user, insignia_class='chat-message__insignia', name_class='chat-message__name', tag_class='chat-message__name__tag') }}
|
||||
|
@ -176,6 +201,15 @@
|
|||
<span class="chat-message__markup">{{ message.markup }}</span>
|
||||
</li>
|
||||
{% endwith %}
|
||||
{%
|
||||
if loop.nextitem is defined and loop.nextitem.date != message.date
|
||||
or loop.nextitem is not defined and show_initial_date
|
||||
%}
|
||||
<li class="chat-date" data-date="{{ message.date }}">
|
||||
<hr>
|
||||
<div><time datetime="{{ message.date }}">{{ message.date }}</time></div>
|
||||
</li>
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
</ol>
|
||||
<aside id="timeout-dismiss">
|
||||
|
|
|
@ -1,5 +1,5 @@
|
|||
{##
|
||||
# SPDX-FileCopyrightText: 2022 n9k [https://git.076.ne.jp/ninya9k]
|
||||
# SPDX-FileCopyrightText: 2022 n9k <https://git.076.ne.jp/ninya9k>
|
||||
# SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
##}
|
||||
{% from 'macros/user.html' import appearance with context %}
|
||||
|
@ -18,6 +18,7 @@
|
|||
margin: 0;
|
||||
color: #ddd;
|
||||
font-family: sans-serif;
|
||||
background-color: #121214;
|
||||
}
|
||||
#timeout {
|
||||
height: 0;
|
||||
|
@ -67,6 +68,12 @@
|
|||
line-height: 1.4375;
|
||||
}
|
||||
.user__insignia {
|
||||
color: var(--chat-bg);
|
||||
border: 1px outset #0000007f;
|
||||
text-shadow: 0 0 1px var(--chat-bg-color);
|
||||
background-color: orangered;
|
||||
padding: 0 2px;
|
||||
border-radius: 3px;
|
||||
cursor: help;
|
||||
}
|
||||
.user__name {
|
||||
|
|
|
@ -1,5 +1,5 @@
|
|||
{##
|
||||
# SPDX-FileCopyrightText: 2022 n9k [https://git.076.ne.jp/ninya9k]
|
||||
# SPDX-FileCopyrightText: 2022 n9k <https://git.076.ne.jp/ninya9k>
|
||||
# SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
##}
|
||||
<!doctype html>
|
||||
|
@ -15,6 +15,7 @@
|
|||
margin: 0.75ch 1.25ch;
|
||||
font-family: sans-serif;
|
||||
color: #ddd;
|
||||
background-color: #18181a;
|
||||
}
|
||||
#float {
|
||||
float: right;
|
||||
|
|
|
@ -1,5 +1,5 @@
|
|||
{##
|
||||
# SPDX-FileCopyrightText: 2022 n9k [https://git.076.ne.jp/ninya9k]
|
||||
# SPDX-FileCopyrightText: 2022 n9k <https://git.076.ne.jp/ninya9k>
|
||||
# SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
##}
|
||||
<!doctype html>
|
||||
|
@ -18,6 +18,7 @@
|
|||
overflow: hidden;
|
||||
color: #ddd;
|
||||
font-family: sans-serif;
|
||||
background-color: black;
|
||||
}
|
||||
{% if online %}
|
||||
#video {
|
||||
|
|
|
@ -1,13 +1,15 @@
|
|||
# SPDX-FileCopyrightText: 2022 n9k <https://git.076.ne.jp/ninya9k>
|
||||
# SPDX-FileCopyrightText: 2022 n9k <https://gitler.moe/ninya9k>
|
||||
# SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import operator
|
||||
import time
|
||||
from functools import reduce
|
||||
from math import inf
|
||||
|
||||
from quart import current_app
|
||||
|
||||
from anonstream.wrappers import try_except_log, with_timestamp
|
||||
from anonstream.events import notify_event_sockets
|
||||
from anonstream.wrappers import try_except_log, with_timestamp, get_timestamp
|
||||
from anonstream.helpers.user import get_default_name, get_presence, Presence
|
||||
from anonstream.helpers.captcha import check_captcha_digest, Answer
|
||||
from anonstream.helpers.tripcode import generate_tripcode
|
||||
|
@ -17,6 +19,7 @@ from anonstream.utils.user import get_user_for_websocket, trilean
|
|||
CONFIG = current_app.config
|
||||
MESSAGES = current_app.messages
|
||||
USERS = current_app.users
|
||||
ALLOWEDNESS = current_app.allowedness
|
||||
CAPTCHA_SIGNER = current_app.captcha_signer
|
||||
USERS_UPDATE_BUFFER = current_app.users_update_buffer
|
||||
|
||||
|
@ -41,6 +44,18 @@ class DeletedEyes(EyesException):
|
|||
class ExpiredEyes(EyesException):
|
||||
pass
|
||||
|
||||
class DisallowedEyes(EyesException):
|
||||
pass
|
||||
|
||||
class AllowednessException(Exception):
|
||||
pass
|
||||
|
||||
class Blacklisted(AllowednessException):
|
||||
pass
|
||||
|
||||
class SecretClub(AllowednessException):
|
||||
pass
|
||||
|
||||
def add_state(user, **state):
|
||||
state_id = time.time_ns() // 1_000_000
|
||||
user['state'][state_id] = state
|
||||
|
@ -84,6 +99,21 @@ def try_change_appearance(user, name, color, password, want_tripcode):
|
|||
# Add to the users update buffer
|
||||
USERS_UPDATE_BUFFER.add(user['token'])
|
||||
|
||||
# Notify event sockets that a user's appearance was set
|
||||
# NOTE: Changing appearance is currently NOT ratelimited.
|
||||
# Applications using the event socket API should buffer these
|
||||
# events or do something else to a prevent a potential denial of
|
||||
# service.
|
||||
notify_event_sockets({
|
||||
'type': 'appearance',
|
||||
'event': {
|
||||
'token': user['token'],
|
||||
'name': user['name'],
|
||||
'color': user['color'],
|
||||
'tripcode': user['tripcode'],
|
||||
}
|
||||
})
|
||||
|
||||
return errors
|
||||
|
||||
def change_name(user, name, dry_run=False):
|
||||
|
@ -132,15 +162,23 @@ def change_tripcode(user, password, dry_run=False):
|
|||
def delete_tripcode(user):
|
||||
user['tripcode'] = None
|
||||
|
||||
@with_timestamp()
|
||||
def see(timestamp, user):
|
||||
def see(user, timestamp=None):
|
||||
if timestamp is None:
|
||||
timestamp = get_timestamp()
|
||||
user['last']['seen'] = timestamp
|
||||
|
||||
@with_timestamp()
|
||||
def watched(timestamp, user):
|
||||
def watching(user, timestamp=None):
|
||||
if timestamp is None:
|
||||
timestamp = get_timestamp()
|
||||
user['last']['seen'] = timestamp
|
||||
user['last']['watching'] = timestamp
|
||||
|
||||
def reading(user, timestamp=None):
|
||||
if timestamp is None:
|
||||
timestamp = get_timestamp()
|
||||
user['last']['seen'] = timestamp
|
||||
user['last']['reading'] = timestamp
|
||||
|
||||
@with_timestamp()
|
||||
def get_all_users_for_websocket(timestamp):
|
||||
return {
|
||||
|
@ -165,21 +203,23 @@ def verify(user, digest, answer):
|
|||
|
||||
return verification_happened
|
||||
|
||||
@with_timestamp()
|
||||
def deverify(timestamp, user):
|
||||
if not user['verified']:
|
||||
return
|
||||
|
||||
n_user_messages = 0
|
||||
for message in reversed(MESSAGES):
|
||||
message_sent_ago = timestamp - message['timestamp']
|
||||
if message_sent_ago >= CONFIG['FLOOD_MESSAGE_DURATION']:
|
||||
break
|
||||
elif message['token'] == user['token']:
|
||||
n_user_messages += 1
|
||||
|
||||
if n_user_messages >= CONFIG['FLOOD_MESSAGE_THRESHOLD']:
|
||||
user['verified'] = False
|
||||
def deverify(user, timestamp=None):
|
||||
'''
|
||||
Try to deverify a user. The user is deverified iff they have
|
||||
exceeded the message flood threshold.
|
||||
'''
|
||||
if timestamp is None:
|
||||
timestamp = get_timestamp()
|
||||
if user['verified'] and not user['broadcaster']:
|
||||
n_user_messages = 0
|
||||
for message in reversed(MESSAGES):
|
||||
message_sent_ago = timestamp - message['timestamp']
|
||||
if message_sent_ago >= CONFIG['FLOOD_MESSAGE_DURATION']:
|
||||
break
|
||||
elif message['token'] == user['token']:
|
||||
n_user_messages += 1
|
||||
if n_user_messages >= CONFIG['FLOOD_MESSAGE_THRESHOLD']:
|
||||
user['verified'] = False
|
||||
|
||||
def _update_presence(timestamp, user):
|
||||
old, user['presence'] = user['presence'], get_presence(timestamp, user)
|
||||
|
@ -243,6 +283,9 @@ def get_users_by_presence(timestamp):
|
|||
|
||||
@with_timestamp(precise=True)
|
||||
def create_eyes(timestamp, user, headers):
|
||||
# Unlike in renew_eyes, allowedness is NOT checked here because it is
|
||||
# assumed to have already been checked (by the route handler).
|
||||
|
||||
# Enforce cooldown
|
||||
last_created_ago = timestamp - user['last']['eyes']
|
||||
cooldown_ended_ago = last_created_ago - CONFIG['FLOOD_VIDEO_COOLDOWN']
|
||||
|
@ -255,7 +298,7 @@ def create_eyes(timestamp, user, headers):
|
|||
# Treat eyes as a stack, do not create new eyes if it would
|
||||
# cause the limit to be exceeded
|
||||
if not CONFIG['FLOOD_VIDEO_OVERWRITE']:
|
||||
raise TooManyEyes
|
||||
raise TooManyEyes(len(user['eyes']['current']))
|
||||
# Treat eyes as a queue, expire old eyes upon creating new eyes
|
||||
# if the limit would have been exceeded otherwise
|
||||
elif user['eyes']['current']:
|
||||
|
@ -276,12 +319,11 @@ def create_eyes(timestamp, user, headers):
|
|||
}
|
||||
return eyes_id
|
||||
|
||||
@with_timestamp(precise=True)
|
||||
def renew_eyes(timestamp, user, eyes_id, just_read_new_segment=False):
|
||||
try:
|
||||
eyes = user['eyes']['current'][eyes_id]
|
||||
except KeyError:
|
||||
raise DeletedEyes
|
||||
except KeyError as e:
|
||||
raise DeletedEyes from e
|
||||
|
||||
# Enforce expire_after (if the background task hasn't already)
|
||||
renewed_ago = timestamp - eyes['renewed']
|
||||
|
@ -289,6 +331,41 @@ def renew_eyes(timestamp, user, eyes_id, just_read_new_segment=False):
|
|||
user['eyes']['current'].pop(eyes_id)
|
||||
raise ExpiredEyes
|
||||
|
||||
# Ensure allowedness
|
||||
try:
|
||||
ensure_allowedness(user, timestamp=timestamp)
|
||||
except AllowednessException as e:
|
||||
user['eyes']['current'].pop(eyes_id)
|
||||
raise DisallowedEyes from e
|
||||
|
||||
if just_read_new_segment:
|
||||
eyes['n_segments'] += 1
|
||||
eyes['renewed'] = timestamp
|
||||
|
||||
def ensure_allowedness(user, timestamp=None):
|
||||
if timestamp is None:
|
||||
timestamp = get_timestamp()
|
||||
|
||||
# Check against blacklist
|
||||
for keytuple, values in ALLOWEDNESS['blacklist'].items():
|
||||
try:
|
||||
value = reduce(lambda mapping, key: mapping[key], keytuple, user)
|
||||
except (KeyError, TypeError):
|
||||
value = None
|
||||
if value in values:
|
||||
raise Blacklisted
|
||||
|
||||
# Check against whitelist
|
||||
for keytuple, values in ALLOWEDNESS['whitelist'].items():
|
||||
try:
|
||||
value = reduce(lambda mapping, key: mapping[key], keytuple, user)
|
||||
except (KeyError, TypeError):
|
||||
value = None
|
||||
if value in values:
|
||||
break
|
||||
else:
|
||||
# Apply default
|
||||
if not ALLOWEDNESS['default']:
|
||||
raise SecretClub
|
||||
|
||||
user['last']['allowed'] = timestamp
|
||||
|
|
|
@ -1,4 +1,4 @@
|
|||
# SPDX-FileCopyrightText: 2022 n9k <https://git.076.ne.jp/ninya9k>
|
||||
# SPDX-FileCopyrightText: 2022 n9k <https://gitler.moe/ninya9k>
|
||||
# SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import hashlib
|
||||
|
|
|
@ -1,10 +1,15 @@
|
|||
# SPDX-FileCopyrightText: 2022 n9k <https://git.076.ne.jp/ninya9k>
|
||||
# SPDX-FileCopyrightText: 2022 n9k <https://gitler.moe/ninya9k>
|
||||
# SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import base64
|
||||
import hashlib
|
||||
import math
|
||||
import re
|
||||
import secrets
|
||||
from datetime import datetime
|
||||
from functools import lru_cache
|
||||
|
||||
from quart import escape
|
||||
|
||||
class NonceReuse(Exception):
|
||||
pass
|
||||
|
@ -26,3 +31,15 @@ def get_approx_linespan(text):
|
|||
linespan = sum(map(height, text.splitlines()))
|
||||
linespan = linespan if linespan > 0 else 1
|
||||
return linespan
|
||||
|
||||
def should_show_initial_date(timestamp, messages):
|
||||
try:
|
||||
first_message = next(iter(messages))
|
||||
except StopIteration:
|
||||
return False
|
||||
if any(message['date'] != first_message['date'] for message in messages):
|
||||
return True
|
||||
else:
|
||||
latest_date = max(map(lambda message: message['date'], messages))
|
||||
date = datetime.utcfromtimestamp(timestamp).strftime('%Y-%m-%d')
|
||||
return date != latest_date
|
||||
|
|
|
@ -1,4 +1,4 @@
|
|||
# SPDX-FileCopyrightText: 2022 n9k <https://git.076.ne.jp/ninya9k>
|
||||
# SPDX-FileCopyrightText: 2022 n9k <https://gitler.moe/ninya9k>
|
||||
# SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import re
|
||||
|
|
|
@ -1,4 +1,4 @@
|
|||
# SPDX-FileCopyrightText: 2022 n9k <https://git.076.ne.jp/ninya9k>
|
||||
# SPDX-FileCopyrightText: 2022 n9k <https://gitler.moe/ninya9k>
|
||||
# SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import secrets
|
||||
|
|
|
@ -1,4 +1,4 @@
|
|||
# SPDX-FileCopyrightText: 2022 n9k <https://git.076.ne.jp/ninya9k>
|
||||
# SPDX-FileCopyrightText: 2022 n9k <https://gitler.moe/ninya9k>
|
||||
# SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import base64
|
||||
|
@ -50,3 +50,27 @@ def get_user_for_websocket(user):
|
|||
**{key: user[key] for key in USER_WEBSOCKET_ATTRS},
|
||||
'watching': trilean(user['presence']),
|
||||
}
|
||||
|
||||
def identifying_string(user, ansi=True):
|
||||
tag = user['tag']
|
||||
token_hash = f'{user["token_hash"][:4]}..'
|
||||
token = user['token']
|
||||
if ansi:
|
||||
tag = f'\033[36m{tag}\033[0m'
|
||||
token_hash = f'\033[32m{token_hash}\033[0m'
|
||||
token = f'\033[35m{token}\033[0m'
|
||||
return '/'.join((tag, token_hash, token))
|
||||
|
||||
def generate_blank_allowedness():
|
||||
return {
|
||||
'blacklist': {
|
||||
('token',): set(),
|
||||
('token_hash',): set(),
|
||||
},
|
||||
'whitelist': {
|
||||
('token',): set(),
|
||||
('token_hash',): set(),
|
||||
('tripcode', 'digest'): set(),
|
||||
},
|
||||
'default': True,
|
||||
}
|
||||
|
|
|
@ -1,4 +1,4 @@
|
|||
# SPDX-FileCopyrightText: 2022 n9k <https://git.076.ne.jp/ninya9k>
|
||||
# SPDX-FileCopyrightText: 2022 n9k <https://gitler.moe/ninya9k>
|
||||
# SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
from enum import Enum
|
||||
|
|
|
@ -1,4 +1,4 @@
|
|||
# SPDX-FileCopyrightText: 2022 n9k <https://git.076.ne.jp/ninya9k>
|
||||
# SPDX-FileCopyrightText: 2022 n9k <https://gitler.moe/ninya9k>
|
||||
# SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import asyncio
|
||||
|
@ -9,14 +9,18 @@ from quart import current_app, websocket
|
|||
from anonstream.stream import get_stream_title, get_stream_uptime_and_viewership
|
||||
from anonstream.captcha import get_random_captcha_digest_for
|
||||
from anonstream.chat import get_all_messages_for_websocket, add_chat_message, Rejected
|
||||
from anonstream.user import get_all_users_for_websocket, see, verify, deverify, BadCaptcha, try_change_appearance
|
||||
from anonstream.wrappers import with_timestamp
|
||||
from anonstream.user import get_all_users_for_websocket, see, reading, verify, deverify, BadCaptcha, try_change_appearance, ensure_allowedness, AllowednessException
|
||||
from anonstream.wrappers import with_timestamp, get_timestamp
|
||||
from anonstream.utils.chat import generate_nonce
|
||||
from anonstream.utils.user import identifying_string
|
||||
from anonstream.utils.websocket import parse_websocket_data, Malformed, WS
|
||||
|
||||
CONFIG = current_app.config
|
||||
|
||||
async def websocket_outbound(queue, user):
|
||||
# This function does NOT check alllowedness at first, only later.
|
||||
# Allowedness is assumed to be checked beforehand (by the route handler).
|
||||
# These first two websocket messages are always sent.
|
||||
await websocket.send_json({'type': 'ping'})
|
||||
await websocket.send_json({
|
||||
'type': 'init',
|
||||
|
@ -32,59 +36,92 @@ async def websocket_outbound(queue, user):
|
|||
'scrollback': CONFIG['MAX_CHAT_SCROLLBACK'],
|
||||
'digest': get_random_captcha_digest_for(user),
|
||||
'pingpong': CONFIG['TASK_BROADCAST_PING'],
|
||||
'maxlength': {
|
||||
'comment': CONFIG['CHAT_COMMENT_MAX_LENGTH'],
|
||||
'name': CONFIG['CHAT_NAME_MAX_LENGTH'],
|
||||
'password': CONFIG['CHAT_TRIPCODE_PASSWORD_MAX_LENGTH'],
|
||||
},
|
||||
})
|
||||
while True:
|
||||
payload = await queue.get()
|
||||
if payload['type'] == 'close':
|
||||
if payload['type'] == 'kick':
|
||||
await websocket.send_json(payload)
|
||||
await websocket.close(1001)
|
||||
break
|
||||
elif payload['type'] == 'close':
|
||||
await websocket.close(1011)
|
||||
break
|
||||
else:
|
||||
await websocket.send_json(payload)
|
||||
try:
|
||||
ensure_allowedness(user)
|
||||
except AllowednessException:
|
||||
await websocket.send_json({'type': 'kick'})
|
||||
await websocket.close(1001)
|
||||
break
|
||||
else:
|
||||
if user['verified'] is None:
|
||||
await websocket.send_json({'type': 'kick'})
|
||||
await websocket.close(1001)
|
||||
else:
|
||||
await websocket.send_json(payload)
|
||||
|
||||
async def websocket_inbound(queue, user):
|
||||
while True:
|
||||
# Read from websocket
|
||||
try:
|
||||
receipt = await websocket.receive_json()
|
||||
except json.JSONDecodeError:
|
||||
receipt = None
|
||||
finally:
|
||||
see(user)
|
||||
try:
|
||||
receipt_type, parsed = parse_websocket_data(receipt)
|
||||
except Malformed as e:
|
||||
error , *_ = e.args
|
||||
payload = {
|
||||
'type': 'error',
|
||||
'because': error,
|
||||
}
|
||||
else:
|
||||
match receipt_type:
|
||||
case WS.MESSAGE:
|
||||
handle = handle_inbound_message
|
||||
case WS.APPEARANCE:
|
||||
handle = handle_inbound_appearance
|
||||
case WS.CAPTCHA:
|
||||
handle = handle_inbound_captcha
|
||||
case WS.PONG:
|
||||
handle = handle_inbound_pong
|
||||
payload = handle(queue, user, *parsed)
|
||||
timestamp = get_timestamp()
|
||||
see(user, timestamp=timestamp)
|
||||
|
||||
# Prepare response
|
||||
try:
|
||||
ensure_allowedness(user)
|
||||
except AllowednessException:
|
||||
payload = {'type': 'kick'}
|
||||
else:
|
||||
if user['verified'] is None:
|
||||
payload = {'type': 'kick'}
|
||||
else:
|
||||
try:
|
||||
receipt_type, parsed = parse_websocket_data(receipt)
|
||||
except Malformed as e:
|
||||
error , *_ = e.args
|
||||
payload = {
|
||||
'type': 'error',
|
||||
'because': error,
|
||||
}
|
||||
else:
|
||||
match receipt_type:
|
||||
case WS.MESSAGE:
|
||||
handle = handle_inbound_message
|
||||
case WS.APPEARANCE:
|
||||
handle = handle_inbound_appearance
|
||||
case WS.CAPTCHA:
|
||||
handle = handle_inbound_captcha
|
||||
case WS.PONG:
|
||||
handle = handle_inbound_pong
|
||||
payload = handle(timestamp, queue, user, *parsed)
|
||||
|
||||
# Write to websocket
|
||||
if payload is not None:
|
||||
queue.put_nowait(payload)
|
||||
|
||||
@with_timestamp()
|
||||
def handle_inbound_pong(timestamp, queue, user):
|
||||
print(f'[pong] {user["token"]}')
|
||||
print(f'[pong] {identifying_string(user)}')
|
||||
user['last']['reading'] = timestamp
|
||||
user['websockets'][queue] = timestamp
|
||||
return None
|
||||
|
||||
def handle_inbound_captcha(queue, user):
|
||||
def handle_inbound_captcha(timestamp, queue, user):
|
||||
return {
|
||||
'type': 'captcha',
|
||||
'digest': get_random_captcha_digest_for(user),
|
||||
}
|
||||
|
||||
def handle_inbound_appearance(queue, user, name, color, password, want_tripcode):
|
||||
def handle_inbound_appearance(timestamp, queue, user, name, color, password, want_tripcode):
|
||||
errors = try_change_appearance(user, name, color, password, want_tripcode)
|
||||
if errors:
|
||||
return {
|
||||
|
@ -100,7 +137,7 @@ def handle_inbound_appearance(queue, user, name, color, password, want_tripcode)
|
|||
#'tripcode': user['tripcode'],
|
||||
}
|
||||
|
||||
def handle_inbound_message(queue, user, nonce, comment, digest, answer):
|
||||
def handle_inbound_message(timestamp, queue, user, nonce, comment, digest, answer):
|
||||
try:
|
||||
verification_happened = verify(user, digest, answer)
|
||||
except BadCaptcha as e:
|
||||
|
@ -108,19 +145,20 @@ def handle_inbound_message(queue, user, nonce, comment, digest, answer):
|
|||
message_was_added = False
|
||||
else:
|
||||
try:
|
||||
message_was_added = add_chat_message(
|
||||
seq = add_chat_message(
|
||||
user,
|
||||
nonce,
|
||||
comment,
|
||||
ignore_empty=verification_happened,
|
||||
)
|
||||
message_was_added = seq is not None
|
||||
except Rejected as e:
|
||||
notice, *_ = e.args
|
||||
message_was_added = False
|
||||
else:
|
||||
notice = None
|
||||
if message_was_added:
|
||||
deverify(user)
|
||||
deverify(user, timestamp=timestamp)
|
||||
return {
|
||||
'type': 'ack',
|
||||
'nonce': nonce,
|
||||
|
|
|
@ -1,4 +1,4 @@
|
|||
# SPDX-FileCopyrightText: 2022 n9k <https://git.076.ne.jp/ninya9k>
|
||||
# SPDX-FileCopyrightText: 2022 n9k <https://gitler.moe/ninya9k>
|
||||
# SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import time
|
||||
|
@ -16,13 +16,18 @@ def with_function_call(fn, *fn_args, **fn_kwargs):
|
|||
def with_constant(x):
|
||||
return with_function_call(lambda: x)
|
||||
|
||||
def with_timestamp(monotonic=False, precise=False):
|
||||
def get_timestamp(monotonic=False, precise=False):
|
||||
n = 1_000_000_000
|
||||
if monotonic:
|
||||
fn = precise and time.monotonic or (lambda: time.monotonic_ns() // n)
|
||||
timestamp = precise and time.monotonic() or time.monotonic_ns() // n
|
||||
else:
|
||||
fn = precise and time.time or (lambda: time.time_ns() // n)
|
||||
return with_function_call(fn)
|
||||
timestamp = precise and time.time() or time.time_ns() // n
|
||||
return timestamp
|
||||
|
||||
def with_timestamp(monotonic=False, precise=False):
|
||||
def get_timestamp_specific():
|
||||
return get_timestamp(monotonic=monotonic, precise=precise)
|
||||
return with_function_call(get_timestamp_specific)
|
||||
|
||||
def try_except_log(errors, exception_class):
|
||||
def try_except_log_specific(f):
|
||||
|
|
2
asgi.py
2
asgi.py
|
@ -1,4 +1,4 @@
|
|||
# SPDX-FileCopyrightText: 2022 n9k <https://git.076.ne.jp/ninya9k>
|
||||
# SPDX-FileCopyrightText: 2022 n9k <https://gitler.moe/ninya9k>
|
||||
# SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
if __name__ == '__main__':
|
||||
|
|
14
config.toml
14
config.toml
|
@ -24,6 +24,9 @@ stream_initial_buffer = 3
|
|||
file = "title.txt"
|
||||
file_cache_lifetime = 0.5
|
||||
|
||||
[access]
|
||||
captcha = true
|
||||
|
||||
[captcha]
|
||||
lifetime = 1800
|
||||
fonts = []
|
||||
|
@ -35,6 +38,7 @@ foreground_color = "#dddddd"
|
|||
[memory]
|
||||
states = 32
|
||||
captchas = 256
|
||||
failures = 256
|
||||
chat_messages = 8192
|
||||
chat_scrollback = 256
|
||||
|
||||
|
@ -53,12 +57,13 @@ anonymous = "Anonymous"
|
|||
|
||||
[chat]
|
||||
max_comment_length = 512
|
||||
max_comment_lines = 12
|
||||
max_comment_lines = 20
|
||||
max_name_length = 24
|
||||
min_name_contrast = 3.0
|
||||
background_color = "#232327"
|
||||
max_tripcode_password_length = 1024
|
||||
legacy_tripcode_algorithm = false
|
||||
legacy_tripcode_algorithm = true
|
||||
force_captcha_every = 40
|
||||
|
||||
[flood.messages]
|
||||
duration = 20.0
|
||||
|
@ -71,7 +76,7 @@ threshold = 20
|
|||
[flood.video]
|
||||
max_eyes = 3
|
||||
cooldown = 12.0
|
||||
expire_after = 5.0
|
||||
expire_after = 10.0
|
||||
overwrite = true
|
||||
|
||||
[presence]
|
||||
|
@ -84,3 +89,6 @@ refresh_messages = 4.0
|
|||
refresh_info = 6.0
|
||||
refresh_users = 6.0
|
||||
timeout_chat = 30.0
|
||||
|
||||
[emote]
|
||||
schema = "emotes.json"
|
||||
|
|
|
@ -0,0 +1,32 @@
|
|||
## Emotes
|
||||
|
||||
Emotes are small images that are inserted into chat messages in place of
|
||||
given strings. To add emotes, add entries to `emotes.json` that look
|
||||
like below, then restart the server.
|
||||
|
||||
```json
|
||||
{
|
||||
"name": ":joy:",
|
||||
"file": "joy.png",
|
||||
"width": 20,
|
||||
"height": 24
|
||||
}
|
||||
```
|
||||
```json
|
||||
{
|
||||
"name": "JoySpin",
|
||||
"file": "emote/joyspin.gif",
|
||||
"width": null,
|
||||
"height": 24
|
||||
}
|
||||
```
|
||||
|
||||
* `name` is the string that will be replaced in chat messages.
|
||||
|
||||
* `file` is the location of the emote image relative to the static
|
||||
directory `anonstream/static`. The file must actually be in the
|
||||
static directory or a subdirectory of it.
|
||||
|
||||
* `width` and `height` are the dimensions the inserted <img> element
|
||||
will have. Each can be either a non-negative integer or `null`
|
||||
(automatic width/height).
|
|
@ -1,26 +1,26 @@
|
|||
## Hacking
|
||||
|
||||
By default anonstream has two APIs it exposes through two UNIX sockets:
|
||||
the control socket `control.sock` and the event socket `event.sock`. If
|
||||
the platform you are using does not support UNIX sockets, they can be
|
||||
disabled in the config.
|
||||
By default anonstream has two private APIs it exposes through two UNIX
|
||||
sockets: the control socket `control.sock` and the event socket
|
||||
`event.sock`. If the platform you are on does not support UNIX sockets,
|
||||
they can be disabled in the config.
|
||||
|
||||
### Control socket
|
||||
|
||||
The control socket allows reading and modifying internal state, e.g.
|
||||
setting the title or changing a user's name. Currently the control
|
||||
socket has checks to see if what you're doing is sane, but they're not
|
||||
socket has checks to see if what you're doing is sane, but they're non-
|
||||
comprehensive; you could craft commands that lead to undefined
|
||||
behaviour. If you have `socat`, you can use the control socket
|
||||
interactively like this:
|
||||
```sh
|
||||
rlwrap socat STDIN UNIX-CONNECT:control.sock
|
||||
```
|
||||
`rlwrap` only adds line editing and is optional. If you don't have it
|
||||
you can still get (inferior) line editing by doing:
|
||||
```sh
|
||||
socat READLINE UNIX-CONNECT:control.sock
|
||||
```
|
||||
If you have it, you can use `rlwrap` to get line editing that's a bit
|
||||
nicer:
|
||||
```sh
|
||||
rlwrap socat STDIN UNIX-CONNECT:control.sock
|
||||
```
|
||||
Once connected, type "help" and press enter to get a list of commands.
|
||||
|
||||
### Event socket
|
||||
|
@ -28,35 +28,25 @@ Once connected, type "help" and press enter to get a list of commands.
|
|||
The event socket is a read-only socket that sends out internal events as
|
||||
they happen. Currently the only supported event is a chat message being
|
||||
added. The intended use is to hook into other applications that depend
|
||||
on chat, e.g. text-to-speech or Twitch Plays Pokémon.
|
||||
on chat, e.g. text-to-speech or Twitch Plays Pokémon.
|
||||
|
||||
View events like this:
|
||||
```sh
|
||||
socat UNIX-CONNECT:event.sock STDOUT
|
||||
socat -u UNIX-CONNECT:event.sock STDOUT
|
||||
```
|
||||
|
||||
Sidenote, this will still read from stdin, and if you send anything on
|
||||
stdin the event socket will close itself. If you want to ignore stdin,
|
||||
I couldn't figure out how to get `socat` to do it so you can do it like
|
||||
this:
|
||||
```sh
|
||||
cat > /dev/null | socat UNIX-CONNECT:event.sock STDOUT
|
||||
```
|
||||
If you do this `cat` will not exit when the connection is closed so you
|
||||
will probably have to interrupt it with `^C`.
|
||||
|
||||
#### Examples
|
||||
|
||||
If you have `jq` you can view prettified events like this:
|
||||
```sh
|
||||
socat UNIX-CONNECT:event.sock STDOUT | jq
|
||||
socat -u UNIX-CONNECT:event.sock STDOUT | jq
|
||||
```
|
||||
(On older versions of `jq` you have to say `jq .` when reading from
|
||||
stdin.)
|
||||
|
||||
Use this to get each new chat message on a new line:
|
||||
```sh
|
||||
socat UNIX-CONNECT:event.sock STDOUT | jq 'select(.type == "message") | .event.nomarkup'
|
||||
socat -u UNIX-CONNECT:event.sock STDOUT | jq 'select(.type == "message") | .event.nomarkup'
|
||||
```
|
||||
|
||||
##### Text-to-speech
|
||||
|
@ -65,7 +55,7 @@ This command will take each new chat message with the prefix "!say ",
|
|||
strip the prefix, and synthesize the rest of the message as speech using
|
||||
`espeak`:
|
||||
```sh
|
||||
socat UNIX-CONNECT:event.sock STDOUT \
|
||||
socat -u UNIX-CONNECT:event.sock STDOUT \
|
||||
| jq --unbuffered 'select(.type == "message") | .event.nomarkup' \
|
||||
| grep -E --line-buffered '^"!say ' \
|
||||
| sed -Eu 's/^"!say /"/' \
|
|
@ -0,0 +1,94 @@
|
|||
### OBS Studio
|
||||
|
||||
anonstream considers the stream online when new video segments are
|
||||
actively being created. It knows which segments are new by examining
|
||||
the segment playlist: an HLS playlist located by default at
|
||||
`stream/stream.m3u8`. Software like FFmpeg and OBS can write to this
|
||||
file for you. This guide is for OBS Studio. (An example that uses
|
||||
FFmpeg instead is [at the bottom](#ffmpeg-example).)
|
||||
|
||||
Install OBS Studio. If the autoconfiguration wizard prompts you to
|
||||
choose a third-party service, ignore it since we're not going to be
|
||||
using a third-party service.
|
||||
|
||||
Click `Settings` and set these:
|
||||
|
||||
* Advanced
|
||||
* Recording
|
||||
* Filename Formatting: `stream`
|
||||
* Overwrite if file exists: yes
|
||||
* Video
|
||||
* Output (Scaled) Resolution: `960x540` or lower, or whatever you want
|
||||
* Common FPS Values: any integer framerate (e.g. 30 or 60)
|
||||
* Output
|
||||
* Output Mode: `Advanced`
|
||||
* Recording:
|
||||
```
|
||||
+----------------------------+-------------------------------------+
|
||||
| Field | Value |
|
||||
+============================+=====================================+
|
||||
| Type | `Custom Output (FFmpeg)` |
|
||||
+----------------------------+-------------------------------------+
|
||||
| FFmpeg Output Type | `Output to File` |
|
||||
+----------------------------+-------------------------------------+
|
||||
| File path or URL | same as the `segments/directory` |
|
||||
| | option in config.toml, but make it |
|
||||
| | an absolute path |
|
||||
+----------------------------+-------------------------------------+
|
||||
| Container Format | `hls` |
|
||||
+----------------------------+-------------------------------------+
|
||||
| Muxer Settings (if any) | `hls_init_time=0 hls_time=2 ` |
|
||||
| | `hls_list_size=120 ` |
|
||||
| | `hls_flags=delete_segments ` |
|
||||
| | `hls_segment_type=fmp4` |
|
||||
+----------------------------+-------------------------------------+
|
||||
| Video bitrate | `420 Kbps` or lower, or whatever |
|
||||
| | you want |
|
||||
+----------------------------+-------------------------------------+
|
||||
| Keyframe interval (frames) | `framerate` * `hls_time`, e.g. for |
|
||||
| | 60fps and an `hls_time` of 2 |
|
||||
| | seconds, set this to 120 |
|
||||
+----------------------------+-------------------------------------+
|
||||
| Video Encoder | libx264, or an H.264 hardware |
|
||||
| | encoder (e.g. `h264_nvenc` for |
|
||||
| | Nvidia, [see here][hwaccel]) |
|
||||
+----------------------------+-------------------------------------+
|
||||
| Audio Bitrate | `96 Kbps`, or whatever you want |
|
||||
+----------------------------+-------------------------------------+
|
||||
| Audio Encoder | `aac` |
|
||||
+----------------------------+-------------------------------------+
|
||||
```
|
||||
|
||||
> *If this table looks garbled, read this file as plaintext or [click
|
||||
> here][plaintext] and scroll to the bottom.*
|
||||
|
||||
To start streaming click `Start Recording`.
|
||||
|
||||
When OBS is recording, segments older than four minutes will be
|
||||
regularly deleted. When OBS stops recording, the last four minutes
|
||||
worth of segments will remain the segments directory. (You can change
|
||||
how many segments are kept by modifying the `hls_list_size` option in
|
||||
the muxer settings.) When OBS is not recording, you can delete the
|
||||
files in the segments directory without consequence. Old segments will
|
||||
never be sent over the network even if they are not deleted.
|
||||
|
||||
### FFmpeg example
|
||||
|
||||
This FFmpeg command is basically equivalent to the OBS settings above.
|
||||
The input (`-i ...`) can be anything, e.g. to screen record see
|
||||
<https://trac.ffmpeg.org/wiki/Capture/Desktop>.
|
||||
|
||||
```sh
|
||||
ffmpeg \
|
||||
-re -i somevideo.mp4 \
|
||||
-c:v h264 -b:v 300k -vf scale=-2:360 -g 50 \
|
||||
-c:a aac -b:a 80k \
|
||||
-f hls \
|
||||
-hls_init_time 0 -hls_time 2 \
|
||||
-hls_list_size 120 -hls_flags delete_segments \
|
||||
-hls_segment_type fmp4 \
|
||||
stream/stream.m3u8
|
||||
```
|
||||
|
||||
[hwaccel]: https://trac.ffmpeg.org/wiki/HWAccelIntro
|
||||
[plaintext]: https://gitler.moe/ninya9k/anonstream/raw/branch/master/doc/guide/OBS.md
|
|
@ -0,0 +1,133 @@
|
|||
### Onionsite setup
|
||||
|
||||
You probably want to put your livestream on the Internet somehow. A
|
||||
simple way of doing that is to create an onion address. Follow the
|
||||
setup in [the readme][readme] if you haven't already. You
|
||||
should be to access your site locally at http://127.0.0.1:5051.
|
||||
|
||||
Install tor. On Linux you can probably install a package called `tor` and
|
||||
be done, otherwise [compile it][tor]. On Windows download this binary:
|
||||
<https://www.torproject.org/download/tor/>.
|
||||
|
||||
Find your [torrc][torrc]. On Linux it is probably at `/etc/tor/torrc`.
|
||||
On Windows it might be somewhere in `%appdata%\tor` or something.
|
||||
|
||||
#### Background
|
||||
|
||||
In Tor, a hidden service is a regular TCP service that you talk to via a
|
||||
6-hop circuit created within Tor network. You initiate the creation of
|
||||
this circuit by providing tor with the service's hostname (a long
|
||||
base32-encoded string ending in ".onion"). This hostname is derived
|
||||
from cryptographic keys generated by the hidden service operator.
|
||||
|
||||
A TCP service is a computer program you interact with over the Internet
|
||||
using TCP, which is a low-level networking protocol sitting above IP
|
||||
that creates a reliable connection between two computers. TCP is
|
||||
ubiquitous on the Internet and a lot of applications are built on top
|
||||
of it, e.g. IRC, SSH, RTMP, Minecraft, and HTTP (which we're using).
|
||||
|
||||
#### Configuration
|
||||
|
||||
We are now going to create a hidden service. We need to give tor a
|
||||
directory to store the keys it generates, the location of our existing
|
||||
TCP service, and a virtual TCP port to listen on. There are two
|
||||
directives we have to add to our torrc: `HiddenServiceDir` and
|
||||
`HiddenServicePort`. (There is a commented-out section for hidden
|
||||
services in the default torrc, you probably want to make changes there.)
|
||||
|
||||
##### `HiddenServiceDir`
|
||||
|
||||
`HiddenServiceDir` sets the directory for the hidden service's keys and
|
||||
other data. You could choose any directory, but it should be owned by
|
||||
the user the tor daemon runs as, and its permissions should be
|
||||
`0700/drwx------` (`rwx` for user, `---` for group and everyone else).
|
||||
|
||||
If you configure this in a way tor doesn't like, tor will kill itself
|
||||
and complain in one of these two ways:
|
||||
```
|
||||
Jun 11 23:21:17.000 [warn] Directory /home/n9k/projects/anonstream/hidden_service cannot be read: Permission denied
|
||||
```
|
||||
```
|
||||
Jun 12 02:37:51.036 [warn] Permissions on directory /var/lib/tor/anonstream are too permissive.
|
||||
```
|
||||
|
||||
The simplest option is to go by the examples provided in the torrc. On
|
||||
Linux that would probably be a directory inside `/var/lib/tor`, e.g.
|
||||
```
|
||||
HiddenServiceDir /var/lib/tor/anonstream
|
||||
```
|
||||
tor will create this directory itself with the uid, gid, and permissions
|
||||
that it likes, which for me are these:
|
||||
```
|
||||
Access: (0700/drwx------) Uid: ( 42/ tor) Gid: ( 42/ tor)
|
||||
```
|
||||
|
||||
###### `HiddenServiceDir` troubleshooting
|
||||
|
||||
If you created the directory yourself and gave it the wrong permissions
|
||||
or uid or gid, delete the directory and let tor create it itself, or do
|
||||
this as root:
|
||||
```sh
|
||||
# chown -R tor:tor /var/lib/tor/anonstream
|
||||
# chmod 0700 /var/lib/tor/anonstream
|
||||
# chmod 0600 /var/lib/tor/anonstream/*
|
||||
# chmod 0700 /var/lib/tor/anonstream/*/
|
||||
```
|
||||
|
||||
If the user and group `tor` do not exist, your tor daemon runs as some
|
||||
other user. There may be a `User` directive in your torrc or in a file
|
||||
included by your torrc, for example on Debian it's `User debian-tor`.
|
||||
This means that a tor process running as root will immediately drop
|
||||
privileges by switching to the user `debian-tor`. The user's primary
|
||||
group should have the same name, check like this as root:
|
||||
`# id debian-tor`.
|
||||
|
||||
On Linux, if tor is already running you can see what user and group it
|
||||
is running as like this:
|
||||
```
|
||||
$ ps -C tor -o uid,gid,cmd
|
||||
UID GID CMD
|
||||
42 42 tor --quiet --runasdaemon 0
|
||||
$ cat /etc/passwd | grep :42: | cut -f 1 -d : # 42 is the UID here
|
||||
tor
|
||||
$ cat /etc/group | grep :42: | cut -f 1 -d : # 42 is the GID here
|
||||
tor
|
||||
```
|
||||
|
||||
Alternatively you could specify a directory inside the cloned
|
||||
repository, e.g. `/home/delphine/Documents/anonstream/hidden_service`
|
||||
or something like that. This will only work if the tor daemon has `rwx`
|
||||
permissions on the directory and at least `r-x` permissions on all the
|
||||
directories above it. This is probably not the case for you since your
|
||||
home folder might have `0700/drwx------` permissions. If you
|
||||
installed tor as a package, the daemon probably runs as its own user
|
||||
(e.g. `debian-tor` on Debian, `tor` on Arch/Gentoo). I would advise not
|
||||
going this route and instead just using `/var/lib/tor/anonstream`.
|
||||
|
||||
##### `HiddenServicePort`
|
||||
|
||||
Include this line verbatim directly below the `HiddenServiceDir` line:
|
||||
```
|
||||
HiddenServicePort 80 127.0.0.1:5051
|
||||
```
|
||||
|
||||
tor will listen for connections to our onion address at virtual port 80
|
||||
(the conventional HTTP port), and it will forward traffic to the TCP
|
||||
service at 127.0.0.1:5051 (our webserver).
|
||||
|
||||
##### Finish
|
||||
|
||||
Example configuration:
|
||||
```
|
||||
HiddenServiceDir /var/lib/tor/anonstream
|
||||
HiddenServicePort 80 127.0.0.1:5051
|
||||
```
|
||||
|
||||
Reload tor to have it reread the torrc: `# pkill -HUP tor`. With
|
||||
systemd you can alternatively do `# systemctl reload tor`. If
|
||||
everything went well, the directory will have been created and your
|
||||
onion address will be in `$HIDDEN_SERVICE_DIR/hostname`.
|
||||
|
||||
[readme]: https://gitler.moe/ninya9k/anonstream/src/branch/master/README.md#setup
|
||||
[tor]: https://gitlab.torproject.org/tpo/core/tor
|
||||
[torrc]: https://support.torproject.org/#tbb-editing-torrc
|
|
@ -0,0 +1 @@
|
|||
[]
|
読み込み中…
新しいイシューから参照