Compare commits

..

No commits in common. "main" and "maintenance/clean-up-messages" have entirely different histories.

35 changed files with 1483 additions and 1801 deletions

View File

@ -39,8 +39,7 @@ dependencies = [
"Pillow", "Pillow",
"pillow-heif", "pillow-heif",
"IP2Location", "IP2Location",
"bleach", "bleach"
"sentry-sdk"
] ]
[tool.setuptools.packages.find] [tool.setuptools.packages.find]

View File

@ -1,7 +1,3 @@
import logging
logging.basicConfig(level=logging.INFO)
import pathlib import pathlib
import shutil import shutil
import sqlite3 import sqlite3
@ -13,8 +9,6 @@ from snek.shell import Shell
from snek.app import Application from snek.app import Application
@click.group() @click.group()
def cli(): def cli():
pass pass
@ -128,12 +122,6 @@ def shell(db_path):
Shell(db_path).run() Shell(db_path).run()
def main(): def main():
try:
import sentry_sdk
sentry_sdk.init("https://ab6147c2f3354c819768c7e89455557b@gt.molodetz.nl/1")
except ImportError:
print("Could not import sentry_sdk")
cli() cli()

View File

@ -13,9 +13,6 @@ from snek.view.threads import ThreadsView
logging.basicConfig(level=logging.DEBUG) logging.basicConfig(level=logging.DEBUG)
from concurrent.futures import ThreadPoolExecutor from concurrent.futures import ThreadPoolExecutor
from ipaddress import ip_address from ipaddress import ip_address
import time
import uuid
import IP2Location import IP2Location
from aiohttp import web from aiohttp import web
@ -43,7 +40,6 @@ from snek.system.template import (
PythonExtension, PythonExtension,
sanitize_html, sanitize_html,
) )
from snek.view.new import NewView
from snek.view.about import AboutHTMLView, AboutMDView from snek.view.about import AboutHTMLView, AboutMDView
from snek.view.avatar import AvatarView from snek.view.avatar import AvatarView
from snek.view.channel import ChannelAttachmentView,ChannelAttachmentUploadView, ChannelView from snek.view.channel import ChannelAttachmentView,ChannelAttachmentUploadView, ChannelView
@ -132,6 +128,8 @@ class Application(BaseApplication):
def __init__(self, *args, **kwargs): def __init__(self, *args, **kwargs):
middlewares = [ middlewares = [
cors_middleware, cors_middleware,
web.normalize_path_middleware(merge_slashes=True),
ip2location_middleware,
csp_middleware, csp_middleware,
] ]
self.template_path = pathlib.Path(__file__).parent.joinpath("templates") self.template_path = pathlib.Path(__file__).parent.joinpath("templates")
@ -175,7 +173,6 @@ class Application(BaseApplication):
self.on_startup.append(self.start_ssh_server) self.on_startup.append(self.start_ssh_server)
self.on_startup.append(self.prepare_database) self.on_startup.append(self.prepare_database)
@property @property
def uptime_seconds(self): def uptime_seconds(self):
return (datetime.now() - self.time_start).total_seconds() return (datetime.now() - self.time_start).total_seconds()
@ -264,8 +261,6 @@ class Application(BaseApplication):
name="static", name="static",
show_index=True, show_index=True,
) )
self.router.add_view("/new.html", NewView)
self.router.add_view("/profiler.html", profiler_handler) self.router.add_view("/profiler.html", profiler_handler)
self.router.add_view("/container/sock/{channel_uid}.json", ContainerView) self.router.add_view("/container/sock/{channel_uid}.json", ContainerView)
self.router.add_view("/about.html", AboutHTMLView) self.router.add_view("/about.html", AboutHTMLView)
@ -284,9 +279,9 @@ class Application(BaseApplication):
self.router.add_view("/login.json", LoginView) self.router.add_view("/login.json", LoginView)
self.router.add_view("/register.html", RegisterView) self.router.add_view("/register.html", RegisterView)
self.router.add_view("/register.json", RegisterView) self.router.add_view("/register.json", RegisterView)
# self.router.add_view("/drive/{rel_path:.*}", DriveView) self.router.add_view("/drive/{rel_path:.*}", DriveView)
## self.router.add_view("/drive.bin", UploadView) self.router.add_view("/drive.bin", UploadView)
# self.router.add_view("/drive.bin/{uid}.{ext}", UploadView) self.router.add_view("/drive.bin/{uid}.{ext}", UploadView)
self.router.add_view("/search-user.html", SearchUserView) self.router.add_view("/search-user.html", SearchUserView)
self.router.add_view("/search-user.json", SearchUserView) self.router.add_view("/search-user.json", SearchUserView)
self.router.add_view("/avatar/{uid}.svg", AvatarView) self.router.add_view("/avatar/{uid}.svg", AvatarView)
@ -294,25 +289,25 @@ class Application(BaseApplication):
self.router.add_get("/http-photo", self.handle_http_photo) self.router.add_get("/http-photo", self.handle_http_photo)
self.router.add_get("/rpc.ws", RPCView) self.router.add_get("/rpc.ws", RPCView)
self.router.add_get("/c/{channel:.*}", ChannelView) self.router.add_get("/c/{channel:.*}", ChannelView)
#self.router.add_view( self.router.add_view(
# "/channel/{channel_uid}/attachment.bin", ChannelAttachmentView "/channel/{channel_uid}/attachment.bin", ChannelAttachmentView
#) )
#self.router.add_view( self.router.add_view(
# "/channel/{channel_uid}/drive.json", ChannelDriveApiView "/channel/{channel_uid}/drive.json", ChannelDriveApiView
#) )
self.router.add_view( self.router.add_view(
"/channel/{channel_uid}/attachment.sock", ChannelAttachmentUploadView "/channel/{channel_uid}/attachment.sock", ChannelAttachmentUploadView
) )
self.router.add_view( self.router.add_view(
"/channel/attachment/{relative_url:.*}", ChannelAttachmentView "/channel/attachment/{relative_url:.*}", ChannelAttachmentView
)# )
self.router.add_view("/channel/{channel}.html", WebView) self.router.add_view("/channel/{channel}.html", WebView)
self.router.add_view("/threads.html", ThreadsView) self.router.add_view("/threads.html", ThreadsView)
self.router.add_view("/terminal.ws", TerminalSocketView) self.router.add_view("/terminal.ws", TerminalSocketView)
self.router.add_view("/terminal.html", TerminalView) self.router.add_view("/terminal.html", TerminalView)
#self.router.add_view("/drive.json", DriveApiView) self.router.add_view("/drive.json", DriveApiView)
#self.router.add_view("/drive.html", DriveView) self.router.add_view("/drive.html", DriveView)
#self.router.add_view("/drive/{drive}.json", DriveView) self.router.add_view("/drive/{drive}.json", DriveView)
self.router.add_view("/stats.json", StatsView) self.router.add_view("/stats.json", StatsView)
self.router.add_view("/user/{user}.html", UserView) self.router.add_view("/user/{user}.html", UserView)
self.router.add_view("/repository/{username}/{repository}", RepositoryView) self.router.add_view("/repository/{username}/{repository}", RepositoryView)
@ -367,8 +362,6 @@ class Application(BaseApplication):
# @time_cache_async(60) # @time_cache_async(60)
async def render_template(self, template, request, context=None): async def render_template(self, template, request, context=None):
start_time = time.perf_counter()
channels = [] channels = []
if not context: if not context:
context = {} context = {}
@ -429,12 +422,10 @@ class Application(BaseApplication):
self.jinja2_env.loader = self.original_loader self.jinja2_env.loader = self.original_loader
end_time = time.perf_counter()
print(f"render_template took {end_time - start_time:.4f} seconds")
# rendered.text = whitelist_attributes(rendered.text) # rendered.text = whitelist_attributes(rendered.text)
# rendered.headers['Content-Lenght'] = len(rendered.text) # rendered.headers['Content-Lenght'] = len(rendered.text)
return rendered return rendered
async def static_handler(self, request): async def static_handler(self, request):
file_name = request.match_info.get("filename", "") file_name = request.match_info.get("filename", "")

View File

@ -13,17 +13,13 @@ class ChannelModel(BaseModel):
last_message_on = ModelField(name="last_message_on", required=False, kind=str) last_message_on = ModelField(name="last_message_on", required=False, kind=str)
history_start = ModelField(name="history_start", required=False, kind=str) history_start = ModelField(name="history_start", required=False, kind=str)
@property
def is_dm(self):
return 'dm' in self['tag'].lower()
async def get_last_message(self) -> ChannelMessageModel: async def get_last_message(self) -> ChannelMessageModel:
history_start_filter = "" history_start_filter = ""
if self["history_start"]: if self["history_start"]:
history_start_filter = f" AND created_at > '{self['history_start']}' " history_start_filter = f" AND created_at > '{self['history_start']}' "
try: try:
async for model in self.app.services.channel_message.query( async for model in self.app.services.channel_message.query(
"SELECT uid FROM channel_message WHERE channel_uid=:channel_uid" + history_start_filter + " ORDER BY id DESC LIMIT 1", "SELECT uid FROM channel_message WHERE channel_uid=:channel_uid" + history_start_filter + " ORDER BY created_at DESC LIMIT 1",
{"channel_uid": self["uid"]}, {"channel_uid": self["uid"]},
): ):

View File

@ -49,7 +49,6 @@ CREATE TABLE IF NOT EXISTS channel_member (
is_read_only BOOLEAN, is_read_only BOOLEAN,
label TEXT, label TEXT,
new_count BIGINT, new_count BIGINT,
last_read_at TEXT,
uid TEXT, uid TEXT,
updated_at TEXT, updated_at TEXT,
user_uid TEXT, user_uid TEXT,

View File

@ -1,5 +1,4 @@
from snek.system.service import BaseService from snek.system.service import BaseService
from snek.system.model import now
class ChannelMemberService(BaseService): class ChannelMemberService(BaseService):
@ -9,7 +8,6 @@ class ChannelMemberService(BaseService):
async def mark_as_read(self, channel_uid, user_uid): async def mark_as_read(self, channel_uid, user_uid):
channel_member = await self.get(channel_uid=channel_uid, user_uid=user_uid) channel_member = await self.get(channel_uid=channel_uid, user_uid=user_uid)
channel_member["new_count"] = 0 channel_member["new_count"] = 0
channel_member["last_read_at"] = now()
return await self.save(channel_member) return await self.save(channel_member)
async def get_user_uids(self, channel_uid): async def get_user_uids(self, channel_uid):

View File

@ -1,20 +1,5 @@
from snek.system.service import BaseService from snek.system.service import BaseService
from snek.system.template import sanitize_html from snek.system.template import whitelist_attributes
import time
import asyncio
from concurrent.futures import ProcessPoolExecutor
import json
from jinja2 import Environment, FileSystemLoader
global jinja2_env
import pathlib
template_path = pathlib.Path(__file__).parent.parent.joinpath("templates")
def render(context):
template =jinja2_env.get_template("message.html")
return sanitize_html(template.render(**context))
class ChannelMessageService(BaseService): class ChannelMessageService(BaseService):
@ -23,30 +8,10 @@ class ChannelMessageService(BaseService):
def __init__(self, *args, **kwargs): def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs) super().__init__(*args, **kwargs)
self._configured_indexes = False self._configured_indexes = False
self._executor_pools = {}
global jinja2_env
jinja2_env = self.app.jinja2_env
self._max_workers = 1
def get_or_create_executor(self, uid):
if not uid in self._executor_pools:
self._executor_pools[uid] = ProcessPoolExecutor(max_workers=self._max_workers)
print("Executors available", len(self._executor_pools))
return self._executor_pools[uid]
def delete_executor(self, uid):
if uid in self._executor_pools:
self._executor_pools[uid].shutdown()
del self._executor_pools[uid]
async def maintenance(self): async def maintenance(self):
args = {} args = {}
async for message in self.find():
return
for message in self.mapper.db["channel_message"].find():
print(message)
try:
message = await self.get(uid=message["uid"])
updated_at = message["updated_at"] updated_at = message["updated_at"]
message["is_final"] = True message["is_final"] = True
html = message["html"] html = message["html"]
@ -62,11 +27,6 @@ class ChannelMessageService(BaseService):
if html != message["html"]: if html != message["html"]:
print("Reredefined message", message["uid"]) print("Reredefined message", message["uid"])
except Exception as ex:
time.sleep(0.1)
print(ex, flush=True)
while True: while True:
changed = 0 changed = 0
async for message in self.find(is_final=False): async for message in self.find(is_final=False):
@ -101,14 +61,10 @@ class ChannelMessageService(BaseService):
"color": user["color"], "color": user["color"],
} }
) )
loop = asyncio.get_event_loop()
try: try:
template = self.app.jinja2_env.get_template("message.html")
context = json.loads(json.dumps(context, default=str)) model["html"] = template.render(**context)
model["html"] = whitelist_attributes(model["html"])
model["html"] = await loop.run_in_executor(self.get_or_create_executor(model["uid"]), render,context)
#model['html'] = await loop.run_in_executor(self.get_or_create_executor(user["uid"]), sanitize_html,model['html'])
except Exception as ex: except Exception as ex:
print(ex, flush=True) print(ex, flush=True)
@ -127,8 +83,6 @@ class ChannelMessageService(BaseService):
["deleted_at"], unique=False ["deleted_at"], unique=False
) )
self._configured_indexes = True self._configured_indexes = True
if model['is_final']:
self.delete_executor(model['uid'])
return model return model
raise Exception(f"Failed to create channel message: {model.errors}.") raise Exception(f"Failed to create channel message: {model.errors}.")
@ -137,9 +91,9 @@ class ChannelMessageService(BaseService):
if not user: if not user:
return {} return {}
#if not message["html"].startswith("<chat-message"): if not message["html"].startswith("<chat-message"):
#message = await self.get(uid=message["uid"]) await (await self.get(uid=message["uid"])).save()
#await self.save(message) message["html"] = (await self.get(uid=message["uid"])).html
return { return {
"uid": message["uid"], "uid": message["uid"],
@ -165,15 +119,10 @@ class ChannelMessageService(BaseService):
"color": user["color"], "color": user["color"],
} }
) )
context = json.loads(json.dumps(context, default=str)) template = self.app.jinja2_env.get_template("message.html")
loop = asyncio.get_event_loop() model["html"] = template.render(**context)
model["html"] = await loop.run_in_executor(self.get_or_create_executor(model["uid"]), render, context) model["html"] = whitelist_attributes(model["html"])
#model['html'] = await loop.run_in_executor(self.get_or_create_executor(user["uid"]), sanitize_html,model['html']) return await super().save(model)
result = await super().save(model)
if model['is_final']:
self.delete_executor(model['uid'])
return result
async def offset(self, channel_uid, page=0, timestamp=None, page_size=30): async def offset(self, channel_uid, page=0, timestamp=None, page_size=30):
channel = await self.services.channel.get(uid=channel_uid) channel = await self.services.channel.get(uid=channel_uid)

View File

@ -44,29 +44,19 @@ class SocketService(BaseService):
async def user_availability_service(self): async def user_availability_service(self):
logger.info("User availability update service started.") logger.info("User availability update service started.")
logger.debug("Entering the main loop.")
while True: while True:
logger.info("Updating user availability...") logger.info("Updating user availability...")
logger.debug("Initializing users_updated list.")
users_updated = [] users_updated = []
logger.debug("Iterating over sockets.")
for s in self.sockets: for s in self.sockets:
logger.debug(f"Checking connection status for socket: {s}.")
if not s.is_connected: if not s.is_connected:
logger.debug("Socket is not connected, continuing to next socket.")
continue continue
logger.debug(f"Checking if user {s.user} is already updated.")
if s.user not in users_updated: if s.user not in users_updated:
logger.debug(f"Updating last_ping for user: {s.user}.")
s.user["last_ping"] = now() s.user["last_ping"] = now()
logger.debug(f"Saving user {s.user} to the database.")
await self.app.services.user.save(s.user) await self.app.services.user.save(s.user)
logger.debug(f"Adding user {s.user} to users_updated list.")
users_updated.append(s.user) users_updated.append(s.user)
logger.info( logger.info(
f"Updated user availability for {len(users_updated)} online users." f"Updated user availability for {len(users_updated)} online users."
) )
logger.debug("Sleeping for 60 seconds before the next update.")
await asyncio.sleep(60) await asyncio.sleep(60)
async def add(self, ws, user_uid): async def add(self, ws, user_uid):
@ -87,7 +77,7 @@ class SocketService(BaseService):
async def send_to_user(self, user_uid, message): async def send_to_user(self, user_uid, message):
count = 0 count = 0
for s in list(self.users.get(user_uid, [])): for s in self.users.get(user_uid, []):
if await s.send_json(message): if await s.send_json(message):
count += 1 count += 1
return count return count

View File

@ -65,7 +65,7 @@ export class Chat extends EventHandler {
} }
return new Promise((resolve) => { return new Promise((resolve) => {
this._waitConnect = resolve; this._waitConnect = resolve;
//console.debug("Connecting.."); console.debug("Connecting..");
try { try {
this._socket = new WebSocket(this._url); this._socket = new WebSocket(this._url);
@ -142,7 +142,7 @@ export class NotificationAudio {
new Audio(this.sounds[soundIndex]) new Audio(this.sounds[soundIndex])
.play() .play()
.then(() => { .then(() => {
//console.debug("Gave sound notification"); console.debug("Gave sound notification");
}) })
.catch((error) => { .catch((error) => {
console.error("Notification failed:", error); console.error("Notification failed:", error);

View File

@ -368,7 +368,7 @@ input[type="text"], .chat-input textarea {
} }
} }
.message.switch-user + .message, .message.long-time + .message, .message-list-bottom + .message{ .message.switch-user + .message, .message.long-time + .message, .message:first-child {
.time { .time {
display: block; display: block;
opacity: 1; opacity: 1;
@ -407,48 +407,34 @@ a {
width: 250px; width: 250px;
padding-left: 20px; padding-left: 20px;
padding-right: 20px; padding-right: 20px;
padding-top: 20px; padding-top: 10px;
overflow-y: auto; overflow-y: auto;
grid-area: sidebar; grid-area: sidebar;
} }
.sidebar h2 { .sidebar h2 {
color: #f05a28; color: #f05a28;
font-size: 0.75em; font-size: 1.2em;
font-weight: 600; margin-bottom: 20px;
text-transform: uppercase;
letter-spacing: 0.5px;
margin-bottom: 10px;
margin-top: 20px;
}
.sidebar h2:first-child {
margin-top: 0;
} }
.sidebar ul { .sidebar ul {
list-style: none; list-style: none;
margin-bottom: 15px;
} }
.sidebar ul li { .sidebar ul li {
margin-bottom: 4px; margin-bottom: 15px;
} }
.sidebar ul li a { .sidebar ul li a {
color: #ccc; color: #ccc;
text-decoration: none; text-decoration: none;
font-size: 0.9em; font-size: 1em;
transition: color 0.3s; transition: color 0.3s;
display: block;
padding: 4px 8px;
border-radius: 4px;
transition: background-color 0.2s, color 0.2s;
} }
.sidebar ul li a:hover { .sidebar ul li a:hover {
color: #fff; color: #fff;
background-color: rgba(255, 255, 255, 0.05);
} }
@keyframes glow { @keyframes glow {

View File

@ -81,152 +81,7 @@ class ChatInputComponent extends NjetComponent {
return Array.from(text.matchAll(/@([a-zA-Z0-9_-]+)/g), m => m[1]); return Array.from(text.matchAll(/@([a-zA-Z0-9_-]+)/g), m => m[1]);
} }
matchMentionsToAuthors(mentions, authors) { matchMentionsToAuthors(mentions, authors) {
return mentions.map((mention) => {
const lowerMention = mention.toLowerCase();
let bestMatch = null;
let bestScore = 0;
for (const author of authors) {
const lowerAuthor = author.toLowerCase();
let score = 0;
if (lowerMention === lowerAuthor) {
score = 100;
} else if (lowerAuthor.startsWith(lowerMention)) {
score = 90 + (5 * (lowerMention.length / lowerAuthor.length));
} else if (lowerAuthor.includes(lowerMention) && lowerMention.length >= 2) {
const position = lowerAuthor.indexOf(lowerMention);
score = 80 - (10 * (position / lowerAuthor.length));
} else if (this.isFuzzyMatch(lowerMention, lowerAuthor)) {
const ratio = lowerMention.length / lowerAuthor.length;
score = 40 + (20 * ratio);
} else if (this.isCloseMatch(lowerMention, lowerAuthor)) {
score = 30 + (10 * (lowerMention.length / lowerAuthor.length));
}
if (score > bestScore) {
bestScore = score;
bestMatch = author;
}
}
const minScore = 40;
return {
mention,
closestAuthor: bestScore >= minScore ? bestMatch : null,
score: bestScore,
};
});
}
isFuzzyMatch(needle, haystack) {
if (needle.length < 2) return false;
let needleIndex = 0;
for (let i = 0; i < haystack.length && needleIndex < needle.length; i++) {
if (haystack[i] === needle[needleIndex]) {
needleIndex++;
}
}
return needleIndex === needle.length;
}
isCloseMatch(str1, str2) {
if (Math.abs(str1.length - str2.length) > 2) return false;
const shorter = str1.length <= str2.length ? str1 : str2;
const longer = str1.length > str2.length ? str1 : str2;
let differences = 0;
let j = 0;
for (let i = 0; i < shorter.length && j < longer.length; i++) {
if (shorter[i] !== longer[j]) {
differences++;
if (j + 1 < longer.length && shorter[i] === longer[j + 1]) {
j++;
}
}
j++;
}
differences += Math.abs(longer.length - j);
return differences <= 2;
}
matchMentions4ToAuthors(mentions, authors) {
return mentions.map((mention) => {
let closestAuthor = null;
let minDistance = Infinity;
const lowerMention = mention.toLowerCase();
authors.forEach((author) => {
const lowerAuthor = author.toLowerCase();
let distance = this.levenshteinDistance(lowerMention, lowerAuthor);
if (!this.isSubsequence(lowerMention, lowerAuthor)) {
distance += 10;
}
if (distance < minDistance) {
minDistance = distance;
closestAuthor = author;
}
});
if (minDistance < 5) {
closestAuthor = 0;
}
return { mention, closestAuthor, distance: minDistance };
});
}
levenshteinDistance(a, b) {
const matrix = [];
for (let i = 0; i <= b.length; i++) {
matrix[i] = [i];
}
for (let j = 0; j <= a.length; j++) {
matrix[0][j] = j;
}
for (let i = 1; i <= b.length; i++) {
for (let j = 1; j <= a.length; j++) {
if (b.charAt(i - 1) === a.charAt(j - 1)) {
matrix[i][j] = matrix[i - 1][j - 1];
} else {
matrix[i][j] = Math.min(
matrix[i - 1][j] + 1,
matrix[i][j - 1] + 1,
matrix[i - 1][j - 1] + 1
);
}
}
}
return matrix[b.length][a.length];
}
replaceMentionsWithAuthors(text) {
const authors = this.getAuthors();
const mentions = this.extractMentions(text);
const matches = this.matchMentionsToAuthors(mentions, authors);
let updatedText = text;
matches.forEach(({ mention, closestAuthor }) => {
if(closestAuthor){
const mentionRegex = new RegExp(`@${mention}`, 'g');
updatedText = updatedText.replace(mentionRegex, `@${closestAuthor}`);
}
});
return updatedText;
}
matchMentions2ToAuthors(mentions, authors) {
return mentions.map(mention => { return mentions.map(mention => {
let closestAuthor = null; let closestAuthor = null;
let minDistance = Infinity; let minDistance = Infinity;
@ -245,14 +100,53 @@ class ChatInputComponent extends NjetComponent {
closestAuthor = author; closestAuthor = author;
} }
}); });
if (minDistance < 5){
closestAuthor = 0;
}
return { mention, closestAuthor, distance: minDistance }; return { mention, closestAuthor, distance: minDistance };
}); });
} }
levenshteinDistance(a, b) {
const matrix = [];
// Initialize the first row and column
for (let i = 0; i <= b.length; i++) {
matrix[i] = [i];
}
for (let j = 0; j <= a.length; j++) {
matrix[0][j] = j;
}
// Fill in the matrix
for (let i = 1; i <= b.length; i++) {
for (let j = 1; j <= a.length; j++) {
if (b.charAt(i - 1) === a.charAt(j - 1)) {
matrix[i][j] = matrix[i - 1][j - 1];
} else {
matrix[i][j] = Math.min(
matrix[i - 1][j] + 1, // Deletion
matrix[i][j - 1] + 1, // Insertion
matrix[i - 1][j - 1] + 1 // Substitution
);
}
}
}
return matrix[b.length][a.length];
}
replaceMentionsWithAuthors(text) {
const authors = this.getAuthors();
const mentions = this.extractMentions(text);
const matches = this.matchMentionsToAuthors(mentions, authors);
let updatedText = text;
matches.forEach(({ mention, closestAuthor }) => {
const mentionRegex = new RegExp(`@${mention}`, 'g');
updatedText = updatedText.replace(mentionRegex, `@${closestAuthor}`);
});
return updatedText;
}
textToLeet(text) { textToLeet(text) {
// L33t speak character mapping // L33t speak character mapping
const leetMap = { const leetMap = {
@ -498,7 +392,7 @@ textToLeetAdvanced(text) {
} }
j++; j++;
} }
return i === s.length && s.length > 1; return i === s.length;
} }
flagTyping() { flagTyping() {

View File

@ -1,98 +1,38 @@
import { NjetComponent } from "/njet.js" import { NjetComponent} from "/njet.js"
class NjetEditor extends NjetComponent { class NjetEditor extends NjetComponent {
constructor() { constructor() {
super(); super();
this.attachShadow({ mode: 'open' }); this.attachShadow({ mode: 'open' });
const style = document.createElement('style'); const style = document.createElement('style');
style.textContent = ` style.textContent = `
:host {
display: block;
position: relative;
height: 100%;
font-family: 'Monaco', 'Menlo', 'Ubuntu Mono', monospace;
}
#editor { #editor {
padding: 1rem; padding: 1rem;
outline: none; outline: none;
white-space: pre-wrap; white-space: pre-wrap;
line-height: 1.5; line-height: 1.5;
height: calc(100% - 30px); height: 100%;
overflow-y: auto; overflow-y: auto;
background: #1e1e1e; background: #1e1e1e;
color: #d4d4d4; color: #d4d4d4;
font-size: 14px;
caret-color: #fff;
} }
#command-line {
#editor.insert-mode {
caret-color: #4ec9b0;
}
#editor.visual-mode {
caret-color: #c586c0;
}
#editor::selection {
background: #264f78;
}
#status-bar {
position: absolute; position: absolute;
bottom: 0; bottom: 0;
left: 0; left: 0;
right: 0;
height: 30px;
background: #007acc;
color: #fff;
display: flex;
align-items: center;
padding: 0 1rem;
font-size: 12px;
font-weight: 500;
}
#mode-indicator {
text-transform: uppercase;
margin-right: 20px;
font-weight: bold;
}
#command-line {
position: absolute;
bottom: 30px;
left: 0;
width: 100%; width: 100%;
padding: 0.3rem 1rem; padding: 0.2rem 1rem;
background: #2d2d2d; background: #333;
color: #d4d4d4; color: #0f0;
display: none; display: none;
font-family: inherit; font-family: monospace;
border-top: 1px solid #3e3e3e;
font-size: 14px;
}
#command-input {
background: transparent;
border: none;
color: inherit;
outline: none;
font-family: inherit;
font-size: inherit;
width: calc(100% - 20px);
}
.visual-selection {
background: #264f78 !important;
} }
`; `;
this.editor = document.createElement('div'); this.editor = document.createElement('div');
this.editor.id = 'editor'; this.editor.id = 'editor';
this.editor.contentEditable = true; this.editor.contentEditable = true;
this.editor.spellcheck = false;
this.editor.innerText = `Welcome to VimEditor Component this.editor.innerText = `Welcome to VimEditor Component
Line 2 here Line 2 here
Another line Another line
@ -100,90 +40,22 @@ Try i, Esc, v, :, yy, dd, 0, $, gg, G, and p`;
this.cmdLine = document.createElement('div'); this.cmdLine = document.createElement('div');
this.cmdLine.id = 'command-line'; this.cmdLine.id = 'command-line';
this.shadowRoot.append(style, this.editor, this.cmdLine);
const cmdPrompt = document.createElement('span'); this.mode = 'normal'; // normal | insert | visual | command
cmdPrompt.textContent = ':';
this.cmdInput = document.createElement('input');
this.cmdInput.id = 'command-input';
this.cmdInput.type = 'text';
this.cmdLine.append(cmdPrompt, this.cmdInput);
this.statusBar = document.createElement('div');
this.statusBar.id = 'status-bar';
this.modeIndicator = document.createElement('span');
this.modeIndicator.id = 'mode-indicator';
this.modeIndicator.textContent = 'NORMAL';
this.statusBar.appendChild(this.modeIndicator);
this.shadowRoot.append(style, this.editor, this.cmdLine, this.statusBar);
this.mode = 'normal';
this.keyBuffer = ''; this.keyBuffer = '';
this.lastDeletedLine = ''; this.lastDeletedLine = '';
this.yankedLine = ''; this.yankedLine = '';
this.visualStartOffset = null;
this.visualEndOffset = null;
// Bind event handlers this.editor.addEventListener('keydown', this.handleKeydown.bind(this));
this.handleKeydown = this.handleKeydown.bind(this);
this.handleCmdKeydown = this.handleCmdKeydown.bind(this);
this.updateVisualSelection = this.updateVisualSelection.bind(this);
this.editor.addEventListener('keydown', this.handleKeydown);
this.cmdInput.addEventListener('keydown', this.handleCmdKeydown);
this.editor.addEventListener('beforeinput', this.handleBeforeInput.bind(this));
} }
connectedCallback() { connectedCallback() {
this.editor.focus(); this.editor.focus();
} }
setMode(mode) {
this.mode = mode;
this.modeIndicator.textContent = mode.toUpperCase();
// Update editor classes
this.editor.classList.remove('insert-mode', 'visual-mode', 'normal-mode');
this.editor.classList.add(`${mode}-mode`);
if (mode === 'visual') {
this.visualStartOffset = this.getCaretOffset();
this.editor.addEventListener('selectionchange', this.updateVisualSelection);
} else {
this.clearVisualSelection();
this.editor.removeEventListener('selectionchange', this.updateVisualSelection);
}
if (mode === 'command') {
this.cmdLine.style.display = 'block';
this.cmdInput.value = '';
this.cmdInput.focus();
} else {
this.cmdLine.style.display = 'none';
if (mode !== 'insert') {
// Keep focus on editor for all non-insert modes
this.editor.focus();
}
}
}
updateVisualSelection() {
if (this.mode !== 'visual') return;
this.visualEndOffset = this.getCaretOffset();
}
clearVisualSelection() {
const sel = this.shadowRoot.getSelection();
if (sel) sel.removeAllRanges();
this.visualStartOffset = null;
this.visualEndOffset = null;
}
getCaretOffset() { getCaretOffset() {
let caretOffset = 0;
const sel = this.shadowRoot.getSelection(); const sel = this.shadowRoot.getSelection();
if (!sel || sel.rangeCount === 0) return 0; if (!sel || sel.rangeCount === 0) return 0;
@ -191,304 +63,164 @@ Try i, Esc, v, :, yy, dd, 0, $, gg, G, and p`;
const preCaretRange = range.cloneRange(); const preCaretRange = range.cloneRange();
preCaretRange.selectNodeContents(this.editor); preCaretRange.selectNodeContents(this.editor);
preCaretRange.setEnd(range.endContainer, range.endOffset); preCaretRange.setEnd(range.endContainer, range.endOffset);
return preCaretRange.toString().length; caretOffset = preCaretRange.toString().length;
return caretOffset;
} }
setCaretOffset(offset) { setCaretOffset(offset) {
const textContent = this.editor.innerText;
offset = Math.max(0, Math.min(offset, textContent.length));
const range = document.createRange(); const range = document.createRange();
const sel = this.shadowRoot.getSelection(); const sel = this.shadowRoot.getSelection();
const walker = document.createTreeWalker( const walker = document.createTreeWalker(this.editor, NodeFilter.SHOW_TEXT, null, false);
this.editor,
NodeFilter.SHOW_TEXT,
null,
false
);
let currentOffset = 0; let currentOffset = 0;
let node; let node;
while ((node = walker.nextNode())) { while ((node = walker.nextNode())) {
const nodeLength = node.textContent.length; if (currentOffset + node.length >= offset) {
if (currentOffset + nodeLength >= offset) {
range.setStart(node, offset - currentOffset); range.setStart(node, offset - currentOffset);
range.collapse(true); range.collapse(true);
sel.removeAllRanges(); sel.removeAllRanges();
sel.addRange(range); sel.addRange(range);
return; return;
} }
currentOffset += nodeLength; currentOffset += node.length;
} }
// If we couldn't find the position, set to end
if (this.editor.lastChild) {
range.selectNodeContents(this.editor.lastChild);
range.collapse(false);
sel.removeAllRanges();
sel.addRange(range);
}
}
handleBeforeInput(e) {
if (this.mode !== 'insert') {
e.preventDefault();
}
}
handleCmdKeydown(e) {
if (e.key === 'Enter') {
e.preventDefault();
this.executeCommand(this.cmdInput.value);
this.setMode('normal');
} else if (e.key === 'Escape') {
e.preventDefault();
this.setMode('normal');
}
}
executeCommand(cmd) {
const trimmedCmd = cmd.trim();
// Handle basic vim commands
if (trimmedCmd === 'w' || trimmedCmd === 'write') {
console.log('Save command (not implemented)');
} else if (trimmedCmd === 'q' || trimmedCmd === 'quit') {
console.log('Quit command (not implemented)');
} else if (trimmedCmd === 'wq' || trimmedCmd === 'x') {
console.log('Save and quit command (not implemented)');
} else if (/^\d+$/.test(trimmedCmd)) {
// Go to line number
const lineNum = parseInt(trimmedCmd, 10) - 1;
this.goToLine(lineNum);
}
}
goToLine(lineNum) {
const lines = this.editor.innerText.split('\n');
if (lineNum < 0 || lineNum >= lines.length) return;
let offset = 0;
for (let i = 0; i < lineNum; i++) {
offset += lines[i].length + 1;
}
this.setCaretOffset(offset);
}
getCurrentLineInfo() {
const text = this.editor.innerText;
const caretPos = this.getCaretOffset();
const lines = text.split('\n');
let charCount = 0;
for (let i = 0; i < lines.length; i++) {
if (caretPos <= charCount + lines[i].length) {
return {
lineIndex: i,
lines: lines,
lineStartOffset: charCount,
positionInLine: caretPos - charCount
};
}
charCount += lines[i].length + 1;
}
return {
lineIndex: lines.length - 1,
lines: lines,
lineStartOffset: charCount - lines[lines.length - 1].length - 1,
positionInLine: 0
};
} }
handleKeydown(e) { handleKeydown(e) {
const key = e.key;
if (this.mode === 'insert') { if (this.mode === 'insert') {
if (e.key === 'Escape') { if (key === 'Escape') {
e.preventDefault(); e.preventDefault();
this.setMode('normal'); this.mode = 'normal';
// Move cursor one position left (vim behavior) this.editor.blur();
const offset = this.getCaretOffset(); this.editor.focus();
if (offset > 0) {
this.setCaretOffset(offset - 1);
}
} }
return; return;
} }
if (this.mode === 'command') { if (this.mode === 'command') {
return; // Command mode input is handled by cmdInput if (key === 'Enter' || key === 'Escape') {
e.preventDefault();
this.cmdLine.style.display = 'none';
this.mode = 'normal';
this.keyBuffer = '';
}
return;
} }
if (this.mode === 'visual') { if (this.mode === 'visual') {
if (e.key === 'Escape') { if (key === 'Escape') {
e.preventDefault(); e.preventDefault();
this.setMode('normal'); this.mode = 'normal';
}
return; return;
} }
// Allow movement in visual mode // Handle normal mode
if (['ArrowLeft', 'ArrowRight', 'ArrowUp', 'ArrowDown'].includes(e.key)) { this.keyBuffer += key;
return; // Let default behavior handle selection
const text = this.editor.innerText;
const caretPos = this.getCaretOffset();
const lines = text.split('\n');
let charCount = 0, lineIdx = 0;
for (let i = 0; i < lines.length; i++) {
if (caretPos <= charCount + lines[i].length) {
lineIdx = i;
break;
}
charCount += lines[i].length + 1;
} }
if (e.key === 'y') { const offsetToLine = idx =>
e.preventDefault(); text.split('\n').slice(0, idx).reduce((acc, l) => acc + l.length + 1, 0);
// Yank selected text
const sel = this.shadowRoot.getSelection();
if (sel && sel.rangeCount > 0) {
this.yankedLine = sel.toString();
}
this.setMode('normal');
return;
}
if (e.key === 'd' || e.key === 'x') {
e.preventDefault();
// Delete selected text
const sel = this.shadowRoot.getSelection();
if (sel && sel.rangeCount > 0) {
this.lastDeletedLine = sel.toString();
document.execCommand('delete');
}
this.setMode('normal');
return;
}
}
// Normal mode handling
e.preventDefault();
// Special keys that should be handled immediately
if (e.key === 'Escape') {
this.keyBuffer = '';
this.setMode('normal');
return;
}
// Build key buffer for commands
this.keyBuffer += e.key;
const lineInfo = this.getCurrentLineInfo();
const { lineIndex, lines, lineStartOffset, positionInLine } = lineInfo;
// Process commands
switch (this.keyBuffer) { switch (this.keyBuffer) {
case 'i': case 'i':
e.preventDefault();
this.mode = 'insert';
this.keyBuffer = ''; this.keyBuffer = '';
this.setMode('insert');
break;
case 'a':
this.keyBuffer = '';
this.setCaretOffset(this.getCaretOffset() + 1);
this.setMode('insert');
break; break;
case 'v': case 'v':
e.preventDefault();
this.mode = 'visual';
this.keyBuffer = ''; this.keyBuffer = '';
this.setMode('visual');
break; break;
case ':': case ':':
e.preventDefault();
this.mode = 'command';
this.cmdLine.style.display = 'block';
this.cmdLine.textContent = ':';
this.keyBuffer = ''; this.keyBuffer = '';
this.setMode('command');
break; break;
case 'yy': case 'yy':
e.preventDefault();
this.yankedLine = lines[lineIdx];
this.keyBuffer = ''; this.keyBuffer = '';
this.yankedLine = lines[lineIndex];
break; break;
case 'dd': case 'dd':
this.keyBuffer = ''; e.preventDefault();
this.lastDeletedLine = lines[lineIndex]; this.lastDeletedLine = lines[lineIdx];
lines.splice(lineIndex, 1); lines.splice(lineIdx, 1);
if (lines.length === 0) lines.push('');
this.editor.innerText = lines.join('\n'); this.editor.innerText = lines.join('\n');
this.setCaretOffset(lineStartOffset); this.setCaretOffset(offsetToLine(lineIdx));
this.keyBuffer = '';
break; break;
case 'p': case 'p':
this.keyBuffer = ''; e.preventDefault();
const lineToPaste = this.yankedLine || this.lastDeletedLine; const lineToPaste = this.yankedLine || this.lastDeletedLine;
if (lineToPaste) { if (lineToPaste) {
lines.splice(lineIndex + 1, 0, lineToPaste); lines.splice(lineIdx + 1, 0, lineToPaste);
this.editor.innerText = lines.join('\n'); this.editor.innerText = lines.join('\n');
this.setCaretOffset(lineStartOffset + lines[lineIndex].length + 1); this.setCaretOffset(offsetToLine(lineIdx + 1));
} }
this.keyBuffer = '';
break; break;
case '0': case '0':
e.preventDefault();
this.setCaretOffset(offsetToLine(lineIdx));
this.keyBuffer = ''; this.keyBuffer = '';
this.setCaretOffset(lineStartOffset);
break; break;
case '$': case '$':
e.preventDefault();
this.setCaretOffset(offsetToLine(lineIdx) + lines[lineIdx].length);
this.keyBuffer = ''; this.keyBuffer = '';
this.setCaretOffset(lineStartOffset + lines[lineIndex].length);
break; break;
case 'gg': case 'gg':
this.keyBuffer = ''; e.preventDefault();
this.setCaretOffset(0); this.setCaretOffset(0);
this.keyBuffer = '';
break; break;
case 'G': case 'G':
e.preventDefault();
this.setCaretOffset(text.length);
this.keyBuffer = ''; this.keyBuffer = '';
this.setCaretOffset(this.editor.innerText.length);
break; break;
case 'h': case 'Escape':
case 'ArrowLeft': e.preventDefault();
this.mode = 'normal';
this.keyBuffer = ''; this.keyBuffer = '';
const currentOffset = this.getCaretOffset(); this.cmdLine.style.display = 'none';
if (currentOffset > 0) {
this.setCaretOffset(currentOffset - 1);
}
break;
case 'l':
case 'ArrowRight':
this.keyBuffer = '';
this.setCaretOffset(this.getCaretOffset() + 1);
break;
case 'j':
case 'ArrowDown':
this.keyBuffer = '';
if (lineIndex < lines.length - 1) {
const nextLineStart = lineStartOffset + lines[lineIndex].length + 1;
const nextLineLength = lines[lineIndex + 1].length;
const newPosition = Math.min(positionInLine, nextLineLength);
this.setCaretOffset(nextLineStart + newPosition);
}
break;
case 'k':
case 'ArrowUp':
this.keyBuffer = '';
if (lineIndex > 0) {
let prevLineStart = 0;
for (let i = 0; i < lineIndex - 1; i++) {
prevLineStart += lines[i].length + 1;
}
const prevLineLength = lines[lineIndex - 1].length;
const newPosition = Math.min(positionInLine, prevLineLength);
this.setCaretOffset(prevLineStart + newPosition);
}
break; break;
default: default:
// Clear buffer if it gets too long or contains invalid sequences // allow up to 2 chars for combos
if (this.keyBuffer.length > 2 || if (this.keyBuffer.length > 2) this.keyBuffer = '';
(this.keyBuffer.length === 2 && !['dd', 'yy', 'gg'].includes(this.keyBuffer))) {
this.keyBuffer = '';
}
break; break;
} }
} }
} }
customElements.define('njet-editor', NjetEditor); customElements.define('njet-editor', NjetEditor);
export { NjetEditor } export {NjetEditor}

View File

@ -3,15 +3,8 @@ export class EventHandler {
this.subscribers = {}; this.subscribers = {};
} }
addEventListener(type, handler, { once = false } = {}) { addEventListener(type, handler) {
if (!this.subscribers[type]) this.subscribers[type] = []; if (!this.subscribers[type]) this.subscribers[type] = [];
if (once) {
const originalHandler = handler;
handler = (...args) => {
originalHandler(...args);
this.removeEventListener(type, handler);
};
}
this.subscribers[type].push(handler); this.subscribers[type].push(handler);
} }
@ -19,15 +12,4 @@ export class EventHandler {
if (this.subscribers[type]) if (this.subscribers[type])
this.subscribers[type].forEach((handler) => handler(...data)); this.subscribers[type].forEach((handler) => handler(...data));
} }
removeEventListener(type, handler) {
if (!this.subscribers[type]) return;
this.subscribers[type] = this.subscribers[type].filter(
(h) => h !== handler
);
if (this.subscribers[type].length === 0) {
delete this.subscribers[type];
}
}
} }

View File

@ -5,71 +5,30 @@
// The code seems to rely on some external dependencies like 'models.Message', 'app', and 'Schedule'. These should be imported or defined elsewhere in your application. // The code seems to rely on some external dependencies like 'models.Message', 'app', and 'Schedule'. These should be imported or defined elsewhere in your application.
// MIT License: This is free software. Permission is granted to use, copy, modify, and/or distribute this software for any purpose with or without fee. The software is provided "as is" without any warranty. // MIT License: This is free software. Permission is granted to use, copy, modify, and/or distribute this software for any purpose with or without fee. The software is provided "as is" without any warranty.
import { app } from "./app.js"; import {app} from "./app.js";
const LONG_TIME = 1000 * 60 * 20; const LONG_TIME = 1000 * 60 * 20
export class ReplyEvent extends Event {
constructor(messageTextTarget) {
super('reply', { bubbles: true, composed: true });
this.messageTextTarget = messageTextTarget;
// Clone and sanitize message node to text-only reply
const newMessage = messageTextTarget.cloneNode(true);
newMessage.style.maxHeight = "0";
messageTextTarget.parentElement.insertBefore(newMessage, messageTextTarget);
// Remove all .embed-url-link
newMessage.querySelectorAll('.embed-url-link').forEach(link => link.remove());
// Replace <picture> with their <img>
newMessage.querySelectorAll('picture').forEach(picture => {
const img = picture.querySelector('img');
if (img) picture.replaceWith(img);
});
// Replace <img> with just their src
newMessage.querySelectorAll('img').forEach(img => {
const src = img.src || img.currentSrc;
img.replaceWith(document.createTextNode(src));
});
// Replace <video> with just their src
newMessage.querySelectorAll('video').forEach(vid => {
const src = vid.src || vid.currentSrc || vid.querySelector('source').src;
vid.replaceWith(document.createTextNode(src));
});
// Replace <iframe> with their src
newMessage.querySelectorAll('iframe').forEach(iframe => {
const src = iframe.src || iframe.currentSrc;
iframe.replaceWith(document.createTextNode(src));
});
// Replace <a> with href or markdown
newMessage.querySelectorAll('a').forEach(a => {
const href = a.getAttribute('href');
const text = a.innerText || a.textContent;
if (text === href || text === '') {
a.replaceWith(document.createTextNode(href));
} else {
a.replaceWith(document.createTextNode(`[${text}](${href})`));
}
});
this.replyText = newMessage.innerText.replaceAll("\n\n", "\n").trim();
newMessage.remove();
}
}
class MessageElement extends HTMLElement { class MessageElement extends HTMLElement {
static observedAttributes = ['data-uid', 'data-color', 'data-channel_uid', 'data-user_nick', 'data-created_at', 'data-user_uid'];
isVisible() {
if (!this) return false;
const rect = this.getBoundingClientRect();
return (
rect.top >= 0 &&
rect.left >= 0 &&
rect.bottom <= (window.innerHeight || document.documentElement.clientHeight) &&
rect.right <= (window.innerWidth || document.documentElement.clientWidth)
);
}
updateUI() { updateUI() {
if (this._originalChildren === undefined) { if (this._originalChildren === undefined) {
const { color, user_nick, created_at, user_uid } = this.dataset; const { color, user_nick, created_at, user_uid} = this.dataset;
this.classList.add('message'); this.classList.add('message');
this.style.maxWidth = '100%'; this.style.maxWidth = '100%';
this._originalChildren = Array.from(this.children); this._originalChildren = Array.from(this.children);
this.innerHTML = ` this.innerHTML = `
<a class="avatar" style="background-color: ${color || ''}; color: black;" href="/user/${user_uid || ''}.html"> <a class="avatar" style="background-color: ${color || ''}; color: black;" href="/user/${user_uid || ''}.html">
<img class="avatar-img" width="40" height="40" src="/avatar/${user_uid || ''}.svg" alt="${user_nick || ''}" loading="lazy"> <img class="avatar-img" width="40" height="40" src="/avatar/${user_uid || ''}.svg" alt="${user_nick || ''}" loading="lazy">
@ -79,12 +38,12 @@ class MessageElement extends HTMLElement {
<div class="text"></div> <div class="text"></div>
<div class="time no-select" data-created_at="${created_at || ''}"> <div class="time no-select" data-created_at="${created_at || ''}">
<span></span> <span></span>
<a href="#reply">reply</a> <a href="#reply">reply</a></div>
</div>
</div> </div>
`; `;
this.messageDiv = this.querySelector('.text'); this.messageDiv = this.querySelector('.text');
if (this._originalChildren && this._originalChildren.length > 0) { if (this._originalChildren && this._originalChildren.length > 0) {
this._originalChildren.forEach(child => { this._originalChildren.forEach(child => {
this.messageDiv.appendChild(child); this.messageDiv.appendChild(child);
@ -92,17 +51,10 @@ class MessageElement extends HTMLElement {
} }
this.timeDiv = this.querySelector('.time span'); this.timeDiv = this.querySelector('.time span');
this.replyDiv = this.querySelector('.time a');
this.replyDiv.addEventListener('click', (e) => {
e.preventDefault();
this.dispatchEvent(new ReplyEvent(this.messageDiv));
});
} }
// Sibling logic for user switches and long time gaps if (!this.siblingGenerated && this.nextElementSibling) {
if ((!this.siblingGenerated || this.siblingGenerated !== this.nextElementSibling) && this.nextElementSibling) { this.siblingGenerated = true;
this.siblingGenerated = this.nextElementSibling;
if (this.nextElementSibling?.dataset?.user_uid !== this.dataset.user_uid) { if (this.nextElementSibling?.dataset?.user_uid !== this.dataset.user_uid) {
this.classList.add('switch-user'); this.classList.add('switch-user');
} else { } else {
@ -123,7 +75,7 @@ class MessageElement extends HTMLElement {
updateMessage(...messages) { updateMessage(...messages) {
if (this._originalChildren) { if (this._originalChildren) {
this.messageDiv.replaceChildren(...messages); this.messageDiv.replaceChildren(...messages)
this._originalChildren = messages; this._originalChildren = messages;
} }
} }
@ -132,70 +84,62 @@ class MessageElement extends HTMLElement {
this.updateUI(); this.updateUI();
} }
disconnectedCallback() {} disconnectedCallback() {
connectedMoveCallback() {} }
connectedMoveCallback() {
}
attributeChangedCallback(name, oldValue, newValue) { attributeChangedCallback(name, oldValue, newValue) {
this.updateUI(); this.updateUI()
} }
} }
class MessageList extends HTMLElement { class MessageList extends HTMLElement {
constructor() { constructor() {
super(); super();
app.ws.addEventListener("update_message_text", (data) => {
this.upsertMessage(data);
});
app.ws.addEventListener("set_typing", (data) => {
this.triggerGlow(data.user_uid,data.color);
});
this.messageMap = new Map(); this.messageMap = new Map();
this.visibleSet = new Set(); this.visibleSet = new Set();
this._observer = new IntersectionObserver((entries) => { this._observer = new IntersectionObserver((entries) => {
entries.forEach((entry) => { entries.forEach((entry) => {
if (entry.isIntersecting) { if (entry.isIntersecting) {
this.visibleSet.add(entry.target); this.visibleSet.add(entry.target);
if (entry.target instanceof MessageElement) { const messageElement = entry.target;
entry.target.updateUI(); if (messageElement instanceof MessageElement) {
messageElement.updateUI();
} }
} else { } else {
this.visibleSet.delete(entry.target); this.visibleSet.delete(entry.target);
} }
}); });
console.log(this.visibleSet);
}, { }, {
root: this, root: this,
threshold: 0, threshold: 0.1
}); })
// End-of-messages marker for(const c of this.children) {
this.endOfMessages = document.createElement('div');
this.endOfMessages.classList.add('message-list-bottom');
this.prepend(this.endOfMessages);
// Observe existing children and index by uid
for (const c of this.children) {
this._observer.observe(c); this._observer.observe(c);
if (c instanceof MessageElement) { if (c instanceof MessageElement) {
this.messageMap.set(c.dataset.uid, c); this.messageMap.set(c.dataset.uid, c);
} }
} }
// Wire up socket events
app.ws.addEventListener("update_message_text", (data) => {
if (this.messageMap.has(data.uid)) {
this.upsertMessage(data);
}
});
app.ws.addEventListener("set_typing", (data) => {
this.triggerGlow(data.user_uid, data.color);
});
this.scrollToBottom(true); this.scrollToBottom(true);
} }
connectedCallback() { connectedCallback() {
this.addEventListener('click', (e) => { this.addEventListener('click', (e) => {
if ( if (e.target.tagName !== 'IMG' || e.target.classList.contains('avatar-img')) return;
e.target.tagName !== 'IMG' ||
e.target.classList.contains('avatar-img')
) return;
const img = e.target; const img = e.target;
const overlay = document.createElement('div'); const overlay = document.createElement('div');
overlay.style.cssText = 'position:fixed;top:0;left:0;width:100%;height:100%;background:rgba(0,0,0,0.9);display:flex;justify-content:center;align-items:center;z-index:9999;cursor:pointer;'; overlay.style.cssText = 'position:fixed;top:0;left:0;width:100%;height:100%;background:rgba(0,0,0,0.9);display:flex;justify-content:center;align-items:center;z-index:9999;cursor:pointer;';
@ -216,11 +160,12 @@ class MessageList extends HTMLElement {
overlay.appendChild(fullImg); overlay.appendChild(fullImg);
document.body.appendChild(overlay); document.body.appendChild(overlay);
overlay.addEventListener('click', () => { overlay.addEventListener('click', () => {
if (overlay.parentNode) overlay.parentNode.removeChild(overlay); if (overlay.parentNode) {
overlay.parentNode.removeChild(overlay);
}
}); });
// ESC to close // Optional: ESC key closes overlay
const escListener = (evt) => { const escListener = (evt) => {
if (evt.key === 'Escape') { if (evt.key === 'Escape') {
if (overlay.parentNode) overlay.parentNode.removeChild(overlay); if (overlay.parentNode) overlay.parentNode.removeChild(overlay);
@ -228,9 +173,9 @@ class MessageList extends HTMLElement {
} }
}; };
document.addEventListener('keydown', escListener); document.addEventListener('keydown', escListener);
}); })
}
}
isElementVisible(element) { isElementVisible(element) {
if (!element) return false; if (!element) return false;
const rect = element.getBoundingClientRect(); const rect = element.getBoundingClientRect();
@ -241,16 +186,14 @@ class MessageList extends HTMLElement {
rect.right <= (window.innerWidth || document.documentElement.clientWidth) rect.right <= (window.innerWidth || document.documentElement.clientWidth)
); );
} }
isScrolledToBottom() { isScrolledToBottom() {
return this.visibleSet.has(this.endOfMessages); return this.isElementVisible(this.firstElementChild);
} }
scrollToBottom(force = false, behavior= 'smooth') {
scrollToBottom(force = false, behavior = 'instant') { if (force || this.isScrolledToBottom()) {
if (force || !this.isScrolledToBottom()) { this.firstElementChild.scrollIntoView({ behavior, block: 'start' });
this.endOfMessages.scrollIntoView({ behavior, block: 'end' });
setTimeout(() => { setTimeout(() => {
this.endOfMessages.scrollIntoView({ behavior, block: 'end' }); this.firstElementChild.scrollIntoView({ behavior, block: 'start' });
}, 200); }, 200);
} }
} }
@ -262,9 +205,7 @@ class MessageList extends HTMLElement {
this.querySelectorAll('.avatar').forEach((el) => { this.querySelectorAll('.avatar').forEach((el) => {
const anchor = el.closest('a'); const anchor = el.closest('a');
if (anchor && typeof anchor.href === 'string' && anchor.href.includes(uid)) { if (anchor && typeof anchor.href === 'string' && anchor.href.includes(uid)) {
if(!lastElement)
lastElement = el; lastElement = el;
} }
}); });
if (lastElement) { if (lastElement) {
@ -275,48 +216,41 @@ class MessageList extends HTMLElement {
} }
} }
updateTimes() { updateTimes() {
this.visibleSet.forEach((messageElement) => { this.visibleSet.forEach((messageElement) => {
if (messageElement instanceof MessageElement) { if (messageElement instanceof MessageElement) {
messageElement.updateUI(); messageElement.updateUI();
} }
}); })
} }
upsertMessage(data) { upsertMessage(data) {
let message = this.messageMap.get(data.uid); let message = this.messageMap.get(data.uid);
if (message && (data.is_final || !data.message)) { const newMessage = !!message;
//message.parentElement?.removeChild(message); if (message) {
// TO force insert message.parentElement.removeChild(message);
//message = null; }
} if (!data.message) return
if(message && !data.message){
message.parentElement?.removeChild(message);
message = null;
}
if (!data.message) return;
const wrapper = document.createElement("div"); const wrapper = document.createElement("div");
wrapper.innerHTML = data.html; wrapper.innerHTML = data.html;
if (message) { if (message) {
// If the old element is already custom, only update its message children message.updateMessage(...wrapper.firstElementChild._originalChildren);
message.updateMessage(...(wrapper.firstElementChild._originalChildren || wrapper.firstElementChild.children));
} else { } else {
// If not, insert the new one and observe
message = wrapper.firstElementChild; message = wrapper.firstElementChild;
this.messageMap.set(data.uid, message); this.messageMap.set(data.uid, message);
this._observer.observe(message); this._observer.observe(message);
this.endOfMessages.after(message);
} }
const scrolledToBottom = this.isScrolledToBottom(); const scrolledToBottom = this.isScrolledToBottom();
this.prepend(message);
if (scrolledToBottom) this.scrollToBottom(true); if (scrolledToBottom) this.scrollToBottom(true, !newMessage ? 'smooth' : 'auto');
} }
} }
customElements.define("chat-message", MessageElement); customElements.define("chat-message", MessageElement);
customElements.define("message-list", MessageList); customElements.define("message-list", MessageList);

View File

@ -1,3 +1,5 @@
class RestClient { class RestClient {
constructor({ baseURL = '', headers = {} } = {}) { constructor({ baseURL = '', headers = {} } = {}) {
this.baseURL = baseURL; this.baseURL = baseURL;
@ -208,52 +210,27 @@ class Njet extends HTMLElement {
customElements.define(name, component); customElements.define(name, component);
} }
constructor(config) { constructor() {
super(); super();
// Store the config for use in render and other methods
this.config = config || {};
if (!Njet._root) { if (!Njet._root) {
Njet._root = this Njet._root = this
Njet._rest = new RestClient({ baseURL: '/' || null }) Njet._rest = new RestClient({ baseURL: '/' || null })
} }
this.root._elements.push(this) this.root._elements.push(this)
this.classList.add('njet'); this.classList.add('njet');
// Initialize properties from config before rendering
this.initProps(this.config);
// Call render after properties are initialized
this.render.call(this); this.render.call(this);
//this.initProps(config);
// Call construct if defined //if (typeof this.config.construct === 'function')
if (typeof this.config.construct === 'function') { // this.config.construct.call(this)
this.config.construct.call(this)
}
} }
initProps(config) { initProps(config) {
const props = Object.keys(config) const props = Object.keys(config)
props.forEach(prop => { props.forEach(prop => {
// Skip special properties that are handled separately if (config[prop] !== undefined) {
if (['construct', 'items', 'classes'].includes(prop)) {
return;
}
// Check if there's a setter for this property
const descriptor = Object.getOwnPropertyDescriptor(Object.getPrototypeOf(this), prop);
if (descriptor && descriptor.set) {
// Use the setter
this[prop] = config[prop]; this[prop] = config[prop];
} else if (prop in this) {
// Property exists, set it directly
this[prop] = config[prop];
} else {
// Set as attribute for unknown properties
this.setAttribute(prop, config[prop]);
} }
}); });
if (config.classes) { if (config.classes) {
this.classList.add(...config.classes); this.classList.add(...config.classes);
} }
@ -365,7 +342,7 @@ class NjetDialog extends Component {
const buttonContainer = document.createElement('div'); const buttonContainer = document.createElement('div');
buttonContainer.style.marginTop = '20px'; buttonContainer.style.marginTop = '20px';
buttonContainer.style.display = 'flex'; buttonContainer.style.display = 'flex';
buttonContainer.style.justifyContent = 'flex-end'; buttonContainer.style.justifyContent = 'flenjet-end';
buttonContainer.style.gap = '10px'; buttonContainer.style.gap = '10px';
if (secondaryButton) { if (secondaryButton) {
const secondary = new NjetButton(secondaryButton); const secondary = new NjetButton(secondaryButton);
@ -395,9 +372,8 @@ class NjetWindow extends Component {
header.textContent = title; header.textContent = title;
this.appendChild(header); this.appendChild(header);
} }
if (this.config.items) {
this.config.items.forEach(item => this.appendChild(item)); this.config.items.forEach(item => this.appendChild(item));
}
} }
show(){ show(){
@ -432,8 +408,7 @@ class NjetGrid extends Component {
} }
} }
Njet.registerComponent('njet-grid', NjetGrid); Njet.registerComponent('njet-grid', NjetGrid);
/*
/* Example usage:
const button = new NjetButton({ const button = new NjetButton({
classes: ['my-button'], classes: ['my-button'],
text: 'Shared', text: 'Shared',
@ -518,7 +493,7 @@ document.body.appendChild(dialog);
*/ */
class NjetComponent extends Component {} class NjetComponent extends Component {}
const njet = Njet const njet = Njet
njet.showDialog = function(args){ njet.showDialog = function(args){
const dialog = new NjetDialog(args) const dialog = new NjetDialog(args)
dialog.show() dialog.show()
@ -570,16 +545,15 @@ njet.showWindow = function(args) {
return w return w
} }
njet.publish = function(event, data) { njet.publish = function(event, data) {
if (this.root && this.root._subscriptions && this.root._subscriptions[event]) { if (this.root._subscriptions[event]) {
this.root._subscriptions[event].forEach(callback => callback(data)) this.root._subscriptions[event].forEach(callback => callback(data))
} }
} }
njet.subscribe = function(event, callback) { njet.subscribe = function(event, callback) {
if (!this.root) return;
if (!this.root._subscriptions[event]) { if (!this.root._subscriptions[event]) {
this.root._subscriptions[event] = [] this.root._subscriptions[event] = []
} }
this.root._subscriptions[event].push(callback) this.root._subscriptions[event].push(callback)
} }
export { Njet, NjetButton, NjetPanel, NjetDialog, NjetGrid, NjetComponent, njet, NjetWindow, eventBus }; export { Njet, NjetButton, NjetPanel, NjetDialog, NjetGrid, NjetComponent, njet, NjetWindow,eventBus };

View File

@ -52,7 +52,7 @@ self.addEventListener("push", async (event) => {
data, data,
}).then(e => console.log("Showing notification", e)).catch(console.error); }).then(e => console.log("Showing notification", e)).catch(console.error);
// event.waitUntil(reg); event.waitUntil(reg);
}); });

View File

@ -86,7 +86,6 @@ export class Socket extends EventHandler {
} }
this.emit("data", data.data); this.emit("data", data.data);
if (data["event"]) { if (data["event"]) {
console.info([data.event,data.data])
this.emit(data.event, data.data); this.emit(data.event, data.data);
} }
} }
@ -100,7 +99,7 @@ export class Socket extends EventHandler {
console.log("Reconnecting"); console.log("Reconnecting");
this.emit("reconnecting"); this.emit("reconnecting");
return this.connect(); return this.connect();
}, 4000); }, 0);
} }
_camelToSnake(str) { _camelToSnake(str) {
@ -143,9 +142,10 @@ export class Socket extends EventHandler {
method, method,
args, args,
}; };
const me = this;
return new Promise((resolve) => { return new Promise((resolve) => {
this.addEventListener(call.callId, (data) => resolve(data), { once: true}); me.addEventListener(call.callId, (data) => resolve(data));
this.sendJson(call); me.sendJson(call);
}); });
} }
} }

View File

@ -1,137 +1,75 @@
import asyncio
import functools import functools
import json import json
from collections import OrderedDict
from snek.system import security from snek.system import security
cache = functools.cache
CACHE_MAX_ITEMS_DEFAULT = 5000 CACHE_MAX_ITEMS_DEFAULT = 5000
class Cache: class Cache:
"""
An asynchronous, thread-safe, in-memory LRU (Least Recently Used) cache.
This implementation uses an OrderedDict for efficient O(1) time complexity
for its core get, set, and delete operations.
"""
def __init__(self, app, max_items=CACHE_MAX_ITEMS_DEFAULT): def __init__(self, app, max_items=CACHE_MAX_ITEMS_DEFAULT):
self.app = app self.app = app
# OrderedDict is the core of the LRU logic. It remembers the order self.cache = {}
# in which items were inserted.
self.cache: OrderedDict = OrderedDict()
self.max_items = max_items self.max_items = max_items
self.stats = {} self.stats = {}
self.enabled = True self.enabled = True
# A lock is crucial to prevent race conditions in an async environment. self.lru = []
self._lock = asyncio.Lock()
self.version = ((42 + 420 + 1984 + 1990 + 10 + 6 + 71 + 3004 + 7245) ^ 1337) + 4 self.version = ((42 + 420 + 1984 + 1990 + 10 + 6 + 71 + 3004 + 7245) ^ 1337) + 4
async def get(self, key): async def get(self, args):
"""
Retrieves an item from the cache. If found, it's marked as recently used.
Returns None if the item is not found or the cache is disabled.
"""
if not self.enabled: if not self.enabled:
return None return None
await self.update_stat(args, "get")
#async with self._lock: try:
if key not in self.cache: self.lru.pop(self.lru.index(args))
await self.update_stat(key, "get") except:
# print("Cache miss!", args, flush=True)
return None return None
self.lru.insert(0, args)
# Mark as recently used by moving it to the end of the OrderedDict. while len(self.lru) > self.max_items:
# This is an O(1) operation. self.cache.pop(self.lru[-1])
self.cache.move_to_end(key) self.lru.pop()
await self.update_stat(key, "get") # print("Cache hit!", args, flush=True)
return self.cache[key] return self.cache[args]
async def set(self, key, value):
"""
Adds or updates an item in the cache and marks it as recently used.
If the cache exceeds its maximum size, the least recently used item is evicted.
"""
if not self.enabled:
return
# comment
#async with self._lock:
is_new = key not in self.cache
# Add or update the item. If it exists, it's moved to the end.
self.cache[key] = value
self.cache.move_to_end(key)
await self.update_stat(key, "set")
# Evict the least recently used item if the cache is full.
# This is an O(1) operation.
if len(self.cache) > self.max_items:
# popitem(last=False) removes and returns the first (oldest) item.
evicted_key, _ = self.cache.popitem(last=False)
# Optionally, you could log the evicted key here.
if is_new:
self.version += 1
async def delete(self, key):
"""Removes an item from the cache if it exists."""
if not self.enabled:
return
async with self._lock:
if key in self.cache:
await self.update_stat(key, "delete")
# Deleting from OrderedDict is an O(1) operation on average.
del self.cache[key]
async def get_stats(self): async def get_stats(self):
"""Returns statistics for all items currently in the cache.""" all_ = []
async with self._lock: for key in self.lru:
stats_list = [] all_.append(
# Items are iterated from oldest to newest. We reverse to show {
# most recently used items first.
for key in reversed(self.cache):
stat_data = self.stats.get(key, {"set": 0, "get": 0, "delete": 0})
value = self.cache[key]
value_record = value.record if hasattr(value, 'record') else value
stats_list.append({
"key": key, "key": key,
"set": stat_data.get("set", 0), "set": self.stats[key]["set"],
"get": stat_data.get("get", 0), "get": self.stats[key]["get"],
"delete": stat_data.get("delete", 0), "delete": self.stats[key]["delete"],
"value": str(self.serialize(value_record)), "value": str(self.serialize(self.cache[key].record)),
}) }
return stats_list )
return all_
async def update_stat(self, key, action):
"""Updates hit/miss/set counts for a given cache key."""
# This method is already called within a locked context,
# but the lock makes it safe if ever called directly.
async with self._lock:
if key not in self.stats:
self.stats[key] = {"set": 0, "get": 0, "delete": 0}
self.stats[key][action] += 1
def serialize(self, obj): def serialize(self, obj):
"""A synchronous helper to create a serializable representation of an object."""
if not isinstance(obj, dict):
return obj
cpy = obj.copy() cpy = obj.copy()
for key_to_remove in ["created_at", "deleted_at", "email", "password"]: cpy.pop("created_at", None)
cpy.pop(key_to_remove, None) cpy.pop("deleted_at", None)
cpy.pop("email", None)
cpy.pop("password", None)
return cpy return cpy
async def update_stat(self, key, action):
if key not in self.stats:
self.stats[key] = {"set": 0, "get": 0, "delete": 0}
self.stats[key][action] = self.stats[key][action] + 1
def json_default(self, value): def json_default(self, value):
"""JSON serializer fallback for objects that are not directly serializable.""" # if hasattr(value, "to_json"):
# return value.to_json()
try: try:
return json.dumps(value.__dict__, default=str) return json.dumps(value.__dict__, default=str)
except: except:
return str(value) return str(value)
async def create_cache_key(self, args, kwargs): async def create_cache_key(self, args, kwargs):
"""Creates a consistent, hashable cache key from function arguments."""
# security.hash is async, so this method remains async.
return await security.hash( return await security.hash(
json.dumps( json.dumps(
{"args": args, "kwargs": kwargs}, {"args": args, "kwargs": kwargs},
@ -140,8 +78,38 @@ class Cache:
) )
) )
async def set(self, args, result):
if not self.enabled:
return
is_new = args not in self.cache
self.cache[args] = result
await self.update_stat(args, "set")
try:
self.lru.pop(self.lru.index(args))
except (ValueError, IndexError):
pass
self.lru.insert(0, args)
while len(self.lru) > self.max_items:
self.cache.pop(self.lru[-1])
self.lru.pop()
if is_new:
self.version += 1
# print(f"Cache store! {len(self.lru)} items. New version:", self.version, flush=True)
async def delete(self, args):
if not self.enabled:
return
await self.update_stat(args, "delete")
if args in self.cache:
try:
self.lru.pop(self.lru.index(args))
except IndexError:
pass
del self.cache[args]
def async_cache(self, func): def async_cache(self, func):
"""Decorator to cache the results of an async function."""
@functools.wraps(func) @functools.wraps(func)
async def wrapper(*args, **kwargs): async def wrapper(*args, **kwargs):
cache_key = await self.create_cache_key(args, kwargs) cache_key = await self.create_cache_key(args, kwargs)
@ -151,14 +119,33 @@ class Cache:
result = await func(*args, **kwargs) result = await func(*args, **kwargs)
await self.set(cache_key, result) await self.set(cache_key, result)
return result return result
return wrapper return wrapper
def async_delete_cache(self, func): def async_delete_cache(self, func):
"""Decorator to invalidate a cache entry before running an async function."""
@functools.wraps(func) @functools.wraps(func)
async def wrapper(*args, **kwargs): async def wrapper(*args, **kwargs):
cache_key = await self.create_cache_key(args, kwargs) cache_key = await self.create_cache_key(args, kwargs)
await self.delete(cache_key) if cache_key in self.cache:
try:
self.lru.pop(self.lru.index(cache_key))
except IndexError:
pass
del self.cache[cache_key]
return await func(*args, **kwargs) return await func(*args, **kwargs)
return wrapper return wrapper
def async_cache(func):
cache = {}
@functools.wraps(func)
async def wrapper(*args):
if args in cache:
return cache[args]
result = await func(*args)
cache[args] = result
return result
return wrapper

View File

@ -1,7 +1,7 @@
DEFAULT_LIMIT = 30 DEFAULT_LIMIT = 30
import asyncio import asyncio
import typing import typing
import time
from snek.system.model import BaseModel from snek.system.model import BaseModel
@ -15,6 +15,8 @@ class BaseMapper:
def __init__(self, app): def __init__(self, app):
self.app = app self.app = app
self.default_limit = self.__class__.default_limit
@property @property
def db(self): def db(self):
return self.app.db return self.app.db
@ -25,7 +27,6 @@ class BaseMapper:
async def run_in_executor(self, func, *args, **kwargs): async def run_in_executor(self, func, *args, **kwargs):
use_semaphore = kwargs.pop("use_semaphore", False) use_semaphore = kwargs.pop("use_semaphore", False)
start_time = time.time()
def _execute(): def _execute():
result = func(*args, **kwargs) result = func(*args, **kwargs)
@ -33,8 +34,10 @@ class BaseMapper:
self.db.commit() self.db.commit()
return result return result
async with self.semaphore: return _execute()
return await asyncio.to_thread(_execute) #async with self.semaphore:
# return await self.loop.run_in_executor(None, _execute)
async def new(self): async def new(self):
return self.model_class(mapper=self, app=self.app) return self.model_class(mapper=self, app=self.app)

View File

@ -79,38 +79,44 @@ emoji.EMOJI_DATA[
] = {"en": ":a1:", "status": 2, "E": 0.6, "alias": [":a1:"]} ] = {"en": ":a1:", "status": 2, "E": 0.6, "alias": [":a1:"]}
ALLOWED_TAGS = list(bleach.sanitizer.ALLOWED_TAGS) + ["picture"] ALLOWED_TAGS = list(bleach.sanitizer.ALLOWED_TAGS) + [
"img",
"video",
"audio",
"source",
"iframe",
"picture",
"span",
]
ALLOWED_ATTRIBUTES = {
**bleach.sanitizer.ALLOWED_ATTRIBUTES,
"img": ["src", "alt", "title", "width", "height"],
"a": ["href", "title", "target", "rel", "referrerpolicy", "class"],
"iframe": [
"src",
"width",
"height",
"frameborder",
"allow",
"allowfullscreen",
"title",
"referrerpolicy",
"style",
],
"video": ["src", "controls", "width", "height"],
"audio": ["src", "controls"],
"source": ["src", "type"],
"span": ["class"],
"picture": [],
}
def sanitize_html(value): def sanitize_html(value):
soup = BeautifulSoup(value, 'html.parser')
for script in soup.find_all('script'):
script.decompose()
#for iframe in soup.find_all('iframe'):
#iframe.decompose()
for tag in soup.find_all(['object', 'embed']):
tag.decompose()
for tag in soup.find_all():
event_attributes = ['onclick', 'onerror', 'onload', 'onmouseover', 'onfocus']
for attr in event_attributes:
if attr in tag.attrs:
del tag[attr]
for img in soup.find_all('img'):
if 'onerror' in img.attrs:
img.decompose()
return soup.prettify()
def sanitize_html2(value):
return bleach.clean( return bleach.clean(
value, value,
protocols=list(bleach.sanitizer.ALLOWED_PROTOCOLS) + ["data"], tags=ALLOWED_TAGS,
attributes=ALLOWED_ATTRIBUTES,
protocols=bleach.sanitizer.ALLOWED_PROTOCOLS + ["data"],
strip=True, strip=True,
) )
@ -126,8 +132,50 @@ def set_link_target_blank(text):
return str(soup) return str(soup)
SAFE_ATTRIBUTES = {
"href",
"src",
"alt",
"title",
"width",
"height",
"style",
"id",
"class",
"rel",
"type",
"name",
"value",
"placeholder",
"aria-hidden",
"aria-label",
"srcset",
"target",
"rel",
"referrerpolicy",
"controls",
"frameborder",
"allow",
"allowfullscreen",
"referrerpolicy",
}
def whitelist_attributes(html): def whitelist_attributes(html):
return sanitize_html(html) soup = BeautifulSoup(html, "html.parser")
for tag in soup.find_all():
if hasattr(tag, "attrs"):
if tag.name in ["script", "form", "input"]:
tag.replace_with("")
continue
attrs = dict(tag.attrs)
for attr in list(attrs):
# Check if attribute is in the safe list or is a data-* attribute
if not (attr in SAFE_ATTRIBUTES or attr.startswith("data-")):
del tag.attrs[attr]
return str(soup)
def embed_youtube(text): def embed_youtube(text):

View File

@ -32,7 +32,7 @@
<link rel="stylesheet" href="/base.css"> <link rel="stylesheet" href="/base.css">
<link rel="icon" type="image/png" href="/image/snek_logo_32x32.png" sizes="32x32"> <link rel="icon" type="image/png" href="/image/snek_logo_32x32.png" sizes="32x32">
<link rel="icon" type="image/png" href="/image/snek_logo_64x64.png" sizes="64x64"> <link rel="icon" type="image/png" href="/image/snek_logo_64x64.png" sizes="64x64">
<script nonce="{{nonce}}" defer src="https://umami.molodetz.nl/script.js" data-website-id="d127c3e4-dc70-4041-a1c8-bcc32c2492ea" defer></script> <script nonce="{{nonce}}" defer src="https://umami.molodetz.nl/script.js" data-website-id="d127c3e4-dc70-4041-a1c8-bcc32c2492ea"></script>
</head> </head>
<body> <body>
<header> <header>

View File

@ -1,97 +0,0 @@
<html>
<head>
<meta name="viewport" content="width=device-width, initial-scale=1.0, interactive-widget=resizes-content">
<link rel="manifest" href="/manifest.json" />
<style>
body{
background-color: black;
color: white;
}
</style>
</head>
<body>
<script type="module">
import { Socket } from "./socket.js";
class ChatWindow extends HTMLElement {
constructor() {
super();
this.component = document.createElement("div");
this.message_list = document.createElement("div")
this.component.appendChild(this.message_list);
this.chat_input = document.createElement("div")
this.component.appendChild(this.chat_input);
this.channelUid = null
this.channelUid = this.getAttribute("channel")
this.inputText = document.createElement("textarea")
this.inputText.addEventListener("keyup",(e)=>{
this.rpc.sendMessage(this.channelUid, e.target.value,false)
if(e.key == "Enter" && !e.shiftKey){
this.rpc.sendMessage(this.channelUid, e.target.value,true)
e.target.value = ""
}else{
//this.rpc.sendMessage(this.channelUid, e.target.value, false)
}
})
this.component.appendChild(this.inputText)
this.ws = new Socket();
this.ws.addEventListener("channel-message", this.handleMessage.bind(this))
this.rpc = this.ws.client
this.ws.addEventListener("update_message_text",this.handleMessage.bind(this))
window.chat = this
}
async handleMessage(data,data2) {
if(data2 && data2.event)
data = data.data
console.info(["update-messagettt",data])
console.warn(data.uid)
if(!data.html)
return
let div = this.message_list.querySelector('[data-uid="' + data.uid + '"]');
console.info(div)
if(!div){
let temp = document.createElement("chat-message");
temp.innerHTML = data.html
this.message_list.appendChild(temp)
//this.message_list.replace(div,temp)
//div.innerHTML = data.html
//this.message_list.appendChild(div);
}else{
// alert("HIERR")
let temp = document.createElement("chat-message");
temp.innerHTML = data.html;
div.innerHTML = temp.innerHTML
console.info("REPLACE")
}
}
async connectedCallback() {
await this.rpc.ping(this.channel)
console.info(this.channelUid)
this.messages = await this.rpc.getMessages(this.channelUid, 0, 0);
this.messages.forEach((msg) => {
const temp = document.createElement("div");
temp.innerHTML = msg.html;
this.message_list.appendChild(temp.firstChild);
})
this.appendChild(this.component);
}
}
customElements.define("chat-window", ChatWindow);
</script>
<chat-window channel="df3e1259-7d1a-4184-b75c-3befd5bf08e1"></chat-window>
</body>
</html>

View File

@ -12,7 +12,7 @@ function showTerm(options){
class StarField { class StarField {
constructor({ count = 50, container = document.body } = {}) { constructor({ count = 200, container = document.body } = {}) {
this.container = container; this.container = container;
this.starCount = count; this.starCount = count;
this.stars = []; this.stars = [];
@ -567,7 +567,7 @@ const count = Array.from(messages).filter(el => el.textContent.trim() === text).
const starField = new StarField({starCount: 50}); const starField = new StarField({starCount: 200});
app.starField = starField; app.starField = starField;
class DemoSequence { class DemoSequence {

View File

@ -10,9 +10,6 @@
<script src="https://cdn.jsdelivr.net/npm/xterm-addon-fit/lib/xterm-addon-fit.js"></script> <script src="https://cdn.jsdelivr.net/npm/xterm-addon-fit/lib/xterm-addon-fit.js"></script>
<div id="terminal" class="hidden"></div> <div id="terminal" class="hidden"></div>
<button id="jump-to-unread-btn" style="display: none; position: absolute; top: 10px; right: 10px; z-index: 1000; padding: 8px 16px; background: #007bff; color: white; border: none; border-radius: 4px; cursor: pointer; box-shadow: 0 2px 8px rgba(0,0,0,0.2);">
Jump to First Unread
</button>
<message-list class="chat-messages"> <message-list class="chat-messages">
{% if not messages %} {% if not messages %}
@ -40,9 +37,10 @@
{% include "dialog_help.html" %} {% include "dialog_help.html" %}
{% include "dialog_online.html" %} {% include "dialog_online.html" %}
<script type="module"> <script type="module">
import {app} from "/app.js"; import { app } from "/app.js";
import { Schedule } from "/schedule.js";
// --- Cache selectors --- // --- Cache selectors ---
const chatInputField = document.querySelector("chat-input"); const chatInputField = document.querySelector("chat-input");
const messagesContainer = document.querySelector(".chat-messages"); const messagesContainer = document.querySelector(".chat-messages");
const chatArea = document.querySelector(".chat-area"); const chatArea = document.querySelector(".chat-area");
@ -74,13 +72,12 @@ function throttle(fn, wait) {
// --- Scroll: load extra messages, throttled --- // --- Scroll: load extra messages, throttled ---
let isLoadingExtra = false; let isLoadingExtra = false;
async function loadExtra() { async function loadExtra() {
const firstMessage = messagesContainer.lastElementChild; const firstMessage = messagesContainer.children[messagesContainer.children.length - 1];
if (isLoadingExtra || !isScrolledPastHalf() || !firstMessage) return; if (isLoadingExtra || !isScrolledPastHalf() || !firstMessage) return;
isLoadingExtra = true; isLoadingExtra = true;
const messages = await app.rpc.getMessages(channelUid, 0, firstMessage.dataset.created_at); const messages = await app.rpc.getMessages(channelUid, 0, firstMessage.dataset.created_at);
if (messages.length) { if (messages.length) {
const frag = document.createDocumentFragment(); const frag = document.createDocumentFragment();
messages.reverse();
messages.forEach(msg => { messages.forEach(msg => {
const temp = document.createElement("div"); const temp = document.createElement("div");
temp.innerHTML = msg.html; temp.innerHTML = msg.html;
@ -101,61 +98,21 @@ setInterval(() => requestIdleCallback(updateTimes), 30000);
// --- Paste & drag/drop uploads --- // --- Paste & drag/drop uploads ---
const textBox = chatInputField.textarea; const textBox = chatInputField.textarea;
function uploadDataTransfer(dt) {
if (dt.items.length > 0) {
const uploadButton = chatInputField.fileUploadGrid;
for (const item of dt.items) {
if (item.kind === "file") {
const file = item.getAsFile();
if (file) {
uploadButton.uploadsStarted++
uploadButton.createTile(file)
} else {
console.error("Failed to get file from DataTransferItem");
}
}
}
}
}
textBox.addEventListener("paste", async (e) => { textBox.addEventListener("paste", async (e) => {
try { try {
console.log("Pasted data:", e.clipboardData); const clipboardItems = await navigator.clipboard.read();
if (e.clipboardData.types.every(v => v.startsWith("text/"))) { const dt = new DataTransfer();
const codeType = e.clipboardData.types.find(t => !t.startsWith('text/plain') && !t.startsWith('text/html') && !t.startsWith('text/rtf')); for (const item of clipboardItems) {
const probablyCode = codeType ||e.clipboardData.types.some(t => !t.startsWith('text/plain')); for (const type of item.types.filter(t => !t.startsWith('text/'))) {
for (const item of e.clipboardData.items) { const blob = await item.getType(type);
if (item.kind === "string" && item.type === "text/plain") { dt.items.add(new File([blob], "image.png", { type }));
e.preventDefault();
item.getAsString(text => {
const value = chatInputField.value;
if (probablyCode) {
let code = text;
const minIndentDepth = code.split('\n').reduce((acc, line) => {
if (!line.trim()) return acc;
const match = line.match(/^(\s*)/);
return match ? Math.min(acc, match[1].length) : acc;
}, 9000);
code = code.split('\n').map(line => line.slice(minIndentDepth)).join('\n')
text = `\`\`\`${codeType?.split('/')?.[1] ?? ''}\n${code}\n\`\`\`\n`;
}
const area = chatInputField.textarea
if(area){
const start = area.selectionStart
if ("\n" !== value[start - 1]) {
text = `\n${text}`;
}
document.execCommand('insertText', false, text);
}
});
break;
} }
} }
} else { if (dt.items.length > 0) {
uploadDataTransfer(e.clipboardData); const uploadButton = chatInputField.uploadButton;
const input = uploadButton.shadowRoot.querySelector('.file-input');
input.files = dt.files;
await uploadButton.uploadFiles();
} }
} catch (error) { } catch (error) {
console.error("Failed to read clipboard contents: ", error); console.error("Failed to read clipboard contents: ", error);
@ -163,7 +120,13 @@ textBox.addEventListener("paste", async (e) => {
}); });
chatArea.addEventListener("drop", async (e) => { chatArea.addEventListener("drop", async (e) => {
e.preventDefault(); e.preventDefault();
uploadDataTransfer(e.dataTransfer); const dt = e.dataTransfer;
if (dt.items.length > 0) {
const uploadButton = chatInputField.uploadButton;
const input = uploadButton.shadowRoot.querySelector('.file-input');
input.files = dt.files;
await uploadButton.uploadFiles();
}
}); });
chatArea.addEventListener("dragover", e => { chatArea.addEventListener("dragover", e => {
e.preventDefault(); e.preventDefault();
@ -175,16 +138,10 @@ chatInputField.textarea.focus();
// --- Reply helper --- // --- Reply helper ---
function replyMessage(message) { function replyMessage(message) {
chatInputField.value = "```markdown\n> " + (message || '').trim().split("\n").join("\n> ") + "\n```\n"; chatInputField.value = "```markdown\n> " + (message || '').split("\n").join("\n> ") + "\n```\n";
chatInputField.textarea.dispatchEvent(new Event('change', { bubbles: true }));
chatInputField.focus(); chatInputField.focus();
} }
messagesContainer.addEventListener("reply", (e) => {
const messageText = e.replyText || e.messageTextTarget.textContent.trim();
replyMessage(messageText);
})
// --- Mention helpers --- // --- Mention helpers ---
function extractMentions(message) { function extractMentions(message) {
return [...new Set(message.match(/@\w+/g) || [])]; return [...new Set(message.match(/@\w+/g) || [])];
@ -258,7 +215,7 @@ document.addEventListener('keydown', function(event) {
keyTimeout = setTimeout(() => { gPressCount = 0; }, 300); keyTimeout = setTimeout(() => { gPressCount = 0; }, 300);
if (gPressCount === 2) { if (gPressCount === 2) {
gPressCount = 0; gPressCount = 0;
messagesContainer.lastElementChild?.scrollIntoView({ block: "end", inline: "nearest" }); messagesContainer.querySelector(".message:first-child")?.scrollIntoView({ block: "end", inline: "nearest" });
loadExtra(); loadExtra();
} }
} }
@ -297,82 +254,13 @@ function updateLayout(doScrollDown) {
function isScrolledPastHalf() { function isScrolledPastHalf() {
let scrollTop = messagesContainer.scrollTop; let scrollTop = messagesContainer.scrollTop;
let scrollableHeight = messagesContainer.scrollHeight - messagesContainer.clientHeight; let scrollableHeight = messagesContainer.scrollHeight - messagesContainer.clientHeight;
return Math.abs(scrollTop) > scrollableHeight / 2; return scrollTop < scrollableHeight / 2;
} }
// --- Initial layout update --- // --- Initial layout update ---
updateLayout(true); updateLayout(true);
// --- Jump to unread functionality ---
const jumpToUnreadBtn = document.getElementById('jump-to-unread-btn');
let firstUnreadMessageUid = null;
async function checkForUnreadMessages() {
try {
const uid = await app.rpc.getFirstUnreadMessageUid(channelUid);
if (uid) {
firstUnreadMessageUid = uid;
const messageElement = messagesContainer.querySelector(`[data-uid="${uid}"]`);
if (messageElement && !messagesContainer.isElementVisible(messageElement)) {
jumpToUnreadBtn.style.display = 'block';
} else {
jumpToUnreadBtn.style.display = 'none';
}
} else {
jumpToUnreadBtn.style.display = 'none';
}
} catch (error) {
console.error('Error checking for unread messages:', error);
}
}
async function jumpToUnread() {
if (!firstUnreadMessageUid) {
await checkForUnreadMessages();
}
if (firstUnreadMessageUid) {
let messageElement = messagesContainer.querySelector(`[data-uid="${firstUnreadMessageUid}"]`);
if (!messageElement) {
const messages = await app.rpc.getMessages(channelUid, 0, null);
const targetMessage = messages.find(m => m.uid === firstUnreadMessageUid);
if (targetMessage) {
const temp = document.createElement("div");
temp.innerHTML = targetMessage.html;
const newMessageElement = temp.firstChild;
messagesContainer.endOfMessages.after(newMessageElement);
messagesContainer.messageMap.set(targetMessage.uid, newMessageElement);
messagesContainer._observer.observe(newMessageElement);
messageElement = newMessageElement;
}
}
if (messageElement) {
messageElement.scrollIntoView({ behavior: 'smooth', block: 'center' });
messageElement.style.animation = 'highlight-fade 2s';
setTimeout(() => {
messageElement.style.animation = '';
}, 2000);
jumpToUnreadBtn.style.display = 'none';
}
}
}
jumpToUnreadBtn.addEventListener('click', jumpToUnread);
checkForUnreadMessages();
const style = document.createElement('style');
style.textContent = `
@keyframes highlight-fade {
0% { background-color: rgba(255, 255, 0, 0.3); }
100% { background-color: transparent; }
}
`;
document.head.appendChild(style);
</script> </script>
{% endblock %} {% endblock %}

View File

@ -71,10 +71,7 @@ class AvatarView(BaseView):
uid = self.request.match_info.get("uid") uid = self.request.match_info.get("uid")
if uid == "unique": if uid == "unique":
uid = str(uuid.uuid4()) uid = str(uuid.uuid4())
avatar = await self.app.get(uid)
if not avatar:
avatar = multiavatar.multiavatar(uid, True, None) avatar = multiavatar.multiavatar(uid, True, None)
await self.app.set(uid, avatar)
response = web.Response(text=avatar, content_type="image/svg+xml") response = web.Response(text=avatar, content_type="image/svg+xml")
response.headers["Cache-Control"] = f"public, max-age={1337*42}" response.headers["Cache-Control"] = f"public, max-age={1337*42}"
return response return response

View File

@ -29,7 +29,7 @@ class ChannelDriveApiView(DriveApiView):
class ChannelAttachmentView(BaseView): class ChannelAttachmentView(BaseView):
login_required=False login_required=True
async def get(self): async def get(self):
relative_path = self.request.match_info.get("relative_url") relative_path = self.request.match_info.get("relative_url")

View File

@ -1,8 +0,0 @@
from snek.system.view import BaseView
class NewView(BaseView):
login_required = True
async def get(self):
return await self.render_template("new.html")

View File

@ -6,11 +6,12 @@
# MIT License: Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions. # MIT License: Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions.
import asyncio import asyncio
import json import json
import logging import logging
import traceback import traceback
import random
from aiohttp import web from aiohttp import web
from snek.system.model import now from snek.system.model import now
@ -175,26 +176,6 @@ class RPCView(BaseView):
messages.append(extended_dict) messages.append(extended_dict)
return messages return messages
async def get_first_unread_message_uid(self, channel_uid):
self._require_login()
channel_member = await self.services.channel_member.get(
channel_uid=channel_uid, user_uid=self.user_uid
)
if not channel_member:
return None
last_read_at = channel_member.get("last_read_at")
if not last_read_at:
return None
async for message in self.services.channel_message.query(
"SELECT uid FROM channel_message WHERE channel_uid=:channel_uid AND created_at > :last_read_at AND deleted_at IS NULL ORDER BY created_at ASC LIMIT 1",
{"channel_uid": channel_uid, "last_read_at": last_read_at}
):
return message["uid"]
return None
async def get_channels(self): async def get_channels(self):
self._require_login() self._require_login()
channels = [] channels = []
@ -546,8 +527,8 @@ class RPCView(BaseView):
try: try:
await self.ws.send_str(json.dumps(obj, default=str)) await self.ws.send_str(json.dumps(obj, default=str))
except Exception as ex: except Exception as ex:
print("THIS IS THE DeAL>",str(ex), flush=True)
await self.services.socket.delete(self.ws) await self.services.socket.delete(self.ws)
await self.ws.close()
async def get_online_users(self, channel_uid): async def get_online_users(self, channel_uid):
self._require_login() self._require_login()
@ -655,7 +636,7 @@ class RPCView(BaseView):
try: try:
await rpc(msg.json()) await rpc(msg.json())
except Exception as ex: except Exception as ex:
print("XXXXXXXXXX Deleting socket", ex, flush=True) print("Deleting socket", ex, flush=True)
logger.exception(ex) logger.exception(ex)
await self.services.socket.delete(ws) await self.services.socket.delete(ws)
break break

View File

@ -55,7 +55,7 @@ class WebView(BaseView):
user_uid=self.session.get("uid"), channel_uid=channel["uid"] user_uid=self.session.get("uid"), channel_uid=channel["uid"]
) )
if not channel_member: if not channel_member:
if not channel["is_private"] and not channel.is_dm: if not channel["is_private"]:
channel_member = await self.app.services.channel_member.create( channel_member = await self.app.services.channel_member.create(
channel_uid=channel["uid"], channel_uid=channel["uid"],
user_uid=self.session.get("uid"), user_uid=self.session.get("uid"),
@ -82,6 +82,7 @@ class WebView(BaseView):
await self.app.services.notification.mark_as_read( await self.app.services.notification.mark_as_read(
self.session.get("uid"), message["uid"] self.session.get("uid"), message["uid"]
) )
print(messages)
name = await channel_member.get_name() name = await channel_member.get_name()
return await self.render_template( return await self.render_template(
"web.html", "web.html",

File diff suppressed because it is too large Load Diff

78
src/snekssh/app.py Normal file
View File

@ -0,0 +1,78 @@
import asyncio
import logging
import os
import asyncssh
asyncssh.set_debug_level(2)
logging.basicConfig(level=logging.DEBUG)
# Configuration for SFTP server
SFTP_ROOT = "." # Directory to serve
USERNAME = "test"
PASSWORD = "woeii"
HOST = "localhost"
PORT = 2225
class MySFTPServer(asyncssh.SFTPServer):
def __init__(self, chan):
super().__init__(chan)
self.root = os.path.abspath(SFTP_ROOT)
async def stat(self, path):
"""Handles 'stat' command from SFTP client"""
full_path = os.path.join(self.root, path.lstrip("/"))
return await super().stat(full_path)
async def open(self, path, flags, attrs):
"""Handles file open requests"""
full_path = os.path.join(self.root, path.lstrip("/"))
return await super().open(full_path, flags, attrs)
async def listdir(self, path):
"""Handles directory listing"""
full_path = os.path.join(self.root, path.lstrip("/"))
return await super().listdir(full_path)
class MySSHServer(asyncssh.SSHServer):
"""Custom SSH server to handle authentication"""
def connection_made(self, conn):
print(f"New connection from {conn.get_extra_info('peername')}")
def connection_lost(self, exc):
print("Client disconnected")
def begin_auth(self, username):
return True # No additional authentication steps
def password_auth_supported(self):
return True # Support password authentication
def validate_password(self, username, password):
print(username, password)
return True
return username == USERNAME and password == PASSWORD
async def start_sftp_server():
os.makedirs(SFTP_ROOT, exist_ok=True) # Ensure the root directory exists
await asyncssh.create_server(
lambda: MySSHServer(),
host=HOST,
port=PORT,
server_host_keys=["ssh_host_key"],
process_factory=MySFTPServer,
)
print(f"SFTP server running on {HOST}:{PORT}")
await asyncio.Future() # Keep running forever
if __name__ == "__main__":
try:
asyncio.run(start_sftp_server())
except (OSError, asyncssh.Error) as e:
print(f"Error starting SFTP server: {e}")

77
src/snekssh/app2.py Normal file
View File

@ -0,0 +1,77 @@
import asyncio
import os
import asyncssh
# SSH Server Configuration
HOST = "0.0.0.0"
PORT = 2225
USERNAME = "user"
PASSWORD = "password"
SHELL = "/bin/sh" # Change to another shell if needed
class CustomSSHServer(asyncssh.SSHServer):
def connection_made(self, conn):
print(f"New connection from {conn.get_extra_info('peername')}")
def connection_lost(self, exc):
print("Client disconnected")
def password_auth_supported(self):
return True
def validate_password(self, username, password):
return username == USERNAME and password == PASSWORD
async def custom_bash_process(process):
"""Spawns a custom bash shell process"""
env = os.environ.copy()
env["TERM"] = "xterm-256color"
# Start the Bash shell
bash_proc = await asyncio.create_subprocess_exec(
SHELL,
"-i",
stdin=asyncio.subprocess.PIPE,
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.PIPE,
env=env,
)
async def read_output():
while True:
data = await bash_proc.stdout.read(1)
if not data:
break
process.stdout.write(data)
async def read_input():
while True:
data = await process.stdin.read(1)
if not data:
break
bash_proc.stdin.write(data)
await asyncio.gather(read_output(), read_input())
async def start_ssh_server():
"""Starts the AsyncSSH server with Bash"""
await asyncssh.create_server(
lambda: CustomSSHServer(),
host=HOST,
port=PORT,
server_host_keys=["ssh_host_key"],
process_factory=custom_bash_process,
)
print(f"SSH server running on {HOST}:{PORT}")
await asyncio.Future() # Keep running
if __name__ == "__main__":
try:
asyncio.run(start_ssh_server())
except (OSError, asyncssh.Error) as e:
print(f"Error starting SSH server: {e}")

74
src/snekssh/app3.py Normal file
View File

@ -0,0 +1,74 @@
#!/usr/bin/env python3.7
#
# Copyright (c) 2013-2024 by Ron Frederick <ronf@timeheart.net> and others.
#
# This program and the accompanying materials are made available under
# the terms of the Eclipse Public License v2.0 which accompanies this
# distribution and is available at:
#
# http://www.eclipse.org/legal/epl-2.0/
#
# This program may also be made available under the following secondary
# licenses when the conditions for such availability set forth in the
# Eclipse Public License v2.0 are satisfied:
#
# GNU General Public License, Version 2.0, or any later versions of
# that license
#
# SPDX-License-Identifier: EPL-2.0 OR GPL-2.0-or-later
#
# Contributors:
# Ron Frederick - initial implementation, API, and documentation
# To run this program, the file ``ssh_host_key`` must exist with an SSH
# private key in it to use as a server host key. An SSH host certificate
# can optionally be provided in the file ``ssh_host_key-cert.pub``.
#
# The file ``ssh_user_ca`` must exist with a cert-authority entry of
# the certificate authority which can sign valid client certificates.
import asyncio
import sys
import asyncssh
async def handle_client(process: asyncssh.SSHServerProcess) -> None:
width, height, pixwidth, pixheight = process.term_size
process.stdout.write(
f"Terminal type: {process.term_type}, " f"size: {width}x{height}"
)
if pixwidth and pixheight:
process.stdout.write(f" ({pixwidth}x{pixheight} pixels)")
process.stdout.write("\nTry resizing your window!\n")
while not process.stdin.at_eof():
try:
await process.stdin.read()
except asyncssh.TerminalSizeChanged as exc:
process.stdout.write(f"New window size: {exc.width}x{exc.height}")
if exc.pixwidth and exc.pixheight:
process.stdout.write(f" ({exc.pixwidth}" f"x{exc.pixheight} pixels)")
process.stdout.write("\n")
async def start_server() -> None:
await asyncssh.listen(
"",
2230,
server_host_keys=["ssh_host_key"],
# authorized_client_keys='ssh_user_ca',
process_factory=handle_client,
)
loop = asyncio.new_event_loop()
try:
loop.run_until_complete(start_server())
except (OSError, asyncssh.Error) as exc:
sys.exit("Error starting server: " + str(exc))
loop.run_forever()

90
src/snekssh/app4.py Normal file
View File

@ -0,0 +1,90 @@
#!/usr/bin/env python3.7
#
# Copyright (c) 2013-2024 by Ron Frederick <ronf@timeheart.net> and others.
#
# This program and the accompanying materials are made available under
# the terms of the Eclipse Public License v2.0 which accompanies this
# distribution and is available at:
#
# http://www.eclipse.org/legal/epl-2.0/
#
# This program may also be made available under the following secondary
# licenses when the conditions for such availability set forth in the
# Eclipse Public License v2.0 are satisfied:
#
# GNU General Public License, Version 2.0, or any later versions of
# that license
#
# SPDX-License-Identifier: EPL-2.0 OR GPL-2.0-or-later
#
# Contributors:
# Ron Frederick - initial implementation, API, and documentation
# To run this program, the file ``ssh_host_key`` must exist with an SSH
# private key in it to use as a server host key. An SSH host certificate
# can optionally be provided in the file ``ssh_host_key-cert.pub``.
import asyncio
import sys
from typing import Optional
import asyncssh
import bcrypt
passwords = {
"guest": b"", # guest account with no password
"user": bcrypt.hashpw(b"user", bcrypt.gensalt()),
}
def handle_client(process: asyncssh.SSHServerProcess) -> None:
username = process.get_extra_info("username")
process.stdout.write(f"Welcome to my SSH server, {username}!\n")
# process.exit(0)
class MySSHServer(asyncssh.SSHServer):
def connection_made(self, conn: asyncssh.SSHServerConnection) -> None:
peername = conn.get_extra_info("peername")[0]
print(f"SSH connection received from {peername}.")
def connection_lost(self, exc: Optional[Exception]) -> None:
if exc:
print("SSH connection error: " + str(exc), file=sys.stderr)
else:
print("SSH connection closed.")
def begin_auth(self, username: str) -> bool:
# If the user's password is the empty string, no auth is required
return passwords.get(username) != b""
def password_auth_supported(self) -> bool:
return True
def validate_password(self, username: str, password: str) -> bool:
if username not in passwords:
return False
pw = passwords[username]
if not password and not pw:
return True
return bcrypt.checkpw(password.encode("utf-8"), pw)
async def start_server() -> None:
await asyncssh.create_server(
MySSHServer,
"",
2231,
server_host_keys=["ssh_host_key"],
process_factory=handle_client,
)
loop = asyncio.new_event_loop()
try:
loop.run_until_complete(start_server())
except (OSError, asyncssh.Error) as exc:
sys.exit("Error starting server: " + str(exc))
loop.run_forever()

112
src/snekssh/app5.py Normal file
View File

@ -0,0 +1,112 @@
#!/usr/bin/env python3.7
#
# Copyright (c) 2016-2024 by Ron Frederick <ronf@timeheart.net> and others.
#
# This program and the accompanying materials are made available under
# the terms of the Eclipse Public License v2.0 which accompanies this
# distribution and is available at:
#
# http://www.eclipse.org/legal/epl-2.0/
#
# This program may also be made available under the following secondary
# licenses when the conditions for such availability set forth in the
# Eclipse Public License v2.0 are satisfied:
#
# GNU General Public License, Version 2.0, or any later versions of
# that license
#
# SPDX-License-Identifier: EPL-2.0 OR GPL-2.0-or-later
#
# Contributors:
# Ron Frederick - initial implementation, API, and documentation
# To run this program, the file ``ssh_host_key`` must exist with an SSH
# private key in it to use as a server host key. An SSH host certificate
# can optionally be provided in the file ``ssh_host_key-cert.pub``.
#
# The file ``ssh_user_ca`` must exist with a cert-authority entry of
# the certificate authority which can sign valid client certificates.
import asyncio
import sys
from typing import List, cast
import asyncssh
class ChatClient:
_clients: List["ChatClient"] = []
def __init__(self, process: asyncssh.SSHServerProcess):
self._process = process
@classmethod
async def handle_client(cls, process: asyncssh.SSHServerProcess):
await cls(process).run()
async def readline(self) -> str:
return cast(str, self._process.stdin.readline())
def write(self, msg: str) -> None:
self._process.stdout.write(msg)
def broadcast(self, msg: str) -> None:
for client in self._clients:
if client != self:
client.write(msg)
def begin_auth(self, username: str) -> bool:
# If the user's password is the empty string, no auth is required
# return False
return True # passwords.get(username) != b''
def password_auth_supported(self) -> bool:
return True
def validate_password(self, username: str, password: str) -> bool:
# if username not in passwords:
# return False
# pw = passwords[username]
# if not password and not pw:
# return True
return True
# return bcrypt.checkpw(password.encode('utf-8'), pw)
async def run(self) -> None:
self.write("Welcome to chat!\n\n")
self.write("Enter your name: ")
name = (await self.readline()).rstrip("\n")
self.write(f"\n{len(self._clients)} other users are connected.\n\n")
self._clients.append(self)
self.broadcast(f"*** {name} has entered chat ***\n")
try:
async for line in self._process.stdin:
self.broadcast(f"{name}: {line}")
except asyncssh.BreakReceived:
pass
self.broadcast(f"*** {name} has left chat ***\n")
self._clients.remove(self)
async def start_server() -> None:
await asyncssh.listen(
"",
2235,
server_host_keys=["ssh_host_key"],
process_factory=ChatClient.handle_client,
)
loop = asyncio.new_event_loop()
try:
loop.run_until_complete(start_server())
except (OSError, asyncssh.Error) as exc:
sys.exit("Error starting server: " + str(exc))
loop.run_forever()