コミットを比較

...

36 コミット

作成者 SHA1 メッセージ 日付
n9k beafe88324 v1.6.9 2023-02-23 22:36:20 +00:00
n9k a6c31179b6 Repo changed domains: git.076.ne.jp -> gitler.moe 2023-02-23 22:36:20 +00:00
n9k ad7cc1c5b1 Fix offline screen for Firefox resistFingerprinting 2023-02-23 22:30:02 +00:00
n9k 022bebed73 v1.6.8 2022-08-12 06:10:56 +00:00
n9k a97f3254bd Merge branch 'dev' 2022-08-12 06:10:49 +00:00
n9k ea2a194c93 Control socket: generate tripcodes 2022-08-12 06:10:31 +00:00
n9k 12338747de Websocket: send form field maxlengths 2022-08-12 05:25:57 +00:00
n9k 8426a3490a Event socket: add event for setting appearance 2022-08-12 05:20:20 +00:00
n9k 3fca390a30 Control socket: show all chat messages 2022-08-12 05:20:20 +00:00
n9k 26a86fac7a Control socket: show app.config options 2022-08-12 05:20:20 +00:00
n9k 071edaef3a Control socket: minor help text etc. fixups 2022-08-12 05:20:13 +00:00
n9k 6e9ba1a5db Minor CSS: padding on access captcha button 2022-08-12 05:20:13 +00:00
n9k 78753f7e0c Minor CSS: make access captcha input like comment box 2022-08-12 04:10:15 +00:00
n9k d05c5fec31 Minor CSS: adjust access captcha height
It was too high on mobile screens.
2022-08-12 04:10:11 +00:00
n9k 2a67bee82c If client supports cookies, clear token URL parameter
Only on the homepage.
2022-08-11 06:19:35 +00:00
n9k cbd494e3bf Set cookie when access captcha solved 2022-08-11 06:17:58 +00:00
n9k b9c29a6fdd v1.6.7 2022-08-07 11:38:16 +00:00
n9k 4d192392c4 Merge branch 'dev' 2022-08-07 11:38:13 +00:00
n9k 2599528ae3 JS: typo 2022-08-07 11:37:40 +00:00
n9k 72d5a0526c Fix JS chat dates
Accidentally forgot to change these places where we use
`chat_messages.children`, which now refers to dates as well as messages.
2022-08-07 11:37:39 +00:00
n9k d7b4717cf5 Chat: show dates when the day changes (js) 2022-08-10 00:01:27 +00:00
n9k 68d6efff4e Chat: show dates in chat when time is ambiguous (nojs) 2022-08-10 00:08:28 +00:00
n9k 55a3d7fe1f v1.6.6 2022-08-02 04:57:30 +00:00
n9k f3de542e3b Merge branch 'dev' 2022-08-02 04:57:19 +00:00
n9k f3d613de3b Control socket: add chat messages 2022-08-02 04:57:07 +00:00
n9k 6ddab6c969 Minor: control socket: move around ArgsUser stuff 2022-08-02 04:56:25 +00:00
n9k 4a22ca8a92 Minor: `add_chat_message` returns seq (or None)
It now returns the seq of the just-added message if one was added, and
None otherwise.  The previous behaviour was to return True and False
respectively.
2022-08-02 04:52:11 +00:00
n9k e0f3ec0e07 Control socket: add new users 2022-08-02 04:52:07 +00:00
n9k ddf8811ddc v1.6.5 2022-08-01 02:55:20 +00:00
n9k 777448d83a Merge branch 'dev' 2022-08-01 02:55:06 +00:00
n9k ed8ba4aacc Control socket: show emotes 2022-08-01 02:53:55 +00:00
n9k 51ff285067 Control socket: reload emotes 2022-08-01 02:53:55 +00:00
n9k b9c2d89a5a Control socket: rename method 'exit' -> 'quit' 2022-08-01 02:53:45 +00:00
n9k 0750cd180a Emotes: validate when loading 2022-08-01 00:37:20 +00:00
n9k c2094f1d89 Emotes: reorganize 2022-08-01 00:30:28 +00:00
n9k 41ba8fd026 Readme: typo 2022-07-31 22:53:53 +00:00
56個のファイルの変更634行の追加222行の削除

ファイルの表示

@ -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>
@ -18,7 +18,7 @@ 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
```
@ -96,7 +96,7 @@ using the `ANONSTREAM_CONFIG` environment variable.
anonstream has APIs for accessing internal state and hooking into
internal events. They can be used by humans and other programs. See
[HACKING.md][/doc/HACKING.md].
[HACKING.md](/doc/HACKING.md).
## Copying
@ -136,8 +136,8 @@ anonstream is AGPL 3.0 or later, see
* werkzeug <https://github.com/pallets/werkzeug>
([BSD 3-Clause][werkzeug])
[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
[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

ファイルの表示

@ -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
@ -8,12 +8,12 @@ from collections import OrderedDict
from quart_compress import Compress
from anonstream.config import update_flask_from_toml
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.chat import precompute_emote_regex
from anonstream.utils.user import generate_blank_allowedness
__version__ = '1.6.4'
__version__ = '1.6.9'
def create_app(toml_config):
app = Quart('anonstream', static_folder=None)
@ -49,10 +49,10 @@ def create_app(toml_config):
app.allowedness = generate_blank_allowedness()
# Read emote schema
with open(app.config['EMOTE_SCHEMA']) as fp:
emotes = json.load(fp)
precompute_emote_regex(emotes)
app.emotes = emotes
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()

ファイルの表示

@ -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 argparse

ファイルの表示

@ -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

ファイルの表示

@ -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

ファイルの表示

@ -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
@ -8,7 +8,8 @@ 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, insert_emotes
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
@ -136,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

ファイルの表示

@ -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,22 +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,11 +1,16 @@
# 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
@ -146,3 +151,26 @@ class ArgsJsonStringArray(ArgsJson):
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),
})

ファイルの表示

@ -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

ファイルの表示

@ -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 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)))),
})

43
anonstream/control/spec/methods/config.py ノーマルファイル
ファイルの表示

@ -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)),
})

61
anonstream/control/spec/methods/emote.py ノーマルファイル
ファイルの表示

@ -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,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,7 +9,7 @@ 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'
@ -18,12 +18,18 @@ async def cmd_help():
' user set USER ATTR VALUE.......set an attribute of a user\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

ファイルの表示

@ -1,22 +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
from anonstream.control.spec.common import Str, End
from anonstream.control.exceptions import ControlSocketExit
async def cmd_exit():
async def cmd_quit():
raise ControlSocketExit
async def cmd_exit_help():
normal = ['exit', 'help']
async def cmd_quit_help():
normal = ['quit', 'help']
response = (
'Usage: exit\n'
'Usage: quit\n'
'Commands:\n'
' exit......close the connection\n'
' quit......close the connection\n'
)
return normal, response
SPEC = Str({
None: End(cmd_exit),
'help': End(cmd_exit_help),
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'

42
anonstream/control/spec/methods/tripcode.py ノーマルファイル
ファイルの表示

@ -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 = (
@ -49,6 +26,7 @@ async def cmd_user_help():
' 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'
@ -56,6 +34,7 @@ async def cmd_user_help():
' 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
@ -79,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'
@ -95,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),
]
@ -107,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
@ -122,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
@ -144,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

55
anonstream/emote.py ノーマルファイル
ファイルの表示

@ -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

ファイルの表示

@ -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,14 +1,11 @@
# 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
from functools import lru_cache
import markupsafe
from quart import current_app, escape, url_for, Markup
from quart import current_app
CONFIG = current_app.config
EMOTES = current_app.emotes
def generate_nonce_hash(nonce):
parts = CONFIG['SECRET_KEY'] + b'nonce-hash\0' + nonce.encode()
@ -19,24 +16,3 @@ def get_scrollback(messages):
if len(messages) < n:
return messages
return list(messages)[-n:]
@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)

27
anonstream/helpers/emote.py ノーマルファイル
ファイルの表示

@ -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

ファイルの表示

@ -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,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 anonstream.routes.error

ファイルの表示

@ -1,8 +1,9 @@
# 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, send_from_directory
from werkzeug.exceptions import Forbidden, NotFound, TooManyRequests
@ -22,7 +23,12 @@ CAPTCHA_SIGNER = current_app.captcha_signer
STATIC_DIRECTORY = current_app.root_path / 'static'
@current_app.route('/')
@with_user_from(request, fallback_to_token=True, ignore_allowedness=True)
@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:
@ -128,12 +134,13 @@ async def access(timestamp, user_or_token):
failure_id = None
user = generate_and_add_user(timestamp, token, verified=True)
if failure_id is not None:
url = url_for('home', token=token, failure=failure_id)
raise abort(redirect(url, 303))
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:
pass
url = url_for('home', token=user['token'])
return redirect(url, 303)
response = redirect(url_for('home', token=user['token']), 303)
return response
@current_app.route('/static/<path:filename>')
@with_user_from(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
from quart import current_app, request, render_template, redirect, url_for, escape, Markup
@ -10,11 +10,12 @@ from anonstream.user import add_state, pop_state, try_change_appearance, update_
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')
@ -47,15 +48,17 @@ async def nojs_info(timestamp, user):
@with_user_from(request)
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')
@ -135,12 +138,13 @@ async def nojs_submit_message(timestamp, 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(

ファイルの表示

@ -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
import hashlib
@ -8,7 +8,7 @@ import string
from functools import wraps
from urllib.parse import quote, unquote
from quart import current_app, request, make_response, render_template, request, url_for, Markup
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
@ -87,7 +87,12 @@ def generate_and_add_user(
USERS_UPDATE_BUFFER.add(token)
return user
def with_user_from(context, fallback_to_token=False, ignore_allowedness=False):
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):
@ -129,6 +134,18 @@ def with_user_from(context, fallback_to_token=False, ignore_allowedness=False):
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 CONFIG['ACCESS_CAPTCHA'] and not broadcaster:

ファイルの表示

@ -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

ファイルの表示

@ -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)) {
@ -622,6 +665,11 @@ const on_websocket_message = async (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;
@ -632,17 +680,8 @@ const on_websocket_message = async (event) => {
chat_form_submit.disabled = false;
// remove messages the server isn't acknowledging the existence 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();
}
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 = async (event) => {
left: 0,
top: chat_messages.scrollTopMax,
behavior: "instant",
});
});
}
// appearance form default values
@ -677,7 +716,8 @@ const on_websocket_message = async (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 = async (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":
@ -901,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) {
@ -969,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 = () => {

ファイルの表示

@ -273,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

ファイルの表示

@ -15,7 +15,7 @@
font-family: sans-serif;
font-size: 14pt;
display: grid;
grid-template-rows: calc(50% - 4rem) 1fr;
grid-template-rows: calc(50% - 10vh + 2rem) 1fr;
height: 100vh;
margin: 0;
padding: 1rem;
@ -37,20 +37,23 @@
border-radius: 2px;
color: #ddd;
font-size: 14pt;
padding: 4px 5px;
padding: 5px 6px;
width: 10ch;
}
input[name="answer"]:hover {
background-color: #37373a;
transition: 0.25s;
}
input[type="submit"] {
}
input[name="answer"]:focus {
background-color: black;
border-color: #3584e4;
}
input[type="submit"] {
font-size: 14pt;
}
p {
padding-left: 8px;
padding-right: 8px;
}
p {
grid-column: 1 / span 2;
text-align: center;
}
}
</style>
</head>
<body>

ファイルの表示

@ -41,7 +41,7 @@
<a href="#chat">chat</a>
<a href="#both">both</a>
</nav>
<footer>anonstream {{ version }} &mdash; <a href="https://git.076.ne.jp/ninya9k/anonstream" target="_blank">source</a></footer>
<footer>anonstream {{ version }} &mdash; <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>

ファイルの表示

@ -144,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] %}
@ -172,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>
{{- '&nbsp;' | safe -}}
{{ appearance(this_user, insignia_class='chat-message__insignia', name_class='chat-message__name', tag_class='chat-message__name__tag') }}
@ -180,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,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 operator
@ -8,6 +8,7 @@ from math import inf
from quart import current_app
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
@ -98,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):

ファイルの表示

@ -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,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
@ -6,6 +6,7 @@ import hashlib
import math
import re
import secrets
from datetime import datetime
from functools import lru_cache
from quart import escape
@ -31,22 +32,14 @@ def get_approx_linespan(text):
linespan = linespan if linespan > 0 else 1
return linespan
def precompute_emote_regex(schema):
for emote in schema:
assert emote['name'], 'emote names cannot be empty'
assert not re.search(r'\s', emote['name']), \
f'whitespace is not allowed in emote names: {emote["name"]!r}'
for length in (emote['width'], emote['height']):
assert length is None or isinstance(length, int) and length >= 0, \
f'emote dimensions must be null or non-negative integers: {emote["name"]!r}'
# 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)
))
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

ファイルの表示

@ -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
@ -36,6 +36,11 @@ 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()
@ -140,12 +145,13 @@ def handle_inbound_message(timestamp, queue, user, nonce, comment, digest, answe
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

ファイルの表示

@ -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

ファイルの表示

@ -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__':

ファイルの表示

@ -91,4 +91,4 @@ stream/stream.m3u8
```
[hwaccel]: https://trac.ffmpeg.org/wiki/HWAccelIntro
[plaintext]: https://git.076.ne.jp/ninya9k/anonstream/raw/branch/master/doc/guide/OBS.md
[plaintext]: https://gitler.moe/ninya9k/anonstream/raw/branch/master/doc/guide/OBS.md

ファイルの表示

@ -128,6 +128,6 @@ 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://git.076.ne.jp/ninya9k/anonstream/src/branch/master/README.md#setup
[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