Skip to content
Merged
Show file tree
Hide file tree
Changes from 8 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
71 changes: 70 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,64 @@ 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 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