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
- 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
+1 -1
View File
@@ -15058,7 +15058,7 @@
},
"packages/plugin-sdk": {
"name": "@termix-ssh/plugin-sdk",
"version": "1.0.7",
"version": "1.0.8",
"license": "Apache-2.0",
"dependencies": {
"@deadendjs/swagger-jsdoc": "^8.1.2",
+6
View File
@@ -1,5 +1,11 @@
# Changelog
## 1.0.8
### Added
- `publicRateLimit` on `ctx.http.router()`, for a plugin whose public routes serve a proxied page
## 1.0.7
### Fixed
+3 -2
View File
@@ -1,6 +1,6 @@
# 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
`@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
`dist/openapi.json` and the docs site renders them.
- `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.ws.route("/live", (socket, conn) => ...)`. Long lived sockets should check
`conn.isDataUnlocked()` per message.
+1 -1
View File
@@ -1,6 +1,6 @@
{
"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.",
"license": "Apache-2.0",
"repository": {
+7
View File
@@ -357,6 +357,13 @@ export interface PluginRouterOptions {
* (multer, a raw body for signature checking, a proxied stream).
*/
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.
* 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);
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 publicHits = new Map<string, { count: number; resetAt: number }>();
@@ -54,6 +61,7 @@ export function overPublicRateLimit(
pluginId: string,
ip: string,
now = Date.now(),
pluginLimit?: number,
): boolean {
if (publicHits.size > 10_000) {
for (const [hitKey, entry] of publicHits) {
@@ -67,7 +75,7 @@ export function overPublicRateLimit(
return false;
}
entry.count++;
return entry.count > publicRateLimit();
return entry.count > publicRateLimit(pluginLimit);
}
/** Test seam. */
@@ -319,7 +327,9 @@ export function createPluginRouter({
outer.use((req: Request, res: Response, next: NextFunction) => {
if (isPublicRequest(req, publicPaths, inner)) {
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" });
return;
}
+13
View File
@@ -461,6 +461,19 @@ describe("public route rate limit", () => {
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", () => {
process.env.TERMIX_PLUGIN_PUBLIC_RATE_LIMIT = "1";
expect(http.overPublicRateLimit("demo", "ip", 0)).toBe(false);
+3 -1
View File
@@ -20,7 +20,9 @@ export function getDatabaseTransferUrl(
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) {
return `http://localhost:30001/database/${operation}`;
+3 -4
View File
@@ -175,11 +175,10 @@ export async function pluginWsUrl(
const protocols = websocketAuthProtocols(token);
// 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 =
process.env.NODE_ENV === "development" &&
!import.meta.env.VITE_API_HOST &&
(window.location.port === "3000" || window.location.port === "5173");
process.env.NODE_ENV === "development" && !import.meta.env.VITE_API_HOST;
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",
"expandAppRailOnHover": "Expand App Rail on Hover",
"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",
"showPinAppRailButtonDesc": "Show a button in the app rail to pin it open. Off by default.",
"settingsNavigation": "Navigation",
+2
View File
@@ -323,6 +323,8 @@ export const SETTINGS_SEARCH_INDEX: Record<string, string[]> = {
],
"appearance": [
"newUi.sidebar.userProfile.accentColorLabel",
"newUi.sidebar.userProfile.appRailHoverPushesContent",
"newUi.sidebar.userProfile.appRailHoverPushesContentDesc",
"newUi.sidebar.userProfile.colorPickerTooltip",
"newUi.sidebar.userProfile.commandPalette",
"newUi.sidebar.userProfile.commandPaletteDesc",
+13 -2
View File
@@ -159,6 +159,9 @@ export function AppRail({
const [showPinButton, setShowPinButton] = useState(() =>
readRailPreference("showPinAppRailButton"),
);
const [hoverPushes, setHoverPushes] = useState(() =>
readRailPreference("appRailHoverPushesContent"),
);
const [menuPos, setMenuPos] = useState<{ x: number; y: number } | null>(null);
const [menuTarget, setMenuTarget] = useState<MenuTarget | null>(null);
const [managing, setManaging] = useState(false);
@@ -170,8 +173,11 @@ export function AppRail({
setExpandOnHover(readRailPreference("expandAppRailOnHover"));
const showPinButtonHandler = () =>
setShowPinButton(readRailPreference("showPinAppRailButton"));
const pushHandler = () =>
setHoverPushes(readRailPreference("appRailHoverPushesContent"));
window.addEventListener("pinAppRailChanged", pinHandler);
window.addEventListener("expandAppRailOnHoverChanged", hoverHandler);
window.addEventListener("appRailHoverPushesContentChanged", pushHandler);
window.addEventListener(
"showPinAppRailButtonChanged",
showPinButtonHandler,
@@ -179,6 +185,10 @@ export function AppRail({
return () => {
window.removeEventListener("pinAppRailChanged", pinHandler);
window.removeEventListener("expandAppRailOnHoverChanged", hoverHandler);
window.removeEventListener(
"appRailHoverPushesContentChanged",
pushHandler,
);
window.removeEventListener(
"showPinAppRailButtonChanged",
showPinButtonHandler,
@@ -313,8 +323,9 @@ export function AppRail({
};
// Expanding on hover draws over the content instead of pushing it, so open
// sessions like remote desktop are not resized each time.
const takesSpace = pinned || managing;
// sessions like remote desktop are not resized each time, unless the user
// asked for the old behavior.
const takesSpace = hoverPushes ? railExpanded : pinned || managing;
return (
<div
+18
View File
@@ -553,6 +553,9 @@ export function UserProfilePanel({
const [expandAppRailOnHover, setExpandAppRailOnHover] = useState(() =>
readRailPreference("expandAppRailOnHover"),
);
const [appRailHoverPushes, setAppRailHoverPushes] = useState(() =>
readRailPreference("appRailHoverPushesContent"),
);
const [showPinAppRailButton, setShowPinAppRailButton] = useState(() =>
readRailPreference("showPinAppRailButton"),
);
@@ -1684,6 +1687,21 @@ export function UserProfilePanel({
}}
/>
</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
label={t("newUi.sidebar.userProfile.showPinAppRailButton")}
description={t(
+11 -1
View File
@@ -1,14 +1,23 @@
import { getUserPreferences, saveUserPreferences } from "@/api/open-tabs-api";
export type RailPreference =
"pinAppRail" | "expandAppRailOnHover" | "showPinAppRailButton";
| "pinAppRail"
| "expandAppRailOnHover"
| "showPinAppRailButton"
| "appRailHoverPushesContent";
const CHANGE_EVENT: Record<RailPreference, string> = {
pinAppRail: "pinAppRailChanged",
expandAppRailOnHover: "expandAppRailOnHoverChanged",
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
// defaults on, so each key needs its own read rather than a shared
// === "true" check.
@@ -28,6 +37,7 @@ export function readRailPreference(key: RailPreference): boolean {
export function setRailPreference(key: RailPreference, value: boolean): void {
localStorage.setItem(key, String(value));
window.dispatchEvent(new Event(CHANGE_EVENT[key]));
if (LOCAL_ONLY.has(key)) return;
void getUserPreferences()
.then((preferences) => {
if (preferences.storageMode === "cloud") {
+15
View File
@@ -111,6 +111,21 @@ describe("pluginWsUrl in the browser", () => {
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 () => {
fetchMock.mockResolvedValueOnce(new Response("{}", { status: 401 }));
+11
View File
@@ -66,6 +66,17 @@ describe("AppRail", () => {
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 () => {
registerRailItem({
id: "inbox",