mirror of
https://github.com/Termix-SSH/Termix.git
synced 2026-10-28 06:05:24 +00:00
Compare commits
3
Commits
sdk-v1.0.7
...
sdk-v1.0.8
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5cc65d87cd | ||
|
|
e166994ec4 | ||
|
|
3a851ad189 |
No files matched your search
+1
-1
@@ -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
|
||||
|
||||
|
||||
Generated
+1
-1
@@ -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",
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,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": {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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}`;
|
||||
|
||||
@@ -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";
|
||||
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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") {
|
||||
|
||||
@@ -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 }));
|
||||
|
||||
|
||||
@@ -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",
|
||||
|
||||
Reference in new issue
Block a user