Skip to content
Merged
Show file tree
Hide file tree
Changes from 3 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions client/__tests__/SettingsPage.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -332,6 +332,8 @@ describe("SettingsPage", () => {
apiUrl: "http://apprise:8000",
key: "",
urls: "discord://webhook/xyz",
username: "",
password: "",
});
});
});
Expand Down
48 changes: 48 additions & 0 deletions client/src/pages/settings.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -374,6 +374,8 @@
const [appriseApiUrl, setAppriseApiUrl] = useState("");
const [appriseKey, setAppriseKey] = useState("");
const [appriseUrls, setAppriseUrls] = useState("");
const [appriseUsername, setAppriseUsername] = useState("");
const [apprisePassword, setApprisePassword] = useState("");
const appriseLoadedRef = useRef(false);
const settingsLoadedRef = useRef(false);

Expand Down Expand Up @@ -531,6 +533,8 @@
apiUrl: string | null;
key: string | null;
urls: string | null;
username?: string;
password?: string;
}>({
queryKey: ["/api/settings/apprise"],
queryFn: () => apiRequest("GET", "/api/settings/apprise").then((r) => r.json()),
Expand All @@ -542,6 +546,8 @@
if (appriseSettings.apiUrl !== undefined) setAppriseApiUrl(appriseSettings.apiUrl ?? "");
if (appriseSettings.key !== undefined) setAppriseKey(appriseSettings.key ?? "");
if (appriseSettings.urls !== undefined) setAppriseUrls(appriseSettings.urls ?? "");
setAppriseUsername(appriseSettings.username ?? "");
setApprisePassword(appriseSettings.password ?? "");
appriseLoadedRef.current = true;
}
}, [appriseSettings]);
Expand All @@ -560,6 +566,8 @@
apiUrl?: string;
key?: string;
urls?: string;
username?: string;
password?: string;
}) => {
const res = await apiRequest("POST", "/api/settings/apprise", data);
return res.json();
Expand Down Expand Up @@ -1655,6 +1663,44 @@
</p>
</div>
)}
{appriseMode === "api" && (
<div className="grid gap-4 sm:grid-cols-2">
<div className="space-y-2">
<Label htmlFor="apprise-username">
Username{" "}
<span className="text-xs text-muted-foreground font-normal">
(optional)
</span>
</Label>
<Input
id="apprise-username"
type="text"
autoComplete="off"
value={appriseUsername}
onChange={(e) => setAppriseUsername(e.target.value)}
/>
</div>
<div className="space-y-2">
<Label htmlFor="apprise-password">
Password{" "}
<span className="text-xs text-muted-foreground font-normal">
(optional)
</span>
</Label>
<Input
id="apprise-password"
type="password"
autoComplete="new-password"
value={apprisePassword}
onChange={(e) => setApprisePassword(e.target.value)}
/>
</div>
<p className="text-xs text-muted-foreground sm:col-span-2">
Only needed when your Apprise API server requires a login (
<code className="px-1">APPRISE_AUTH_REQUIRED=yes</code>).

Check warning on line 1700 in client/src/pages/settings.tsx

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Ambiguous spacing before next element code

See more on https://sonarcloud.io/project/issues?id=Doezer_Questarr&issues=AaEPz8QgfW-BnKL-v_hJ&open=AaEPz8QgfW-BnKL-v_hJ&pullRequest=1154
</p>
</div>
)}
<div className="space-y-2">
<Label htmlFor="apprise-urls">
Notification URLs{" "}
Expand Down Expand Up @@ -1694,6 +1740,8 @@
apiUrl: appriseApiUrl.trim(),
key: appriseKey.trim(),
urls: appriseUrls.trim(),
username: appriseUsername.trim(),
password: apprisePassword,
}
)
}
Expand Down
1 change: 1 addition & 0 deletions docs/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,7 @@ All notable changes to this project will be documented in this file.
- **PostgreSQL backend**: [OPTIONAL] Questarr can now run on PostgreSQL instead of SQLite, selected with `DB_DIALECT=postgres` plus `DATABASE_URL` (setting `DATABASE_URL` alone keeps SQLite) (#1046). See `docs/DATABASE.md` if you're looking to migrate from SQLite.
- **Playnite integration**: API keys, an integration API, and a Playnite extension. See [the extension's README](https://github.com/Doezer/Questarr/blob/main/extensions/playnite-questarr/README.md) for setup (#986).
- **Steam wishlist**: optional auto-sync on a configurable interval, alongside the existing manual sync (#805).
- **Apprise API login**: Questarr can send a username and password to an Apprise API server that requires a login (`APPRISE_AUTH_REQUIRED=yes`, Apprise API 2). The password is stored encrypted.

#### Deployment & Admin

Expand Down
57 changes: 57 additions & 0 deletions server/__tests__/api_routes.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -140,6 +140,16 @@ vi.mock("../middleware.js", async () => {
});

vi.mock("../config.js", () => ({ config: mockConfig }));
// The real encryptCredential would load its key through the mocked db module.
vi.mock("../credential-crypto.js", async () => {
const actual =
await vi.importActual<typeof import("../credential-crypto.js")>("../credential-crypto.js");
return {
...actual,
encryptCredential: vi.fn(async (value: string) => `enc:v1:${value}`),
decryptCredential: vi.fn(async (value: string) => value),
};
});
vi.mock("../config-loader.js", () => ({ configLoader: createConfigLoaderMock() }));
vi.mock("../socket.js", () => createSocketMock());

Expand Down Expand Up @@ -3945,6 +3955,53 @@ describe("API Routes - Extended Coverage", () => {
expect(appriseState["apprise.mode"]).toBe("cli");
});

it("should mask a saved API password and return the username", async () => {
appriseState["apprise.username"] = "admin";
appriseState["apprise.password"] = "secret";

const response = await request(app).get("/api/settings/apprise");

expect(response.status).toBe(200);
expect(response.body.username).toBe("admin");
expect(response.body.password).toBe("********");
});

it("should store API credentials encrypted and keep the password on a masked resubmit", async () => {
const response = await request(app).post("/api/settings/apprise").send({
mode: "api",
apiUrl: "http://apprise:8000",
key: "config-key",
username: " admin ",
password: "secret",
});

expect(response.status).toBe(200);
expect(appriseState["apprise.username"]).toBe("admin");
expect(appriseState["apprise.password"]).toBe("enc:v1:secret");

const resubmit = await request(app).post("/api/settings/apprise").send({
mode: "api",
apiUrl: "http://apprise:8000",
key: "config-key",
username: "admin",
password: "********",
});

expect(resubmit.status).toBe(200);
expect(appriseState["apprise.password"]).toBe("enc:v1:secret");
});

it("should reject a username containing a colon", async () => {
const response = await request(app).post("/api/settings/apprise").send({
mode: "api",
apiUrl: "http://apprise:8000",
key: "config-key",
username: "ad:min",
});

expect(response.status).toBe(400);
});

it("should reject an invalid mode", async () => {
const response = await request(app)
.post("/api/settings/apprise")
Expand Down
28 changes: 28 additions & 0 deletions server/__tests__/apprise.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -85,6 +85,34 @@ describe("Apprise client", () => {
);
});

it("sends HTTP Basic Auth credentials to a protected Apprise API server", async () => {
vi.mocked(safeFetch).mockResolvedValue({
ok: true,
status: 200,
text: vi.fn().mockResolvedValue(""),
} as never);

appriseClient.configure({
mode: "api",
apiUrl: "http://apprise:8000",
key: "config-key",
urls: null,
username: "admin",
password: "s3cret:pass",
});

await expect(appriseClient.test()).resolves.toEqual({ success: true });
expect(safeFetch).toHaveBeenCalledWith(
"http://apprise:8000/notify/config-key",
expect.objectContaining({
headers: {
"Content-Type": "application/json",
Authorization: `Basic ${Buffer.from("admin:s3cret:pass").toString("base64")}`,
},
})
);
});

it("sends notifications via Apprise CLI mode using a config file, not argv URLs", async () => {
let capturedArgs: string[] = [];
let capturedConfigContent = "";
Expand Down
31 changes: 28 additions & 3 deletions server/apprise.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,10 @@ export interface AppriseSettings {
apiUrl: string | null;
key: string | null;
urls: string | null;
// HTTP Basic Auth credentials for an Apprise API server started with
// APPRISE_AUTH_REQUIRED=yes (API mode only; the username may be empty).
username: string | null;
password: string | null;
}

type ExecFileResult = { stdout: string; stderr: string };
Expand Down Expand Up @@ -117,18 +121,26 @@ export function isAppriseConfigured(settings: AppriseSettings): boolean {
export async function readAppriseSettings(storage: {
getSystemConfig(key: string): Promise<string | undefined>;
}): Promise<AppriseSettings> {
const [mode, apiUrl, key, urls] = await Promise.all([
const [mode, apiUrl, key, urls, username, password] = await Promise.all([
storage.getSystemConfig("apprise.mode"),
storage.getSystemConfig("apprise.apiUrl"),
storage.getSystemConfig("apprise.key"),
storage.getSystemConfig("apprise.urls"),
storage.getSystemConfig("apprise.username"),
storage.getSystemConfig("apprise.password"),
]);

// Loaded lazily: credential-crypto pulls in the database module, which modules that
// only send notifications through appriseClient should not have to initialize.
const { decryptCredential } = await import("./credential-crypto.js");

return {
mode: normalizeAppriseMode(mode),
apiUrl: trimToNull(apiUrl),
key: trimToNull(key),
urls: trimToNull(urls),
username: trimToNull(username),
password: (await decryptCredential(password)) || null,
Comment thread
Doezer marked this conversation as resolved.
Outdated
};
}

Expand Down Expand Up @@ -200,6 +212,8 @@ class AppriseClient {
apiUrl: null,
key: null,
urls: null,
username: null,
password: null,
};

configure(settings: Partial<AppriseSettings>): void {
Expand All @@ -208,6 +222,8 @@ class AppriseClient {
apiUrl: trimToNull(settings.apiUrl),
key: trimToNull(settings.key),
urls: trimToNull(settings.urls),
username: trimToNull(settings.username),
password: settings.password || null,
};
}

Expand All @@ -219,6 +235,15 @@ class AppriseClient {
return isAppriseConfigured(this.settings);
}

private buildApiHeaders(): Record<string, string> {
const headers: Record<string, string> = { "Content-Type": "application/json" };
if (this.settings.username || this.settings.password) {
const credentials = `${this.settings.username ?? ""}:${this.settings.password ?? ""}`;
headers.Authorization = `Basic ${Buffer.from(credentials, "utf8").toString("base64")}`;
}
return headers;
}

private buildApiRequest(
title: string,
message: string,
Expand Down Expand Up @@ -257,7 +282,7 @@ class AppriseClient {
const res = await safeFetch(request.endpoint, {
method: "POST",
allowPrivate: true,
headers: { "Content-Type": "application/json" },
headers: this.buildApiHeaders(),
Comment thread
Doezer marked this conversation as resolved.
body: JSON.stringify(request.payload),
});

Expand Down Expand Up @@ -347,7 +372,7 @@ class AppriseClient {
const res = await safeFetch(request.endpoint, {
method: "POST",
allowPrivate: true,
headers: { "Content-Type": "application/json" },
headers: this.buildApiHeaders(),
body: JSON.stringify(request.payload),
});

Expand Down
21 changes: 19 additions & 2 deletions server/routes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4808,6 +4808,8 @@ export async function registerRoutes(app: Express): Promise<Server> {
apiUrl: settings.apiUrl,
key: settings.key,
urls: settings.urls,
username: settings.username ?? "",
password: settings.password ? REDACTED_PLACEHOLDER : "",
});
} catch (error) {
routesLogger.error({ error }, "Failed to fetch Apprise settings");
Expand All @@ -4817,11 +4819,13 @@ export async function registerRoutes(app: Express): Promise<Server> {

app.post("/api/settings/apprise", async (req, res) => {
try {
const { apiUrl, key, urls } = req.body as {
const { apiUrl, key, urls, username, password } = req.body as {
mode?: string;
apiUrl?: string;
key?: string;
urls?: string;
username?: string;
password?: string;
};
const mode = normalizeAppriseMode(
typeof req.body?.mode === "string" ? req.body.mode : undefined
Expand All @@ -4838,11 +4842,18 @@ export async function registerRoutes(app: Express): Promise<Server> {
if (
(apiUrl !== undefined && typeof apiUrl !== "string") ||
(key !== undefined && typeof key !== "string") ||
(urls !== undefined && typeof urls !== "string")
(urls !== undefined && typeof urls !== "string") ||
(username !== undefined && typeof username !== "string") ||
(password !== undefined && typeof password !== "string")
) {
return res.status(400).json({ error: "Invalid request payload types" });
}

// HTTP Basic Auth splits on the first colon, and apprise-api rejects one in APPRISE_USER.
if (username?.includes(":")) {
return res.status(400).json({ error: "Username cannot contain a colon" });
}

if (mode === "api") {
if (!apiUrl || apiUrl.trim().length === 0) {
return res.status(400).json({ error: "API URL is required in API mode" });
Expand Down Expand Up @@ -4874,6 +4885,12 @@ export async function registerRoutes(app: Express): Promise<Server> {
if (urls !== undefined) {
await storage.setSystemConfig("apprise.urls", urls.trim());
}
if (username !== undefined) {
await storage.setSystemConfig("apprise.username", username.trim());
}
if (password !== undefined && !isUnchangedSentinel(password)) {
await storage.setSystemConfig("apprise.password", await encryptCredential(password));
Comment thread
Doezer marked this conversation as resolved.
}

appriseClient.configure(await readAppriseSettings(storage));
return res.json({ success: true });
Expand Down
Loading