Skip to content
Merged
Show file tree
Hide file tree
Changes from 7 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
55 changes: 55 additions & 0 deletions client/src/pages/settings.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -374,6 +374,8 @@ export default function SettingsPage() {
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 @@ export default function SettingsPage() {
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 @@ export default function SettingsPage() {
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 @@ export default function SettingsPage() {
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,51 @@ export default function SettingsPage() {
</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>.
</p>
{(appriseUsername.trim() || apprisePassword) &&
appriseApiUrl.trim().toLowerCase().startsWith("http://") && (
<p className="text-xs text-amber-700 in-[.dark]:text-amber-500 sm:col-span-2">
This API URL uses http://, so the login is sent unencrypted. Use https://
unless Apprise runs on a network you trust.
</p>
)}
</div>
)}
<div className="space-y-2">
<Label htmlFor="apprise-urls">
Notification URLs{" "}
Expand Down Expand Up @@ -1694,6 +1747,8 @@ export default function SettingsPage() {
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
52 changes: 52 additions & 0 deletions server/__tests__/apprise.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -85,6 +85,58 @@ 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({
requireHttps: false,
headers: {
"Content-Type": "application/json",
Authorization: `Basic ${Buffer.from("admin:s3cret:pass").toString("base64")}`,
},
})
);
});

it("refuses to let credentials sent to an https server be redirected to http", async () => {
vi.mocked(safeFetch).mockResolvedValue({
ok: true,
status: 200,
text: vi.fn().mockResolvedValue(""),
} as never);

appriseClient.configure({
mode: "api",
apiUrl: "https://apprise.example.com",
key: "config-key",
urls: null,
username: "admin",
password: "secret",
});

await appriseClient.send(notification);
expect(safeFetch).toHaveBeenCalledWith(
"https://apprise.example.com/notify/config-key",
expect.objectContaining({ requireHttps: true })
);
});

it("sends notifications via Apprise CLI mode using a config file, not argv URLs", async () => {
let capturedArgs: string[] = [];
let capturedConfigContent = "";
Expand Down
43 changes: 40 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,25 @@ class AppriseClient {
return isAppriseConfigured(this.settings);
}

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

private hasApiCredentials(): boolean {
return !!(this.settings.username || this.settings.password);
}

// Once credentials are configured against an https:// server, never let a redirect
// downgrade the request (and its Authorization header) to plaintext http.
private requiresHttps(): boolean {
return this.hasApiCredentials() && this.settings.apiUrl?.startsWith("https://") === true;
Comment thread
Doezer marked this conversation as resolved.
Outdated
}

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

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

Expand Down
Loading
Loading