Compare commits

...
3 Commits
17 changed files with 124 additions and 17 deletions

No files matched your search

+1 -1
View File
@@ -9,7 +9,7 @@
### Changed ### Changed
- The side menu now opens over the page when it expands on hover, so open sessions like remote desktop are not resized each time - The side menu now opens over the page when it expands on hover, so open sessions like remote desktop are not resized each time. Turn on Push content when it expands in Settings to get the old behavior back
### Fixed ### Fixed
+1 -1
View File
@@ -15058,7 +15058,7 @@
}, },
"packages/plugin-sdk": { "packages/plugin-sdk": {
"name": "@termix-ssh/plugin-sdk", "name": "@termix-ssh/plugin-sdk",
"version": "1.0.7", "version": "1.0.8",
"license": "Apache-2.0", "license": "Apache-2.0",
"dependencies": { "dependencies": {
"@deadendjs/swagger-jsdoc": "^8.1.2", "@deadendjs/swagger-jsdoc": "^8.1.2",
+6
View File
@@ -1,5 +1,11 @@
# Changelog # Changelog
## 1.0.8
### Added
- `publicRateLimit` on `ctx.http.router()`, for a plugin whose public routes serve a proxied page
## 1.0.7 ## 1.0.7
### Fixed ### Fixed
+3 -2
View File
@@ -1,6 +1,6 @@
# Termix plugin guide # Termix plugin guide
SDK 1.0.7 | Plugin API 1.2.0 SDK 1.0.8 | Plugin API 1.2.0
This guide is for anyone, person or AI coding tool, building a Termix plugin. It ships inside This guide is for anyone, person or AI coding tool, building a Termix plugin. It ships inside
`@termix-ssh/plugin-sdk`, so the copy in your `node_modules` always matches the SDK you have installed. `@termix-ssh/plugin-sdk`, so the copy in your `node_modules` always matches the SDK you have installed.
@@ -283,7 +283,8 @@ router.post("/notes", async (req, res) => {
- The `@openapi` path is the full path, starting with `/plugin-api/<id>/`. `build` turns these into - The `@openapi` path is the full path, starting with `/plugin-api/<id>/`. `build` turns these into
`dist/openapi.json` and the docs site renders them. `dist/openapi.json` and the docs site renders them.
- `ctx.http.router({ public: ["/webhook/:token"], bodyLimit: "20mb", rawBody: false })`. Public routes - `ctx.http.router({ public: ["/webhook/:token"], bodyLimit: "20mb", rawBody: false })`. Public routes
have no actor: check their token yourself and rate limit them. have no actor: check their token yourself. Core allows 120 public requests a minute per address;
`publicRateLimit` raises that for a proxied page that loads its own assets.
- `ctx.http.baseUrl(req)` is the public address, for callback URLs. - `ctx.http.baseUrl(req)` is the public address, for callback URLs.
- `ctx.ws.route("/live", (socket, conn) => ...)`. Long lived sockets should check - `ctx.ws.route("/live", (socket, conn) => ...)`. Long lived sockets should check
`conn.isDataUnlocked()` per message. `conn.isDataUnlocked()` per message.
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "@termix-ssh/plugin-sdk", "name": "@termix-ssh/plugin-sdk",
"version": "1.0.7", "version": "1.0.8",
"description": "The contract between Termix core and a plugin: types, the capability catalog and the manifest schema.", "description": "The contract between Termix core and a plugin: types, the capability catalog and the manifest schema.",
"license": "Apache-2.0", "license": "Apache-2.0",
"repository": { "repository": {
+7
View File
@@ -357,6 +357,13 @@ export interface PluginRouterOptions {
* (multer, a raw body for signature checking, a proxied stream). * (multer, a raw body for signature checking, a proxied stream).
*/ */
rawBody?: boolean; rawBody?: boolean;
/**
* Requests per minute one address may make to this plugin's public routes.
* Defaults to 120, which a proxied page loading its own scripts and images
* can pass on its own. Capped at 10000, and an admin's
* TERMIX_PLUGIN_PUBLIC_RATE_LIMIT wins over it.
*/
publicRateLimit?: number;
} }
/** /**
+14 -4
View File
@@ -42,9 +42,16 @@ const routers = new Map<string, Router>();
* Requests per minute one address may make to one plugin's public routes. * Requests per minute one address may make to one plugin's public routes.
* They run without a login, so they are the cheap way to hammer a plugin. * They run without a login, so they are the cheap way to hammer a plugin.
*/ */
function publicRateLimit(): number { const MAX_PLUGIN_PUBLIC_RATE_LIMIT = 10_000;
/** The admin's limit when set, else the plugin's own, else 120. */
function publicRateLimit(pluginLimit?: number): number {
const parsed = Number(process.env.TERMIX_PLUGIN_PUBLIC_RATE_LIMIT); const parsed = Number(process.env.TERMIX_PLUGIN_PUBLIC_RATE_LIMIT);
return Number.isFinite(parsed) && parsed > 0 ? parsed : 120; if (Number.isFinite(parsed) && parsed > 0) return parsed;
if (Number.isFinite(pluginLimit) && (pluginLimit as number) > 0) {
return Math.min(pluginLimit as number, MAX_PLUGIN_PUBLIC_RATE_LIMIT);
}
return 120;
} }
const PUBLIC_RATE_WINDOW_MS = 60_000; const PUBLIC_RATE_WINDOW_MS = 60_000;
const publicHits = new Map<string, { count: number; resetAt: number }>(); const publicHits = new Map<string, { count: number; resetAt: number }>();
@@ -54,6 +61,7 @@ export function overPublicRateLimit(
pluginId: string, pluginId: string,
ip: string, ip: string,
now = Date.now(), now = Date.now(),
pluginLimit?: number,
): boolean { ): boolean {
if (publicHits.size > 10_000) { if (publicHits.size > 10_000) {
for (const [hitKey, entry] of publicHits) { for (const [hitKey, entry] of publicHits) {
@@ -67,7 +75,7 @@ export function overPublicRateLimit(
return false; return false;
} }
entry.count++; entry.count++;
return entry.count > publicRateLimit(); return entry.count > publicRateLimit(pluginLimit);
} }
/** Test seam. */ /** Test seam. */
@@ -319,7 +327,9 @@ export function createPluginRouter({
outer.use((req: Request, res: Response, next: NextFunction) => { outer.use((req: Request, res: Response, next: NextFunction) => {
if (isPublicRequest(req, publicPaths, inner)) { if (isPublicRequest(req, publicPaths, inner)) {
const ip = req.ip || req.socket?.remoteAddress || "unknown"; const ip = req.ip || req.socket?.remoteAddress || "unknown";
if (overPublicRateLimit(pluginId, ip)) { if (
overPublicRateLimit(pluginId, ip, Date.now(), options?.publicRateLimit)
) {
res.status(429).json({ error: "Too many requests" }); res.status(429).json({ error: "Too many requests" });
return; return;
} }
+13
View File
@@ -461,6 +461,19 @@ describe("public route rate limit", () => {
expect(http.overPublicRateLimit("other", "1.2.3.4", now)).toBe(false); expect(http.overPublicRateLimit("other", "1.2.3.4", now)).toBe(false);
}); });
it("lets a plugin raise its own limit, capped, unless the admin set one", () => {
const hits = (limit: number, count: number) =>
Array.from({ length: count }, () =>
http.overPublicRateLimit("proxy", "ip", 0, limit),
).filter(Boolean).length;
expect(hits(500, 500)).toBe(0);
http.resetPublicRateLimits();
expect(hits(50_000, 10_001)).toBe(1);
http.resetPublicRateLimits();
process.env.TERMIX_PLUGIN_PUBLIC_RATE_LIMIT = "2";
expect(hits(500, 3)).toBe(1);
});
it("starts a new window after a minute", () => { it("starts a new window after a minute", () => {
process.env.TERMIX_PLUGIN_PUBLIC_RATE_LIMIT = "1"; process.env.TERMIX_PLUGIN_PUBLIC_RATE_LIMIT = "1";
expect(http.overPublicRateLimit("demo", "ip", 0)).toBe(false); expect(http.overPublicRateLimit("demo", "ip", 0)).toBe(false);
+3 -1
View File
@@ -20,7 +20,9 @@ export function getDatabaseTransferUrl(
return `${serverUrl.replace(/\/$/, "")}/database/${operation}`; return `${serverUrl.replace(/\/$/, "")}/database/${operation}`;
} }
const development = location.port === "5173"; // Vite moves off 5173 when another dev server holds it.
const development =
process.env.NODE_ENV === "development" || location.port === "5173";
if (development) { if (development) {
return `http://localhost:30001/database/${operation}`; return `http://localhost:30001/database/${operation}`;
+3 -4
View File
@@ -175,11 +175,10 @@ export async function pluginWsUrl(
const protocols = websocketAuthProtocols(token); const protocols = websocketAuthProtocols(token);
// Dev without a configured API host goes through Vite's proxy, which is // Dev without a configured API host goes through Vite's proxy, which is
// reached on the page's own origin rather than the backend port. // reached on the page's own origin rather than the backend port. Vite moves
// to another port when 5173 is taken, so the port says nothing here.
const devProxy = const devProxy =
process.env.NODE_ENV === "development" && process.env.NODE_ENV === "development" && !import.meta.env.VITE_API_HOST;
!import.meta.env.VITE_API_HOST &&
(window.location.port === "3000" || window.location.port === "5173");
const wsProtocol = window.location.protocol === "https:" ? "wss" : "ws"; const wsProtocol = window.location.protocol === "https:" ? "wss" : "ws";
+2
View File
@@ -1674,6 +1674,8 @@
"pinAppRailDesc": "Keep the left sidebar app rail always expanded instead of expanding on hover", "pinAppRailDesc": "Keep the left sidebar app rail always expanded instead of expanding on hover",
"expandAppRailOnHover": "Expand App Rail on Hover", "expandAppRailOnHover": "Expand App Rail on Hover",
"expandAppRailOnHoverDesc": "Allow the left sidebar app rail to expand when the pointer moves over it", "expandAppRailOnHoverDesc": "Allow the left sidebar app rail to expand when the pointer moves over it",
"appRailHoverPushesContent": "Push content when it expands",
"appRailHoverPushesContentDesc": "Make room for the expanded rail instead of opening it over the page. Open sessions, like remote desktop, resize each time",
"showPinAppRailButton": "Show Pin App Rail Button", "showPinAppRailButton": "Show Pin App Rail Button",
"showPinAppRailButtonDesc": "Show a button in the app rail to pin it open. Off by default.", "showPinAppRailButtonDesc": "Show a button in the app rail to pin it open. Off by default.",
"settingsNavigation": "Navigation", "settingsNavigation": "Navigation",
+2
View File
@@ -323,6 +323,8 @@ export const SETTINGS_SEARCH_INDEX: Record<string, string[]> = {
], ],
"appearance": [ "appearance": [
"newUi.sidebar.userProfile.accentColorLabel", "newUi.sidebar.userProfile.accentColorLabel",
"newUi.sidebar.userProfile.appRailHoverPushesContent",
"newUi.sidebar.userProfile.appRailHoverPushesContentDesc",
"newUi.sidebar.userProfile.colorPickerTooltip", "newUi.sidebar.userProfile.colorPickerTooltip",
"newUi.sidebar.userProfile.commandPalette", "newUi.sidebar.userProfile.commandPalette",
"newUi.sidebar.userProfile.commandPaletteDesc", "newUi.sidebar.userProfile.commandPaletteDesc",
+13 -2
View File
@@ -159,6 +159,9 @@ export function AppRail({
const [showPinButton, setShowPinButton] = useState(() => const [showPinButton, setShowPinButton] = useState(() =>
readRailPreference("showPinAppRailButton"), readRailPreference("showPinAppRailButton"),
); );
const [hoverPushes, setHoverPushes] = useState(() =>
readRailPreference("appRailHoverPushesContent"),
);
const [menuPos, setMenuPos] = useState<{ x: number; y: number } | null>(null); const [menuPos, setMenuPos] = useState<{ x: number; y: number } | null>(null);
const [menuTarget, setMenuTarget] = useState<MenuTarget | null>(null); const [menuTarget, setMenuTarget] = useState<MenuTarget | null>(null);
const [managing, setManaging] = useState(false); const [managing, setManaging] = useState(false);
@@ -170,8 +173,11 @@ export function AppRail({
setExpandOnHover(readRailPreference("expandAppRailOnHover")); setExpandOnHover(readRailPreference("expandAppRailOnHover"));
const showPinButtonHandler = () => const showPinButtonHandler = () =>
setShowPinButton(readRailPreference("showPinAppRailButton")); setShowPinButton(readRailPreference("showPinAppRailButton"));
const pushHandler = () =>
setHoverPushes(readRailPreference("appRailHoverPushesContent"));
window.addEventListener("pinAppRailChanged", pinHandler); window.addEventListener("pinAppRailChanged", pinHandler);
window.addEventListener("expandAppRailOnHoverChanged", hoverHandler); window.addEventListener("expandAppRailOnHoverChanged", hoverHandler);
window.addEventListener("appRailHoverPushesContentChanged", pushHandler);
window.addEventListener( window.addEventListener(
"showPinAppRailButtonChanged", "showPinAppRailButtonChanged",
showPinButtonHandler, showPinButtonHandler,
@@ -179,6 +185,10 @@ export function AppRail({
return () => { return () => {
window.removeEventListener("pinAppRailChanged", pinHandler); window.removeEventListener("pinAppRailChanged", pinHandler);
window.removeEventListener("expandAppRailOnHoverChanged", hoverHandler); window.removeEventListener("expandAppRailOnHoverChanged", hoverHandler);
window.removeEventListener(
"appRailHoverPushesContentChanged",
pushHandler,
);
window.removeEventListener( window.removeEventListener(
"showPinAppRailButtonChanged", "showPinAppRailButtonChanged",
showPinButtonHandler, showPinButtonHandler,
@@ -313,8 +323,9 @@ export function AppRail({
}; };
// Expanding on hover draws over the content instead of pushing it, so open // Expanding on hover draws over the content instead of pushing it, so open
// sessions like remote desktop are not resized each time. // sessions like remote desktop are not resized each time, unless the user
const takesSpace = pinned || managing; // asked for the old behavior.
const takesSpace = hoverPushes ? railExpanded : pinned || managing;
return ( return (
<div <div
+18
View File
@@ -553,6 +553,9 @@ export function UserProfilePanel({
const [expandAppRailOnHover, setExpandAppRailOnHover] = useState(() => const [expandAppRailOnHover, setExpandAppRailOnHover] = useState(() =>
readRailPreference("expandAppRailOnHover"), readRailPreference("expandAppRailOnHover"),
); );
const [appRailHoverPushes, setAppRailHoverPushes] = useState(() =>
readRailPreference("appRailHoverPushesContent"),
);
const [showPinAppRailButton, setShowPinAppRailButton] = useState(() => const [showPinAppRailButton, setShowPinAppRailButton] = useState(() =>
readRailPreference("showPinAppRailButton"), readRailPreference("showPinAppRailButton"),
); );
@@ -1684,6 +1687,21 @@ export function UserProfilePanel({
}} }}
/> />
</SettingRow> </SettingRow>
<SettingRow
label={t("newUi.sidebar.userProfile.appRailHoverPushesContent")}
description={t(
"newUi.sidebar.userProfile.appRailHoverPushesContentDesc",
)}
>
<FakeSwitch
checked={appRailHoverPushes}
disabled={!expandAppRailOnHover}
onChange={(v) => {
setAppRailHoverPushes(v);
setRailPreference("appRailHoverPushesContent", v);
}}
/>
</SettingRow>
<SettingRow <SettingRow
label={t("newUi.sidebar.userProfile.showPinAppRailButton")} label={t("newUi.sidebar.userProfile.showPinAppRailButton")}
description={t( description={t(
+11 -1
View File
@@ -1,14 +1,23 @@
import { getUserPreferences, saveUserPreferences } from "@/api/open-tabs-api"; import { getUserPreferences, saveUserPreferences } from "@/api/open-tabs-api";
export type RailPreference = export type RailPreference =
"pinAppRail" | "expandAppRailOnHover" | "showPinAppRailButton"; | "pinAppRail"
| "expandAppRailOnHover"
| "showPinAppRailButton"
| "appRailHoverPushesContent";
const CHANGE_EVENT: Record<RailPreference, string> = { const CHANGE_EVENT: Record<RailPreference, string> = {
pinAppRail: "pinAppRailChanged", pinAppRail: "pinAppRailChanged",
expandAppRailOnHover: "expandAppRailOnHoverChanged", expandAppRailOnHover: "expandAppRailOnHoverChanged",
showPinAppRailButton: "showPinAppRailButtonChanged", showPinAppRailButton: "showPinAppRailButtonChanged",
appRailHoverPushesContent: "appRailHoverPushesContentChanged",
}; };
/** Kept on this device only; the server has no column for it. */
const LOCAL_ONLY: ReadonlySet<RailPreference> = new Set([
"appRailHoverPushesContent",
]);
// pinAppRail and showPinAppRailButton default off, expandAppRailOnHover // pinAppRail and showPinAppRailButton default off, expandAppRailOnHover
// defaults on, so each key needs its own read rather than a shared // defaults on, so each key needs its own read rather than a shared
// === "true" check. // === "true" check.
@@ -28,6 +37,7 @@ export function readRailPreference(key: RailPreference): boolean {
export function setRailPreference(key: RailPreference, value: boolean): void { export function setRailPreference(key: RailPreference, value: boolean): void {
localStorage.setItem(key, String(value)); localStorage.setItem(key, String(value));
window.dispatchEvent(new Event(CHANGE_EVENT[key])); window.dispatchEvent(new Event(CHANGE_EVENT[key]));
if (LOCAL_ONLY.has(key)) return;
void getUserPreferences() void getUserPreferences()
.then((preferences) => { .then((preferences) => {
if (preferences.storageMode === "cloud") { if (preferences.storageMode === "cloud") {
+15
View File
@@ -111,6 +111,21 @@ describe("pluginWsUrl in the browser", () => {
expect(target?.protocols).toEqual(["termix.jwt.ticket-1"]); expect(target?.protocols).toEqual(["termix.jwt.ticket-1"]);
}); });
it("goes through the Vite proxy in dev on any port", async () => {
vi.stubEnv("NODE_ENV", "development");
try {
fetchMock.mockResolvedValueOnce(
new Response(JSON.stringify({ ticket: "ticket-1" })),
);
const target = await pluginWsUrl("ssh-terminal", "/terminal");
expect(target?.url).toBe(
`ws://${window.location.host}/__termix_api/30001/plugin-ws/ssh-terminal/terminal`,
);
} finally {
vi.unstubAllEnvs();
}
});
it("falls back to the cookie alone for a guest", async () => { it("falls back to the cookie alone for a guest", async () => {
fetchMock.mockResolvedValueOnce(new Response("{}", { status: 401 })); fetchMock.mockResolvedValueOnce(new Response("{}", { status: 401 }));
+11
View File
@@ -66,6 +66,17 @@ describe("AppRail", () => {
expect(slot.style.width).toBe(collapsed); expect(slot.style.width).toBe(collapsed);
}); });
it("pushes the content on hover when the user asks for it", () => {
localStorage.setItem("appRailHoverPushesContent", "true");
renderRail();
const rail = screen
.getByLabelText("common.logout")
.closest(".absolute") as HTMLElement;
const slot = rail.parentElement as HTMLElement;
fireEvent.mouseEnter(rail);
expect(slot.style.width).toBe(rail.style.width);
});
it("puts a footer item above the profile with its badge", async () => { it("puts a footer item above the profile with its badge", async () => {
registerRailItem({ registerRailItem({
id: "inbox", id: "inbox",