Skip to content
Merged
Show file tree
Hide file tree
Changes from all 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
62 changes: 62 additions & 0 deletions client/src/pages/settings.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -102,6 +102,14 @@ const NOTIFICATION_EVENT_ROWS: { key: NotificationEvent; label: string; group: s
{ key: "errorDetected", label: "Error Detected", group: "system" },
];

function isHttpUrl(value: string): boolean {
try {
return new URL(value.trim()).protocol === "http:";
} catch {
return false;
}
}

function SettingsToggleRow({
id,
label,
Expand Down Expand Up @@ -374,6 +382,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 +541,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 +554,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 +574,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 +1671,50 @@ 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) && isHttpUrl(appriseApiUrl) && (
<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 +1754,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
81 changes: 80 additions & 1 deletion server/__tests__/api_routes.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -41,7 +41,8 @@ import { rssService } from "../rss.js";
import { comparePassword } from "../auth.js";
import { routesLogger } from "../logger.js";
import { db } from "../db.js";
import { appriseClient } from "../apprise.js";
import { appriseClient, readAppriseSettings } from "../apprise.js";
import { decryptCredential } from "../credential-crypto.js";
import * as ssrfModule from "../ssrf.js";
import fsExtra from "fs-extra";
import { normalizeTitle } from "../../shared/title-utils.js";
Expand Down Expand Up @@ -140,6 +141,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 +3956,74 @@ 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 not decrypt the API password in CLI mode", async () => {
appriseState["apprise.mode"] = "cli";
appriseState["apprise.password"] = "enc:v1:secret";
vi.mocked(decryptCredential).mockClear();

const settings = await readAppriseSettings(storage);

expect(settings.password).toBeNull();
expect(decryptCredential).not.toHaveBeenCalled();
});

it("should still report a saved API password while CLI mode is active", async () => {
appriseState["apprise.mode"] = "cli";
appriseState["apprise.password"] = "enc:v1:secret";

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

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

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
57 changes: 57 additions & 0 deletions server/__tests__/apprise.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -85,6 +85,63 @@ 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,
headers: expect.objectContaining({
Authorization: `Basic ${Buffer.from("admin:secret").toString("base64")}`,
}),
})
);
});

it("sends notifications via Apprise CLI mode using a config file, not argv URLs", async () => {
let capturedArgs: string[] = [];
let capturedConfigContent = "";
Expand Down
Loading
Loading