Compare commits

..

28 Commits
main ... main

Author SHA1 Message Date
c53b930554 Update. 2025-06-12 01:41:05 +02:00
1705575985 Update. 2025-06-12 01:39:22 +02:00
0c0742fce7 Update. 2025-06-12 01:38:45 +02:00
803ad3dfc6 Update. 2025-06-12 00:09:16 +02:00
f965cc4ecd Update. 2025-06-11 20:20:34 +02:00
ad3f46a9ae Update channel. 2025-06-11 17:27:43 +02:00
3ea4918ca2 Update. 2025-06-11 17:13:58 +02:00
26a54848f1 Update. 2025-06-11 01:28:35 +02:00
015337d75c Update signals. 2025-06-11 01:21:33 +02:00
5f06d7e04c Update. 2025-06-11 01:21:21 +02:00
1b2ad3965b Update. 2025-06-10 19:26:52 +02:00
7adb71efe5 Update. 2025-06-10 19:15:31 +02:00
f35fb12856 Update. 2025-06-10 18:25:21 +02:00
0295108e6b Updated paths. 2025-06-10 17:04:50 +02:00
a2fa976065 Update url! 2025-06-10 16:51:40 +02:00
b82db127ae Deployment communication. 2025-06-10 16:44:01 +02:00
f5e807cdbf Perfect sizes! 2025-06-10 16:28:30 +02:00
06f28fd426 Update url! 2025-06-10 15:59:41 +02:00
60afefac96 Perfect sizes! 2025-06-10 15:44:39 +02:00
351fef504c Update. 2025-06-10 15:31:31 +02:00
3a8703d3c6 Update. 2025-06-10 15:31:09 +02:00
1c58165425 Update contianer. 2025-06-10 13:39:35 +02:00
38640d8f75 Update contianer. 2025-06-10 13:37:34 +02:00
bb1f7cdb88 Upate. 2025-06-10 13:18:26 +02:00
4bb1b0997a Update. 2025-06-10 13:04:33 +02:00
f8f1235e60 Toggle container. 2025-06-10 13:00:59 +02:00
fbd72f727a Updated NJET 2025-06-10 11:36:09 +02:00
62eb1060d9 Update. 2025-06-10 11:28:54 +02:00
23 changed files with 1586 additions and 81 deletions

View File

@ -10,3 +10,9 @@ RUN chmod +x r
RUN mv r /usr/local/bin/r
RUN echo 'root:root' | chpasswd
COPY ./terminal /opt/bootstrap
COPY ./terminal /opt/snek
RUN cp -r /root /opt/bootstrap/root
COPY ./terminal/entry /usr/local/bin/entry

View File

@ -3,6 +3,7 @@ import logging
import pathlib
import ssl
import uuid
import signal
from datetime import datetime
from snek import snode
@ -40,9 +41,10 @@ from snek.system.template import (
)
from snek.view.about import AboutHTMLView, AboutMDView
from snek.view.avatar import AvatarView
from snek.view.channel import ChannelAttachmentView, ChannelView
from snek.view.channel import ChannelAttachmentView,ChannelAttachmentUploadView, ChannelView
from snek.view.docs import DocsHTMLView, DocsMDView
from snek.view.drive import DriveApiView, DriveView
from snek.view.channel import ChannelDriveApiView
from snek.view.index import IndexView
from snek.view.login import LoginView
from snek.view.logout import LogoutView
@ -210,6 +212,10 @@ class Application(BaseApplication):
# app.loop = asyncio.get_running_loop()
app.executor = ThreadPoolExecutor(max_workers=200)
app.loop.set_default_executor(self.executor)
#for sig in (signal.SIGINT, signal.SIGTERM):
#app.loop.add_signal_handler(
# sig, lambda: asyncio.create_task(self.services.container.shutdown())
#)
async def create_task(self, task):
await self.tasks.put(task)
@ -281,6 +287,12 @@ class Application(BaseApplication):
self.router.add_view(
"/channel/{channel_uid}/attachment.bin", ChannelAttachmentView
)
self.router.add_view(
"/channel/{channel_uid}/drive.json", ChannelDriveApiView
)
self.router.add_view(
"/channel/{channel_uid}/attachment.sock", ChannelAttachmentUploadView
)
self.router.add_view(
"/channel/attachment/{relative_url:.*}", ChannelAttachmentView
)

View File

@ -12,6 +12,9 @@ class ContainerService(BaseService):
self.compose = ComposeFileManager(self.compose_path,self.container_event_handler)
self.event_listeners = {}
async def shutdown(self):
return await self.compose.shutdown()
async def add_event_listener(self, name, event,event_handler):
if not name in self.event_listeners:
self.event_listeners[name] = {}
@ -19,6 +22,13 @@ class ContainerService(BaseService):
self.event_listeners[name][event] = []
self.event_listeners[name][event].append(event_handler)
async def remove_event_listener(self, name, event, event_handler):
if name in self.event_listeners and event in self.event_listeners[name]:
try:
self.event_listeners[name][event].remove(event_handler)
except ValueError:
pass
async def container_event_handler(self, name, event, data):
event_listeners = self.event_listeners.get(name, {})
handlers = event_listeners.get(event, [])
@ -84,7 +94,7 @@ class ContainerService(BaseService):
"./"
+ str(await self.services.channel.get_home_folder(channel_uid))
+ ":"
+ "/home/ubuntu"
+ "/root"
],
)
return await self.compose.get_instance(name)

View File

@ -10,7 +10,7 @@
import { Schedule } from "./schedule.js";
import { EventHandler } from "./event-handler.js";
import { Socket } from "./socket.js";
import { Njet } from "./njet.js";
export class RESTClient {
debug = false;

View File

@ -1,6 +1,7 @@
import { app } from "../app.js";
class ChatInputComponent extends HTMLElement {
import { app } from "./app.js";
import { NjetComponent } from "./njet.js";
import { FileUploadGrid } from "./file-upload-grid.js";
class ChatInputComponent extends NjetComponent {
autoCompletions = {
"example 1": () => {
},
@ -161,6 +162,11 @@ class ChatInputComponent extends HTMLElement {
this.classList.add("chat-input");
this.fileUploadGrid = new FileUploadGrid();
this.fileUploadGrid.setAttribute("channel", this.channelUid);
this.fileUploadGrid.style.display = "none";
this.appendChild(this.fileUploadGrid);
this.textarea.setAttribute("placeholder", "Type a message...");
this.textarea.setAttribute("rows", "2");
@ -174,7 +180,18 @@ class ChatInputComponent extends HTMLElement {
this.uploadButton.addEventListener("uploaded", (e) => {
this.dispatchEvent(new CustomEvent("uploaded", e));
});
this.uploadButton.addEventListener("click", (e) => {
// e.preventDefault();
// this.fileUploadGrid.openFileDialog()
})
this.subscribe("file-uploading", (e) => {
this.fileUploadGrid.style.display = "block";
this.uploadButton.style.display = "none";
this.textarea.style.display = "none";
})
this.appendChild(this.uploadButton);
this.textarea.addEventListener("blur", () => {
this.updateFromInput("");

View File

@ -25,7 +25,7 @@ export class Container extends EventHandler{
});
}
this.refresh()
//this.refresh()
this.terminal.focus()
}
refresh(){
@ -36,7 +36,10 @@ export class Container extends EventHandler{
this._container.classList.toggle("hidden")
this.refresh()
}
fit(){
this._fitAddon.fit();
}
constructor(channelUid,log){
super()
this.terminal = new Terminal({ cursorBlink: true ,theme: {
@ -58,7 +61,15 @@ export class Container extends EventHandler{
this.terminal.write(new TextDecoder().decode(fixedData));
})
this.ws = new WebSocket(`/container/sock/${channelUid}.json`)
const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
const host = window.location.host;
const wsUrl = `${protocol}//${host}/container/sock/${this.channelUid}.json`;
this.ws = new WebSocket(wsUrl);
this.ws.binaryType = "arraybuffer"; // Support binary data
this.ws.onmessage = (event) => {
this.emit("stdout", event.data)

View File

@ -1,13 +1,32 @@
class DumbTerminal extends HTMLElement {
constructor() {
super();
this.attachShadow({ mode: "open" });
}
import { NjetComponent } from "/njet.js";
class WebTerminal extends NjetComponent {
commands = {
"clear": () => {
this.outputEl.innerHTML = "";
return "";
},
"help": () => {
return "Available commands: help, clear, date";
},
"date": () => {
return new Date().toString();
}
};
help = {
"clear": "Clear the terminal",
"help": "Show available commands",
"date": "Show the current date"
};
connectedCallback() {
this.shadowRoot.innerHTML = `
this.innerHTML = `
<style>
:host {
.web-terminal {
--terminal-bg: #111;
--terminal-fg: #0f0;
--terminal-accent: #0ff;
@ -23,7 +42,7 @@ class DumbTerminal extends HTMLElement {
max-height: 500px;
}
.output {
.web-terminal-output {
white-space: pre-wrap;
margin-bottom: 1em;
}
@ -37,7 +56,7 @@ class DumbTerminal extends HTMLElement {
margin-right: 0.5em;
}
input {
.web-terminal-input {
background: transparent;
border: none;
color: var(--terminal-fg);
@ -57,21 +76,22 @@ class DumbTerminal extends HTMLElement {
padding: 2rem;
}
</style>
<div class="output" id="output"></div>
<div class="web-terminal">
<div class="web-terminal-output" id="output"></div>
<div class="input-line">
<span class="prompt">&gt;</span>
<input type="text" id="input" autocomplete="off" autofocus />
<span class="prompt">&gt;</span>
<input type="text" class="web-terminal-input" id="input" autocomplete="off" autofocus />
</div>
</div>
`;
this.outputEl = this.shadowRoot.getElementById("output");
this.inputEl = this.shadowRoot.getElementById("input");
this.container = this.querySelector(".web-terminal");
this.outputEl = this.querySelector(".web-terminal-output");
this.inputEl = this.querySelector(".web-terminal-input");
this.history = [];
this.historyIndex = -1;
this.inputEl.addEventListener("keydown", (e) => this.onKeyDown(e));
this.inputEl.addEventListener("keyup", (e) => this.onKeyDown(e));
}
onKeyDown(event) {
@ -116,17 +136,89 @@ class DumbTerminal extends HTMLElement {
this.outputEl.scrollTop = this.outputEl.scrollHeight;
}
parseCommand(input) {
const args = [];
let current = '';
let inSingleQuote = false;
let inDoubleQuote = false;
let inTemplate = false; // For ${...}
let templateBuffer = '';
for (let i = 0; i < input.length; i++) {
const char = input[i];
// Handle template expressions
if (!inSingleQuote && !inDoubleQuote && char === '$' && input[i + 1] === '{') {
inTemplate = true;
i++; // Skip '{'
templateBuffer = '${';
continue;
}
if (inTemplate) {
templateBuffer += char;
if (char === '}') {
// End of template
args.push(eval(templateBuffer));
inTemplate = false;
templateBuffer = '';
}
continue; // Continue to next char
}
// Handle quotes
if (char === "'" && !inDoubleQuote) {
inSingleQuote = !inSingleQuote;
continue; // Skip quote
}
if (char === '"' && !inSingleQuote) {
inDoubleQuote = !inDoubleQuote;
continue; // Skip quote
}
// Handle spaces outside quotes and templates
if (char === ' ' && !inSingleQuote && !inDoubleQuote) {
if (current.length > 0) {
args.push(current);
current = '';
}
} else {
current += char;
}
}
if (current.length > 0) {
args.push(current);
}
return args;
}
executeScript(script) {
const scriptElement = document.createElement("script");
scriptElement.textContent = script;
this.appendChild(scriptElement);
}
mockExecute(command) {
switch (command.trim()) {
case "help":
return "Available commands: help, clear, date";
case "date":
return new Date().toString();
case "clear":
this.outputEl.innerHTML = "";
return "";
default:
return `Unknown command: ${command}`;
let args;
try {
args = this.parseCommand(command);
} catch (e) {
return e.toString();
}
console.info({ef:this})
console.info({af:this})
console.info({gf:args})
let cmdName = args.shift();
let commandHandler = this.commands[cmdName];
if (commandHandler) {
return commandHandler.apply(this, args);
}
try {
// Try to eval as JS
return this.executeScript(command);
} catch (e) {
return e.toString();
}
}
@ -134,7 +226,7 @@ class DumbTerminal extends HTMLElement {
* Static method to create a modal dialog with the terminal
* @returns {HTMLDialogElement}
*/
static createModal() {
show() {
const dialog = document.createElement("dialog");
dialog.innerHTML = `
<div class="dialog-backdrop">
@ -147,4 +239,12 @@ class DumbTerminal extends HTMLElement {
}
}
window.showTerm = function (options) {
const term = new WebTerminal(options);
term.show();
return term;
}
customElements.define("web-terminal", WebTerminal);
export { WebTerminal };

View File

@ -1,4 +1,6 @@
/* A <file-browser> custom element that talks to /api/files */
import { NjetComponent } from "/njet.js";
class FileBrowser extends HTMLElement {
constructor() {
super();
@ -6,9 +8,11 @@ class FileBrowser extends HTMLElement {
this.path = ""; // current virtual path ("" = ROOT)
this.offset = 0; // pagination offset
this.limit = 40; // items per request
this.url = '/drive.json'
}
connectedCallback() {
this.url = this.getAttribute("url") || this.url;
this.path = this.getAttribute("path") || "";
this.renderShell();
this.load();
@ -58,7 +62,7 @@ class FileBrowser extends HTMLElement {
// ---------- Networking ----------------------------------------------
async load() {
const r = await fetch(
`/drive.json?path=${encodeURIComponent(this.path)}&offset=${this.offset}&limit=${this.limit}`,
this.url + `?path=${encodeURIComponent(this.path)}&offset=${this.offset}&limit=${this.limit}`
);
if (!r.ok) {
console.error(await r.text());

View File

@ -0,0 +1,59 @@
.fug-root {
background: #181818;
color: white;
font-family: sans-serif;
/*min-height: 100vh;*/
}
.fug-grid {
display: grid;
grid-template-columns: repeat(6, 150px);
gap: 20px;
margin: 30px;
}
.fug-tile {
background: #111;
border: 2px solid #ff6600;
border-radius: 12px;
display: flex;
flex-direction: column;
align-items: center;
padding: 16px 8px 8px 8px;
box-shadow: 0 0 4px #333;
position: relative;
}
.fug-icon {
width: 48px; height: 48px;
margin-bottom: 8px;
object-fit: cover;
background: #222;
border-radius: 5px;
}
.fug-filename {
word-break: break-all;
margin-bottom: 8px;
font-size: 0.95em;
text-align: center;
}
.fug-progressbar {
width: 100%;
height: 7px;
background: #333;
border-radius: 4px;
overflow: hidden;
margin-top: auto;
}
.fug-progress {
height: 100%;
background: #ffb200;
transition: width 0.15s;
width: 0%;
}
.fug-tile.fug-done {
border-color: #0f0;
}
.fug-fileinput {
margin: 24px;
font-size: 1.1em;
display: none;
}

View File

@ -0,0 +1,175 @@
import { NjetComponent, NjetDialog } from '/njet.js';
const FUG_ICONS = {
file: 'data:image/svg+xml;utf8,<svg xmlns="http://www.w3.org/2000/svg" width="48" height="48"><rect x="8" y="8" width="32" height="40" rx="5" fill="white" stroke="%234af" stroke-width="2"/></svg>'
};
class FileUploadGrid extends NjetComponent {
constructor() {
super();
this._grid = null;
this._fileInput = null;
this.channelUid = null ;
this.uploadsDone = 0;
this.uploadsStarted = 0;
}
openFileDialog() {
if(this.isBusy){
const dialog = new NjetDialog({
title: 'Upload in progress',
content: 'Please wait for the current upload to complete.',
primaryButton: {
text: 'OK',
handler: function () {
this.closest('njet-dialog').remove();
}
}
})
dialog.open();
return false
}
if (this._fileInput) {
this._fileInput.value = ''; // Allow same file selection twice
this._fileInput.click();
}
return true;
}
render() {
// Root wrapper for styling
this.classList.add('fug-root');
// Clear previous (if rerendered)
this.innerHTML = '';
// Input
this._fileInput = document.createElement('input');
this._fileInput.type = 'file';
this._fileInput.className = 'fug-fileinput';
this._fileInput.multiple = true;
// Grid
this._grid = document.createElement('div');
this._grid.className = 'fug-grid';
this.appendChild(this._fileInput);
this.appendChild(this._grid);
this.uploadsDone = 0;
this.uploadsStarted = 0;
}
reset(){
this.uploadsDone = 0;
this.uploadsStarted = 0;
this._grid.innerHTML = '';
}
get isBusy(){
return this.uploadsDone != this.uploadsStarted;
}
handleFiles(files) {
this.reset()
this.uploadsDone = 0;
this.uploadsStarted = files.length;
[...files].forEach(file => this.createTile(file));
}
connectedCallback() {
this.channelUid = this.getAttribute('channel');
this.render();
this._fileInput.addEventListener('change', e => this.handleFiles(e.target.files));
}
createTile(file) {
const tile = document.createElement('div');
tile.className = 'fug-tile';
// Icon/Thumbnail
let icon;
if (file.type.startsWith('image/')) {
icon = document.createElement('img');
icon.className = 'fug-icon';
icon.src = URL.createObjectURL(file);
icon.onload = () => URL.revokeObjectURL(icon.src);
} else if (file.type.startsWith('video/')) {
icon = document.createElement('video');
icon.className = 'fug-icon';
icon.src = URL.createObjectURL(file);
icon.muted = true;
icon.playsInline = true;
icon.controls = false;
icon.preload = 'metadata';
icon.onloadeddata = () => {
icon.currentTime = 0.5;
URL.revokeObjectURL(icon.src);
};
} else {
icon = document.createElement('img');
icon.className = 'fug-icon';
icon.src = FUG_ICONS.file;
}
// Filename
const name = document.createElement('div');
name.className = 'fug-filename';
name.textContent = file.name;
// Progressbar
const progressbar = document.createElement('div');
progressbar.className = 'fug-progressbar';
const progress = document.createElement('div');
progress.className = 'fug-progress';
progressbar.appendChild(progress);
// Tile composition
tile.appendChild(icon);
tile.appendChild(name);
tile.appendChild(progressbar);
this._grid.appendChild(tile);
// Start upload
this.startUpload(file, tile, progress);
}
startUpload(file, tile, progress) {
this.publish('file-uploading', {file: file, tile: tile, progress: progress});
const protocol = location.protocol === "https:" ? "wss://" : "ws://";
const ws = new WebSocket(`${protocol}${location.host}/channel/${this.channelUid}/attachment.sock`);
ws.binaryType = 'arraybuffer';
let sent = 0;
ws.onopen = async () => {
ws.send(JSON.stringify({type: 'start', filename: file.name}));
const chunkSize = 64*1024;
while (sent < file.size) {
const chunk = file.slice(sent, sent + chunkSize);
ws.send(await chunk.arrayBuffer());
sent += chunkSize;
}
ws.send(JSON.stringify({type: 'end', filename: file.name}));
};
ws.onmessage = (event) => {
const data = JSON.parse(event.data);
if (data.type === 'progress') {
const pct = Math.min(100, Math.round(100 * data.bytes / file.size));
progress.style.width = pct + '%';
this.publish('file-uploading', {file: file, tile: tile, progress: progress});
} else if (data.type === 'done') {
this.uploadsDone += 1;
this.publish('file-uploaded', {file: file, tile: tile, progress: progress});
progress.style.width = '100%';
tile.classList.add('fug-done');
ws.close();
this.reset()
}
};
}
}
customElements.define('file-upload-grid', FileUploadGrid);
export { FileUploadGrid };

18
src/snek/static/njet.css Normal file
View File

@ -0,0 +1,18 @@
/* CSS version */
.njet-dialog {
position: fixed;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
padding: 20px;
min-width: 33px;
}
.njet-window {
position: fixed;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
padding: 20px;
min-width: 33px;
}

508
src/snek/static/njet.js Normal file
View File

@ -0,0 +1,508 @@
class RestClient {
constructor({ baseURL = '', headers = {} } = {}) {
this.baseURL = baseURL;
this.headers = { ...headers };
// Interceptor containers
this.interceptors = {
request: {
handlers: [],
use: (fn) => {
this.interceptors.request.handlers.push(fn);
}
},
response: {
handlers: [],
use: (successFn, errorFn) => {
this.interceptors.response.handlers.push({ success: successFn, error: errorFn });
}
}
};
}
// Core request method
request(config) {
// Merge defaults
const cfg = {
method: 'GET',
url: '',
data: null,
headers: {},
...config
};
cfg.headers = { ...this.headers, ...cfg.headers };
// Apply request interceptors
let chain = Promise.resolve(cfg);
this.interceptors.request.handlers.forEach((fn) => {
chain = chain.then(fn);
});
// Perform fetch
chain = chain.then((c) => {
const url = this.baseURL + c.url;
const options = { method: c.method, headers: c.headers };
if (c.data != null) {
options.body = JSON.stringify(c.data);
if (!options.headers['Content-Type']) {
options.headers['Content-Type'] = 'application/json';
}
}
return fetch(url, options).then(async (response) => {
const text = await response.text();
let data;
try {
data = JSON.parse(text);
} catch (e) {
data = text;
}
const result = {
data,
status: response.status,
statusText: response.statusText,
headers: RestClient._parseHeaders(response.headers),
config: c,
request: response
};
if (!response.ok) {
return Promise.reject(result);
}
return result;
});
});
// Apply response interceptors
this.interceptors.response.handlers.forEach(({ success, error }) => {
chain = chain.then(success, error);
});
return chain;
}
// Helper methods for HTTP verbs
get(url, config) {
return this.request({ ...config, method: 'GET', url });
}
delete(url, config) {
return this.request({ ...config, method: 'DELETE', url });
}
head(url, config) {
return this.request({ ...config, method: 'HEAD', url });
}
options(url, config) {
return this.request({ ...config, method: 'OPTIONS', url });
}
post(url, data, config) {
return this.request({ ...config, method: 'POST', url, data });
}
put(url, data, config) {
return this.request({ ...config, method: 'PUT', url, data });
}
patch(url, data, config) {
return this.request({ ...config, method: 'PATCH', url, data });
}
// Utility to parse Fetch headers into an object
static _parseHeaders(headers) {
const result = {};
for (const [key, value] of headers.entries()) {
result[key] = value;
}
return result;
}
}
class Njet extends HTMLElement {
static _root = null
static showDialog = null
get isRoot() {
return Njet._root === this
}
get root() {
return Njet._root
}
get rest() {
return Njet._root._rest
}
attach(element) {
this._attachedTo = element
this._attachedTo.addEventListener("resize", () => {
this.updatePosition()
})
}
updatePosition(){
if(this._attachedTo)
{
this.style.width = `${this._attachedTo.offsetWidth}`
this.style.height = `${this._attachedTo.offsetHeight}`
this.style.left = `${this._attachedTo.offsetLeft}`
this.style.top = `${this._attachedTo.offsetTop}`
this.style.position = 'fixed'
}
}
_subscriptions = {}
_elements = []
_rest = null
_attachedTo = null
match(args) {
return Object.entries(args).every(([key, value]) => this[key] === value);
}
set(key, value) {
this.dataset[key] = value
}
get(key, defaultValue) {
if (this.dataset[key]) {
return this.dataset[key]
}
if (defaultValue === undefined) {
return
}
this.dataset[key] = defaultValue
return this.dataset[key]
}
showDialog(args){
// const dialog = this.createComponent('njet-dialog', args)
// dialog.show()
// return dialog()
}
find(args) {
for (let element of this.root._elements) {
if (element.match(args)) {
return element
}
}
return null
}
findAll(args) {
return this.root._elements.filter(element => element.match(args))
}
subscribe(event, callback) {
if (!this.root._subscriptions[event]) {
this.root._subscriptions[event] = []
}
this.root._subscriptions[event].push(callback)
}
publish(event, data) {
if (this.root._subscriptions[event]) {
this.root._subscriptions[event].forEach(callback => callback(data))
}
}
static registerComponent(name, component) {
customElements.define(name, component);
}
constructor(config = {}) {
super();
if (!Njet._root) {
Njet._root = this
Njet._rest = new RestClient({ baseURL: config.baseURL || null })
}
this.root._elements.push(this)
this.classList.add('njet');
this.config = config;
this.render.call(this);
this.initProps(config);
if (typeof this.construct === 'function')
this.construct.call(this)
}
initProps(config) {
const props = Object.keys(config)
props.forEach(prop => {
if (config[prop] !== undefined) {
this[prop] = config[prop];
}
});
if (config.classes) {
this.classList.add(...config.classes);
}
}
duplicate() {
const duplicatedConfig = { ...this.config };
if (duplicatedConfig.items) {
duplicatedConfig.items = duplicatedConfig.items.map(item => {
return typeof item.duplicate === 'function' ? item.duplicate() : item;
});
}
return new this.constructor(duplicatedConfig);
}
set width(val) {
this.style.width = typeof val === 'number' ? `${val}px` : val;
}
get width() { return this.style.width; }
set height(val) {
this.style.height = typeof val === 'number' ? `${val}px` : val;
}
get height() { return this.style.height; }
set left(val) {
this.style.position = 'absolute';
this.style.left = typeof val === 'number' ? `${val}px` : val;
}
get left() { return this.style.left; }
set top(val) {
this.style.position = 'absolute';
this.style.top = typeof val === 'number' ? `${val}px` : val;
}
get top() { return this.style.top; }
set opacity(val) { this.style.opacity = val; }
get opacity() { return this.style.opacity; }
set disabled(val) { this.toggleAttribute('disabled', !!val); }
get disabled() { return this.hasAttribute('disabled'); }
set visible(val) { this.style.display = val ? '' : 'none'; }
get visible() { return this.style.display !== 'none'; }
render() {}
}
class Component extends Njet {}
class NjetPanel extends Component {
render() {
this.innerHTML = '';
const { title, items = [] } = this.config;
this.style.border = '1px solid #ccc';
this.style.padding = '10px';
if (title) {
const header = document.createElement('h3');
header.textContent = title;
this.appendChild(header);
}
items.forEach(item => this.appendChild(item));
}
}
Njet.registerComponent('njet-panel', NjetPanel);
class NjetButton extends Component {
render() {
this.classList.add('njet-button');
this.innerHTML = '';
const button = document.createElement('button');
button.textContent = this.config.text || 'Button';
if (typeof this.config.handler === 'function') {
button.addEventListener('click', (event) => this.config.handler.call(this));
}
const observer = new MutationObserver(() => {
button.disabled = this.disabled;
});
observer.observe(this, { attributes: true, attributeFilter: ['disabled'] });
button.disabled = this.disabled;
this.appendChild(button);
}
}
Njet.registerComponent('njet-button', NjetButton);
class NjetDialog extends Component {
render() {
this.innerHTML = '';
const { title, content, primaryButton, secondaryButton } = this.config;
this.classList.add('njet-dialog');
if (title) {
const header = document.createElement('h2');
header.textContent = title;
this.appendChild(header);
}
if (content) {
const body = document.createElement('div');
body.innerHTML = content;
this.appendChild(body);
}
const buttonContainer = document.createElement('div');
buttonContainer.style.marginTop = '20px';
buttonContainer.style.display = 'flex';
buttonContainer.style.justifyContent = 'flenjet-end';
buttonContainer.style.gap = '10px';
if (secondaryButton) {
const secondary = new NjetButton(secondaryButton);
buttonContainer.appendChild(secondary);
}
if (primaryButton) {
const primary = new NjetButton(primaryButton);
buttonContainer.appendChild(primary);
}
this.appendChild(buttonContainer);
}
show(){
document.body.appendChild(this)
}
}
Njet.registerComponent('njet-dialog', NjetDialog);
class NjetWindow extends Component {
render() {
this.innerHTML = '';
const { title, content, primaryButton, secondaryButton } = this.config;
this.classList.add('njet-window');
if (title) {
const header = document.createElement('h2');
header.textContent = title;
this.appendChild(header);
}
this.config.items.forEach(item => this.appendChild(item));
}
show(){
document.body.appendChild(this)
}
}
Njet.registerComponent('njet-window', NjetWindow);
class NjetGrid extends Component {
render() {
this.classList.add('njet-grid');
this.innerHTML = '';
const table = document.createElement('table');
table.style.width = '100%';
table.style.borderCollapse = 'collapse';
const data = this.config.data || [];
data.forEach(row => {
const tr = document.createElement('tr');
Object.values(row).forEach(cell => {
const td = document.createElement('td');
td.textContent = cell;
td.style.border = '1px solid #ddd';
td.style.padding = '4px';
tr.appendChild(td);
});
table.appendChild(tr);
});
this.appendChild(table);
}
}
Njet.registerComponent('njet-grid', NjetGrid);
/*
const button = new NjetButton({
classes: ['my-button'],
text: 'Shared',
tag: 'shared',
width: 120,
height: 30,
handler() {
this.root.findAll({ tag: 'shared' }).forEach(e => {
e.disabled = !e.disabled;
});
}
});
const button2 = new NjetButton({
classes: ['my-button'],
text: 'Single',
iValue: 0,
width: 120,
height: 30,
handler() {
this.iValue++;
const panel = this.closest('njet-panel');
if (panel) {
const h3 = panel.querySelector('h3');
if (h3) h3.textContent = `Click ${this.iValue}`;
}
this.publish("btn2Click", `Click ${this.iValue}`);
}
});
const grid = new NjetGrid({
data: [
{ id: 1, name: 'John' },
{ id: 2, name: 'Jane' }
],
width: '100%',
visible: true
});
const panel = new NjetPanel({
title: 'My Panel',
items: [button, grid, button2],
left: 50,
top: 50,
construct: function () {
this.subscribe('btn2Click', (data) => {
this._title = data
});
}
});
document.body.appendChild(panel);
const panelClone = panel.duplicate();
const panell = panelClone.duplicate();
panell.left = 120;
panell.width = 300;
panelClone.appendChild(panell);
panelClone.left = 300;
panelClone.top = 50;
document.body.appendChild(panelClone);
const dialog = new NjetDialog({
title: 'Confirm Action',
content: 'Are you sure you want to continue?',
primaryButton: {
text: 'Yes',
handler: function () {
alert('Confirmed');
this.closest('njet-dialog').remove();
}
},
secondaryButton: {
text: 'Cancel',
handler: function () {
this.closest('njet-dialog').remove();
}
}
});
document.body.appendChild(dialog);
*/
class NjetComponent extends Component {}
const njet = Njet;
njet.showDialog = function(args){
const dialog = new NjetDialog(args)
dialog.show()
return dialog
}
njet.showWindow = function(args) {
const w = new NjetWindow(args)
w.show()
return w
}
window.njet = njet
export { Njet, NjetButton, NjetPanel, NjetDialog, NjetGrid, NjetComponent, njet, NjetWindow };

View File

@ -17,6 +17,19 @@ class ComposeFileManager:
self.running_instances = {}
self.event_handler = event_handler
async def shutdown(self):
print("Stopping all sessions")
tasks = []
for name in self.list_instances():
proc = self.running_instances.get(name)
if not proc:
continue
if proc['proc'].returncode == None:
print("Stopping",name)
tasks.append(asyncio.create_task(proc['proc'].stop()))
print("Stopped",name,"gracefully")
return tasks
def _load(self):
try:
with open(self.compose_path) as f:
@ -62,7 +75,13 @@ class ComposeFileManager:
volumes=None,
):
service = {
"image": image,
"build": {
"context": ".",
"dockerfile": "DockerfileUbuntu",
},
"user":"root",
"working_dir": "/home/retoor/projects/snek",
"environment": [f"SNEK_UID={name}"],
}
service["command"] = command or "tail -f /dev/null"
if cpus or memory:
@ -153,15 +172,21 @@ class ComposeFileManager:
)
if name in self.running_instances:
del self.running_instances[name]
stdout, stderr = await proc.communicate()
if proc.returncode != 0:
raise RuntimeError(f"Failed to stop {name}: {stderr.decode()}")
stdout, stderr = None, None
while proc.returncode is None:
try:
stdout, stderr = await proc.communicate()
except Exception as ex:
stdout = b''
stderr = str(ex).encode()
await self.event_handler(name,"stdout",stdout or stderr)
print("Return code", proc.returncode)
if stdout:
await self.event_handler(name,"stdout",stdout)
return stdout.decode(errors="ignore")
await self.event_handler(name,"stdout",stdout or b'')
return stdout and stdout.decode(errors="ignore") or ""
await self.event_handler(name,"stdout",stderr)
return stderr.decode(errors="ignore")
await self.event_handler(name,"stdout",stderr or b'')
return stderr and stderr.decode(errors="ignore") or ""
async def start(self, name):
"""Asynchronously start a container by doing 'docker compose up -d [name]'."""
@ -176,17 +201,27 @@ class ComposeFileManager:
proc = await asyncio.create_subprocess_exec(
"docker", "compose", "-f", self.compose_path, "up", name, "-d",
stdin=asyncio.subprocess.PIPE,
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.PIPE,
stdin=asyncio.subprocess.PIPE,
)
stdout,stderr = await proc.communicate()
if proc.returncode != 0:
print(f"Failed to start {name}: {stderr.decode(errors='ignore')}")
return False
stdout, stderr = None, None
while proc.returncode is None:
try:
stdout, stderr = await proc.communicate()
except Exception as ex:
stdout = b''
stderr = str(ex).encode()
if stdout:
print(stdout.decode(errors="ignore"))
if stderr:
print(stderr.decode(errors="ignore"))
await self.event_handler(name,"stdout",stdout or stderr)
print("Return code", proc.returncode)
master, slave = pty.openpty()
proc = await asyncio.create_subprocess_exec(
"docker", "compose", "-f", self.compose_path, "exec", name, "/bin/bash",
"docker", "compose", "-f", self.compose_path, "exec", name, "/usr/local/bin/entry",
stdin=slave,
stdout=slave,
stderr=slave,

View File

@ -6,7 +6,12 @@
<link rel="manifest" href="/manifest.json" />
<title>Snek</title>
<style>{{highlight_styles}}</style>
<link rel="stylesheet" href="/file-upload-grid.css">
<script src="/njet.js" type="module"></script>
<script src="/file-upload-grid.js" type="module"></script>
<script src="/polyfills/Promise.withResolvers.js" type="module"></script>
<script src="/push.js" type="module"></script>
<script src="/fancy-button.js" type="module"></script>
<script src="/upload-button.js" type="module"></script>
@ -18,6 +23,7 @@
<script src="/message-list.js" type="module"></script>
<script src="/chat-input.js" type="module"></script>
<script src="/container.js" type="module"></script>
<script src="/dumb-term.js" type="module"></script>
<link rel="stylesheet" href="/sandbox.css">
<link rel="stylesheet" href="/user-list.css">
<link rel="stylesheet" href="/fa640.min.css">
@ -55,7 +61,59 @@
import { app } from "/app.js";
import { Container } from "/container.js";
let prevKey = null;
document.addEventListener("keydown", () => {
function toggleDevelopmentMode(){
const headerElement = document.querySelector('header');
headerElement.style.display = 'none';
const sidebarElement = document.querySelector('aside');
sidebarElement.style.position = 'fixed'
sidebarElement.style.width= '10%'
sidebarElement.style.top = '0px'
sidebarElement.style.left='0px'
sidebarElement.style.height='100%'
// sidebarElement.style.display = 'none';
const containerElement = document.querySelector('#terminal');
containerElement.style.position = 'fixed';
containerElement.style.width = '50%';
containerElement.style.height = '100%';
containerElement.style.left = '10%';
containerElement.style.top = '0px';
//window.container.resizeToPercentage(document.body,'50%','100%')
const messagesElement = document.querySelector('.chat-area');
messagesElement.style.position = 'fixed';
messagesElement.style.width = '40%';
messagesElement.style.height = '100%';
messagesElement.style.left = '60%';
messagesElement.style.top = '0px';
const messageList = document.querySelector('message-list')
messageList.scrollToBottom()
window.container.fit()
app.starField.renderWord("H4x0r 1337")
}
{% if channel %}
app.channelUid = '{{ channel.uid.value }}'
window.getContainer = async function (){
if(window.c) return window.c
window.c = new Container(app.channelUid,false)
window.c.start()
window.t = document.querySelector("#terminal")
window.t.classList.toggle("hidden")
window.c.render(window.t)
return window.c
}
{% endif %}
document.addEventListener("keydown", async() => {
if(prevKey == "Escape"){
document.querySelector("chat-input").querySelector("textarea").value = "";
}
@ -63,25 +121,32 @@
app.starField.shuffleAll(5000)
}
if(event.key == "." && event.ctrlKey){
event.preventDefault();
if(!window.c)
{
window.getContainer()
}
if(window.c){
toggleDevelopmentMode()
//window.container.terminal.element.hidden = !window.container.terminal.element.hidden
}
}
prevKey = event.key
if(event.key == "," && event.ctrlKey){
event.preventDefault();
let textAreas = document.querySelectorAll("textarea")
textAreas.forEach(textArea => {
if(document.activeElement != textArea)
setTimeout(() => textArea.focus(), 300)
setTimeout(() => textArea.focus(), 10)
})
}
})
{% if channel %}
app.channelUid = '{{ channel.uid.value }}'
window.getContainer = function(){
return new Container(app.channelUid,false)
}
{% endif %}
let installPrompt = null
window.addEventListener("beforeinstallprompt", (e) => {
//e.preventDefault();

View File

@ -0,0 +1,40 @@
<script type="module">
import { njet } from "/njet.js"
app.channel = {}
app.channel.remove = function(channelUid){
const dialog = new njet.showDialog({
title: 'Upload in progress',
content: 'Please wait for the current upload to complete.',
primaryButton: {
text: 'OK',
handler: function () {
dialog.remove();
}
}
})
}
app.channel._fileManager = null
app.channel.toggleDrive = function(channelUid){
if(app.channel._fileManager){
app.channel._fileManager.remove()
app.channel._fileManager = null
document.querySelector('message-list').style.display = 'block'
return
}
app.channel._fileManager = document.createElement("file-manager")
app.channel._fileManager.style.padding = '10px'
app.channel._fileManager.setAttribute("url",`/channel/${app.channelUid}/drive.json`)
document.querySelector(".chat-area").insertBefore(app.channel._fileManager,document.querySelector(".chat-area").firstChild)
document.querySelector("message-list").style.display = 'none'
}
</script>

View File

@ -2,6 +2,14 @@
<div id="star-popup" class="star-popup"></div>
<script type="module">
import { app } from "/app.js";
import {WebTerminal} from "/dumb-term.js";
function showTerm(options){
const term = new WebTerminal(options);
term.show();
}
class StarField {
constructor({ count = 200, container = document.body } = {}) {
@ -12,6 +20,250 @@ class StarField {
this.originalColor = getComputedStyle(document.documentElement).getPropertyValue("--star-color").trim();
this._createStars();
window.stars = this.positionMap;
this.patchConsole()
this.starSignal = (() => {
const positionMap = this.positionMap;
const areaMap = {
Center: 'Center',
Corner: 'Corner',
Edge: 'Edge',
North: 'North',
South: 'South',
East: 'East',
West: 'West',
All: Object.keys(positionMap),
};
const applyEffect = (stars, effectFn, duration = 2000) => {
const active = new Set();
stars.forEach((star, i) => {
const { cleanup } = effectFn(star, i);
active.add(cleanup);
});
setTimeout(() => {
active.forEach(fn => fn && fn());
}, duration);
};
const effects = {
pulseColor: (color, size = 5) => (star) => {
const orig = {
color: star.style.backgroundColor,
width: star.style.width,
height: star.style.height,
shadow: star.style.boxShadow
};
star.style.backgroundColor = color;
star.style.width = `${size}px`;
star.style.height = `${size}px`;
star.style.boxShadow = `0 0 ${size * 2}px ${color}`;
return {
cleanup: () => {
star.style.backgroundColor = orig.color;
star.style.width = orig.width;
star.style.height = orig.height;
star.style.boxShadow = orig.shadow;
}
};
},
flicker: (color) => (star) => {
let visible = true;
const orig = {
color: star.style.backgroundColor,
shadow: star.style.boxShadow
};
const flick = setInterval(() => {
star.style.backgroundColor = visible ? color : '';
star.style.boxShadow = visible ? `0 0 4px ${color}` : '';
visible = !visible;
}, 200);
return {
cleanup: () => {
clearInterval(flick);
star.style.backgroundColor = orig.color;
star.style.boxShadow = orig.shadow;
}
};
},
shimmer: (colors) => (star, i) => {
const orig = star.style.backgroundColor;
let idx = 0;
const interval = setInterval(() => {
star.style.backgroundColor = colors[idx % colors.length];
idx++;
}, 100 + (i % 5) * 10);
return {
cleanup: () => {
clearInterval(interval);
star.style.backgroundColor = orig;
}
};
}
};
const trigger = (signalName) => {
switch (signalName) {
// --- Notifications ---
case 'notif.newDM':
applyEffect(positionMap[areaMap.Corner], effects.pulseColor('white'));
break;
case 'notif.groupMsg':
applyEffect(positionMap[areaMap.Edge], effects.flicker('blue'), 1000);
break;
case 'notif.mention':
applyEffect(positionMap[areaMap.Center], effects.pulseColor('magenta'));
break;
case 'notif.react':
applyEffect(positionMap[areaMap.South], effects.pulseColor('gold'), 1200);
break;
case 'notif.typing':
applyEffect(positionMap[areaMap.West], effects.pulseColor('teal'), 1500);
break;
case 'notif.msgSent':
applyEffect(positionMap[areaMap.East], effects.pulseColor('lightgreen'), 800);
break;
case 'notif.msgRead':
applyEffect(positionMap[areaMap.North], effects.pulseColor('lightblue'), 1000);
break;
// --- User Status ---
case 'status.online':
applyEffect(positionMap[areaMap.Center], effects.pulseColor('green'), 3000);
break;
case 'status.offline':
applyEffect(positionMap[areaMap.Center], effects.pulseColor('gray'), 3000);
break;
case 'status.idle':
applyEffect(positionMap[areaMap.Center], effects.pulseColor('orange'), 3000);
break;
case 'status.typing':
applyEffect(positionMap[areaMap.Center], effects.flicker('lightblue'), 2000);
break;
case 'status.join':
applyEffect(positionMap[areaMap.Edge], effects.pulseColor('cyan'), 1500);
break;
case 'status.leave':
applyEffect(positionMap[areaMap.Edge], effects.pulseColor('black'), 1500);
break;
// --- System ---
case 'sys.broadcast':
areaMap.All.forEach(area => {
applyEffect(positionMap[area], effects.pulseColor('gold'), 1000);
});
break;
case 'sys.warning':
areaMap.All.forEach(area => {
applyEffect(positionMap[area], effects.flicker('red'), 2000);
});
break;
case 'sys.down':
areaMap.All.forEach(area => {
applyEffect(positionMap[area], effects.flicker('darkred'), 3000);
});
break;
case 'sys.update':
applyEffect(positionMap[areaMap.North], effects.shimmer(['cyan', 'violet', 'lime']), 2500);
break;
case 'sys.ping':
areaMap.All.forEach(area => {
applyEffect(positionMap[area], effects.pulseColor('white'), 500);
});
break;
// --- Activity & Reactions ---
case 'activity.surge':
applyEffect(positionMap[areaMap.Center], effects.shimmer(['white', 'blue', 'purple']), 1500);
break;
case 'reaction.burst':
applyEffect(positionMap[areaMap.South], effects.pulseColor('hotpink'), 1000);
break;
case 'reaction.lol':
applyEffect(positionMap[areaMap.South], effects.flicker('yellow'), 800);
break;
case 'reaction.likes':
applyEffect(positionMap[areaMap.Center], effects.pulseColor('deeppink'), 1000);
break;
// --- Focus & Movement ---
case 'focus.zone':
applyEffect(positionMap[areaMap.Center], effects.pulseColor('white'), 1500);
break;
case 'focus.enter':
applyEffect(positionMap[areaMap.West], effects.pulseColor('cyan'), 1000);
break;
case 'focus.exit':
applyEffect(positionMap[areaMap.East], effects.pulseColor('gray'), 1000);
break;
case 'focus.idle':
applyEffect(positionMap[areaMap.North], effects.flicker('gray'), 2000);
break;
default:
console.warn('Unknown star signal:', signalName);
}
this.trigger = this.starSignal.trigger;
};
return { trigger };
})();
}
showLogEvent(...args) {
this.showNotify(...args)
}
mapLogLevel(level) {
switch (level) {
case 'info':
return { category: 'Info', color: 'skyblue' };
case 'warn':
return { category: 'Warning', color: 'orange' };
case 'error':
return { category: 'Error', color: 'crimson' };
case 'log':
default:
return { category: 'Log', color: 'gold' };
}
}
patchConsole(){
const originalConsole = {
info: console.info,
warn: console.warn,
error: console.error,
log: console.log,
};
const me = this
// Override console methods
console.info = function(...args) {
me.showLogEvent('info', args);
me.createConsoleStar('info', args);
originalConsole.info.apply(console, args);
};
console.warn = function(...args) {
me.showLogEvent('warn', args);
me.createConsoleStar('warn', args);
originalConsole.warn.apply(console, args);
};
console.error = function(...args) {
me.showLogEvent('error', args);
me.createConsoleStar('error', args);
originalConsole.error.apply(console, args);
};
console.log = function(...args) {
me.showLogEvent('log', args);
me.createConsoleStar('log', args);
originalConsole.log.apply(console, args);
};
}
_getStarPosition(star) {
@ -48,6 +300,21 @@ class StarField {
star.shuffle = () => this._randomizeStar(star);
star.position = this._getStarPosition(star);
}
createConsoleStar(level, args) {
const { category, color } = this.mapLogLevel(level);
const message = args.map(a => (typeof a === 'object' ? JSON.stringify(a) : String(a))).join(' ');
this.addSpecialStar({
title: category.toUpperCase(),
content: message,
category,
color,
onClick: () => {
this.showNotify(level, [`[${category}]`, message]);
},
});
}
_placeStar(star) {
const pos = star.position;

View File

@ -3,7 +3,7 @@
{% block header_text %}<h2 style="color:#fff">{{ name }}</h2>{% endblock %}
{% block main %}
{% include "channel.html" %}
<section class="chat-area">
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/xterm/css/xterm.css">
<script src="https://cdn.jsdelivr.net/npm/xterm/lib/xterm.js"></script>
@ -47,7 +47,6 @@ const messagesContainer = document.querySelector(".chat-messages");
const chatArea = document.querySelector(".chat-area");
const channelUid = "{{ channel.uid.value }}";
const username = "{{ user.username.value }}";
let container = null
// --- Command completions ---
chatInputField.autoCompletions = {
"/online": showOnline,
@ -55,14 +54,7 @@ chatInputField.autoCompletions = {
"/live": () => { chatInputField.liveType = !chatInputField.liveType; },
"/help": showHelp,
"/container": async() =>{
if(container == null){
window.c = await window.getContainer()
await window.c.start()
window.t = document.querySelector("#terminal")
window.t.classList.toggle("hidden")
window.c.render(window.t)
}
//containerDialog.openWithStatus()
containerDialog.openWithStatus()
}
};

View File

@ -13,6 +13,17 @@ from snek.system.view import BaseView
register_heif_opener()
from snek.view.drive import DriveApiView
class ChannelDriveApiView(DriveApiView):
async def get_target(self):
target = await self.services.channel.get_home_folder(self.request.match_info.get("channel_uid"))
target.mkdir(parents=True, exist_ok=True)
return target
async def get_download_url(self, rel):
return f"/channel/{self.request.match_info.get('channel_uid')}/drive/{urllib.parse.quote(rel)}"
class ChannelAttachmentView(BaseView):
async def get(self):
relative_path = self.request.match_info.get("relative_url")
@ -146,6 +157,56 @@ class ChannelAttachmentView(BaseView):
)
class ChannelAttachmentUploadView(BaseView):
async def get(self):
channel_uid = self.request.match_info.get("channel_uid")
user_uid = self.request.session.get("uid")
channel_member = await self.services.channel_member.get(
user_uid=user_uid, channel_uid=channel_uid, deleted_at=None, is_banned=False
)
if not channel_member:
return web.HTTPNotFound()
ws = web.WebSocketResponse()
await ws.prepare(self.request)
file = None
filename = None
msg = await ws.receive()
if not msg.type == web.WSMsgType.TEXT:
return web.HTTPBadRequest()
data = msg.json()
if not data.get('type') == 'start':
return web.HTTPBadRequest()
filename = data['filename']
attachment = await self.services.channel_attachment.create_file(
channel_uid=channel_uid, name=filename, user_uid=user_uid
)
pathlib.Path(attachment["path"]).parent.mkdir(parents=True, exist_ok=True)
async with aiofiles.open(attachment["path"], "wb") as f:
async for msg in ws:
if msg.type == web.WSMsgType.BINARY:
if file is not None:
await file.write(msg.data)
await ws.send_json({"type": "progress", "filename": filename, "bytes": file.tell()})
elif msg.type == web.WSMsgType.TEXT:
data = msg.json()
if data.get('type') == 'end':
await ws.send_json({"type": "done", "filename": filename})
elif msg.type == web.WSMsgType.ERROR:
break
return ws
class ChannelView(BaseView):
async def get(self):
channel_name = self.request.match_info.get("channel")

View File

@ -35,7 +35,7 @@ class ContainerView(BaseView):
if not container['status'] == 'running':
resp = await self.services.container.start(channel_uid)
await ws.send_str(str(resp))
await ws.send_bytes(b'Container is starting\n\n')
container_name = await self.services.container.get_container_name(channel_uid)

View File

@ -28,8 +28,19 @@ class DriveView(BaseView):
class DriveApiView(BaseView):
async def get(self):
login_required = True
async def get_target(self):
target = await self.services.user.get_home_folder(self.session.get("uid"))
return target
async def get_download_url(self, rel):
return f"/drive/{urllib.parse.quote(rel)}"
async def get(self):
target = await self.get_target()
original_target = target
rel = self.request.query.get("path", "")
offset = int(self.request.query.get("offset", 0))
limit = int(self.request.query.get("limit", 20))
@ -40,6 +51,9 @@ class DriveApiView(BaseView):
if not target.exists():
return web.json_response({"error": "Not found"}, status=404)
if not target.relative_to(original_target):
return web.json_response({"error": "Not found"}, status=404)
if target.is_dir():
entries = []
for p in sorted(
@ -79,7 +93,7 @@ class DriveApiView(BaseView):
}
)
url = self.request.url.with_path(f"/drive/{urllib.parse.quote(rel)}")
url = self.request.url.with_path(await self.get_download_url(rel))
return web.json_response(
{
"name": target.name,

View File

@ -17,7 +17,7 @@ from aiohttp import web
from snek.system.model import now
from snek.system.profiler import Profiler
from snek.system.view import BaseView
import time
logger = logging.getLogger(__name__)
@ -202,7 +202,43 @@ class RPCView(BaseView):
}
)
return channels
async def write_container(self, channel_uid, content,timeout=3):
self._require_login()
channel_member = await self.services.channel_member.get(
channel_uid=channel_uid, user_uid=self.user_uid
)
if not channel_member:
raise Exception("Not allowed")
container_name = await self.services.container.get_container_name(channel_uid)
class SessionCall:
def __init__(self, app,channel_uid_uid, container_name):
self.app = app
self.channel_uid = channel_uid
self.container_name = container_name
self.time_last_output = time.time()
self.output = b''
async def stdout_event_handler(self, data):
self.time_last_output = time.time()
self.output += data
return True
async def communicate(self,content, timeout=3):
await self.app.services.container.add_event_listener(self.container_name, "stdout", self.stdout_event_handler)
await self.app.services.container.write_stdin(self.channel_uid, content)
while time.time() - self.time_last_output < timeout:
await asyncio.sleep(0.1)
await self.app.services.container.remove_event_listener(self.container_name, "stdout", self.stdout_event_handler)
return self.output
sc = SessionCall(self, channel_uid,container_name)
return (await sc.communicate(content)).decode("utf-8","ignore")
async def get_container(self, channel_uid):
self._require_login()
channel_member = await self.services.channel_member.get(
@ -217,7 +253,7 @@ class RPCView(BaseView):
"name": await self.services.container.get_container_name(channel_uid),
"cpus": container["deploy"]["resources"]["limits"]["cpus"],
"memory": container["deploy"]["resources"]["limits"]["memory"],
"image": container["image"],
"image": "ubuntu:latest",
"volumes": [],
"status": container["status"]
}
@ -389,9 +425,12 @@ class RPCView(BaseView):
)
async def _send_json(self, obj):
if not self.ws.closed:
try:
await self.ws.send_str(json.dumps(obj, default=str))
except Exception as ex:
await self.services.socket.delete(self.ws)
await self.ws.close()
async def get_online_users(self, channel_uid):
self._require_login()
@ -496,8 +535,7 @@ class RPCView(BaseView):
async for msg in ws:
if msg.type == web.WSMsgType.TEXT:
try:
async with Profiler():
await rpc(msg.json())
await rpc(msg.json())
except Exception as ex:
print("Deleting socket", ex, flush=True)
logger.exception(ex)

47
terminal/.welcome.txt Normal file
View File

@ -0,0 +1,47 @@
πŸ‘‹ Welcome to your custom Ubuntu development environment!
This container is pre-configured with a rich set of tools to help you hit the ground running:
πŸ”§ Development Libraries & Tools:
Full support for building and debugging C/C++ and Python code
Libraries for SSL, readline, SQLite, zlib, bz2, ffi, lzma, and JSON-C
Valgrind for memory debugging and profiling
🐍 Python:
Python 3 with pip and virtual environment support (venv)
πŸ¦€ Rust:
Rust installed with the nightly toolchain via rustup
πŸ›  Command-line Essentials:
vim, tmux, htop, git, curl, wget, xterm, ack, and more
πŸ’¬ Networking & Communication:
irssi for IRC and lynx for browsing from the terminal
πŸ“¦ Custom Tool Installed:
Latest version of AI vibe coding tool r is installed from the
retoor.molodetz.nl repository.
To get started, open a new terminal and type "r" to launch the tool.
Configure ~/.rcontext.txt to describe it's behavior. It can be a friend
or a whatever you like.
πŸ” Credentials:
Default root password is set to: root (please change it if needed)
πŸ—‚ Custom Terminal Files:
Files from ./terminal/ have been copied to your home directory (/root/)
Enjoy hacking in your personalized command-line workspace!

26
terminal/entry Executable file
View File

@ -0,0 +1,26 @@
#!/usr/bin/env python3
import os
import sys
import pathlib
import os
os.chdir("/root")
sys.path.insert(0, os.path.join(os.path.dirname(__file__), ".."))
sys.path.insert(0, "/root/bin")
sys.path.insert(0, "/root/bin/local/bin")
if not pathlib.Path(".welcome.txt").exists():
os.system("python3 -m venv --prompt '' .venv")
os.system("cp -r /opt/bootstrap/root/.* /root")
os.system("cp /opt/bootstrap/.welcome.txt /root/.welcome.txt")
pathlib.Path(".bashrc").write_text(pathlib.Path(".bashrc").read_text() + "\n" + "source .venv/bin/activate")
os.environ["SNEK"] = "1"
if pathlib.Path(".welcome.txt").exists():
with open(".welcome.txt") as f:
print(f.read())
os.system("bash")