add simple login functionality

AuthorKonata <konata@posteo.jp>
Date
Commitb76351b87751b2e5aaa31f08be5dd41369de2d20
Parent2326fa1
13 files changed, 362 insertions(+), 151 deletions(-)
▾MREADME.md
@@ -40,9 +40,7 @@ If you choose to not run this via the include Containerfile, you need to ensure
You can configure the backend with the following environment variables:
- `COVER_REGEX`: JS-compatible regex to match filenames in a directory to find a matching cover art. Searches the directory structure upwards. Set to an empty string to disable cover detection.
- `EXCLUDE_EXTENSION`: comma-separated list of of file extensions to ignore completely, e.g. `txt,log,nfo`. If unset, uses a safe set of text files common in downloaded music archives. Useful because some files can get mis-scanned, e.g. some CD-scan .log files get scanned as mp1
## Important Notes
- **No Authentication**: The app has no built-in auth. Run it behind a proxy with basic auth for security. Simple login form might get added in the future.
- `AUTH`: Same format as the string used for basic auth (username and password joined by a colon `:` and encoded to base64). If set, the server APIs return 401 unless the users is signed in with the given login details. Basic assets are not protected, so the UI itself will load fine even when not logged in, so when you get signed out for whatever reason (e.g. by the server restarting), you can still access the UI and play already synced files without problems.
## Non-Goals
- **Metadata-Based Views**: No support for filtering by genre, artist, etc. Use other projects for this
@@ -56,8 +54,6 @@ You can configure the backend with the following environment variables:
- I'm getting "Quota exceeded" errors when trying to sync files *or*
- My synced files randomly don't work, are missing
- By default websites aren't granted infinite storage or have persisten data. The frontend requests these permissions by calling `navigator.storage.persist()` on startup, which causes some browsers to show a permissions popup (e.g. firefox), while others just seem to ignore this request and use a heuristic like interaction amount and bookmarking instead. So if you have the error described above you either are running out of actual disk storage, or you need to check what heuristics your browser uses and trigger them.
- My server is online, but I'm getting loading failed errors for everything
- If you have set up basic auth, then the PWA can interact in a weird way with it, where the pwa itself loads fine because it loaded from cache, but any request fails because the basic auth is gone. Usually can be fixed by loading the page, waiting a few seconds and then reloading, which should give you the auth prompt.
## Known issues
- Range requests are not correctly handled in Bun right now, and e.g. doesn't send the total byte length correctly. I worked around this by sending a chunked transfer-encoded response, and setting the range and content-length header manually, which seems to work with the browsers I tested, but still might cause issues in some cases
▾Mserver/src/index.ts
@@ -10,6 +10,9 @@ import {
probeCache,
mediaTypes,
generatedPlaylistIds,
username,
password,
authTokens,
} from "./shared";
import {
getPathInfo,
@@ -109,140 +112,176 @@ const app = setup
if (path === "/remote-log") return;
console.info(request.method, path);
})
.post("/reset-cache", () => {
fileTypeCache.clear();
probeCache.clear();
})
.get("/download/*", downloadHandler)
.head("/download/*", downloadHandler)
.get(
"/transcode/*",
async ({ request, query, set, params }) => {
const filePath = path.join(musicRoot, decodePath(params["*"]));
const fileScan = await getPathInfo(filePath);
if (!fileScan || fileScan instanceof ServerError) {
set.status = fileScan?.status || "Internal Server Error";
return fileScan?.error || "Not a file";
}
if (!matchesType(fileScan.mimeType, allowedTypes)) {
set.status = "Forbidden";
return "Forbidden file type";
}
const probe = await probeFile(filePath);
//don't use higher bitrate than what the file has, use requested bitrate if unknown, default to 96kbit if not provided
const bitrate = query.bitrate && probe.bitrate ? Math.min(query.bitrate, probe.bitrate) : query.bitrate || 96000;
if (!matchesType(fileScan.mimeType, mediaTypes)) {
set.status = "Temporary Redirect";
set.headers.Location = `/download/${params["*"]}`;
return "Not a media file, redirecting to normal endpoint";
}
const { cmd, mimeType } = convertWithFFmpeg(filePath, bitrate, query.format ?? MediaFormat["opus/ogg"]);
request.signal.addEventListener("abort", () => cmd.kill("SIGKILL"));
set.headers["content-type"] = mimeType;
if (query.disableChunkedTranscoding) {
const chunks: Uint8Array[] = [];
for await (const chunk of cmd.stdout) {
chunks.push(chunk);
}
const full = new Uint8Array(chunks.reduce((acc, val) => acc + val.length, 0));
let offset = 0;
for (const chunk of chunks) {
full.set(chunk, offset);
offset += chunk.length;
}
return full;
.post(
"/login",
({ body, set }) => {
const [givenUser, givenPassword] = (body as string).split(":", 2);
if (givenUser === username && givenPassword === password) {
set.status = 200;
const millisInYear = 365 * 24 * 60 * 60 * 1000;
const endDate = new Date(Date.now() + millisInYear);
const token = randomUUIDv7();
authTokens.set(token, endDate);
set.headers["set-cookie"] =
`authToken=${token}; Expires=${endDate.toUTCString()}; Secure; HttpOnly; SameSite=Strict"`;
return "Logged in successfully";
}
return new Response(cmd.stdout);
},
{
query: t.Optional(
t.Object({ bitrate: t.Number(), format: t.Enum(MediaFormat), disableChunkedTranscoding: t.Boolean() }),
),
set.status = 401;
return "Invalid username or password";
},
{ body: t.String() },
)
.get(
"/list/*",
async ({ params, set, query }) => {
const dirPath = path.join(musicRoot, decodePath(params["*"]));
const fileList = await listFiles(dirPath, query.recursive || false);
if (fileList instanceof ServerError) {
set.status = fileList.status;
return fileList.error;
}
set.headers["Content-Type"] = "application/json";
set.status = "OK";
return stringifyMap(fileList);
},
{ query: t.Optional(t.Object({ recursive: t.Boolean() })) },
)
.get(
"/cover/*",
async ({ params, set, query }) => {
set.status = "Not Found";
const dirPath = path.join(musicRoot, decodePath(params["*"]));
const fileScanResult = await findCover(dirPath);
const setContentType = (type: string) => {
set.headers["Content-Type"] = type;
};
if ("path" in fileScanResult) {
set.headers["Content-Type"] = fileScanResult.info.mimeType;
set.status = "OK";
if (query.transcode)
return new Response(
await toAvif(await Bun.file(fileScanResult.path).bytes(), fileScanResult.info.mimeType, setContentType),
);
//wrapping in new response discards the accept-range header, which we don't support here
return new Response(Bun.file(fileScanResult.path));
}
if ("content" in fileScanResult) {
set.headers["Content-Type"] = fileScanResult.info.mimeType;
set.status = "OK";
if (query.transcode)
return new Response(await toAvif(fileScanResult.content, fileScanResult.info.mimeType, setContentType));
return new Response(fileScanResult.content);
}
set.status = fileScanResult.info.status;
return fileScanResult.info.error;
},
{ query: t.Optional(t.Object({ transcode: t.Boolean() })) },
)
.post("/prepare-playlist", async ({ set, body }) => {
set.status = "OK";
set.headers["Content-Type"] = "text/plain";
const id = randomUUIDv7();
generatedPlaylistIds.set(id, JSON.parse(body as string) as string[]);
setTimeout(
() => {
generatedPlaylistIds.delete(id);
.guard(
{
beforeHandle({ cookie: { authToken }, set }) {
if (username && password && (!authToken.value || !authTokens.has(authToken.value))) {
set.status = 401;
set.headers.location;
return "Unauthorized";
}
},
1000 * 60 * 60, // 1 hour
);
return id;
})
.get(
"/download-playlist/:id",
async ({ set, params }) => {
const playlist = generatedPlaylistIds.get(params.id);
if (!playlist) {
set.status = "Not Found";
return "Playlist ID not found";
}
set.status = "OK";
if (playlist.length === 1) {
set.headers["Content-Disposition"] = `attachment; filename="${basename(playlist[0])}"`;
return new Response(Bun.file(path.join(musicRoot, playlist[0])));
}
set.headers["Content-Type"] = "application/x-tar";
set.headers["Content-Disposition"] = `attachment; filename="playlist.tar"`;
return new Response(packWithTar(playlist).stdout);
},
{ params: t.Object({ id: t.String({ minLength: 1 }) }) },
)
.post("remote-log", ({ body }) => {
console.log(body);
})
(guarded) =>
guarded
.post("/reset-cache", () => {
fileTypeCache.clear();
probeCache.clear();
})
.get("/download/*", downloadHandler)
.head("/download/*", downloadHandler)
.get(
"/transcode/*",
async ({ request, query, set, params }) => {
const filePath = path.join(musicRoot, decodePath(params["*"]));
const fileScan = await getPathInfo(filePath);
if (!fileScan || fileScan instanceof ServerError) {
set.status = fileScan?.status || "Internal Server Error";
return fileScan?.error || "Not a file";
}
if (!matchesType(fileScan.mimeType, allowedTypes)) {
set.status = "Forbidden";
return "Forbidden file type";
}
const probe = await probeFile(filePath);
//don't use higher bitrate than what the file has, use requested bitrate if unknown, default to 96kbit if not provided
const bitrate =
query.bitrate && probe.bitrate ? Math.min(query.bitrate, probe.bitrate) : query.bitrate || 96000;
if (!matchesType(fileScan.mimeType, mediaTypes)) {
set.status = "Temporary Redirect";
set.headers.Location = `/download/${params["*"]}`;
return "Not a media file, redirecting to normal endpoint";
}
const { cmd, mimeType } = convertWithFFmpeg(filePath, bitrate, query.format ?? MediaFormat["opus/ogg"]);
request.signal.addEventListener("abort", () => cmd.kill("SIGKILL"));
set.headers["content-type"] = mimeType;
if (query.disableChunkedTranscoding) {
const chunks: Uint8Array[] = [];
for await (const chunk of cmd.stdout) {
chunks.push(chunk);
}
const full = new Uint8Array(chunks.reduce((acc, val) => acc + val.length, 0));
let offset = 0;
for (const chunk of chunks) {
full.set(chunk, offset);
offset += chunk.length;
}
return full;
}
return new Response(cmd.stdout);
},
{
query: t.Optional(
t.Object({ bitrate: t.Number(), format: t.Enum(MediaFormat), disableChunkedTranscoding: t.Boolean() }),
),
},
)
.get(
"/list/*",
async ({ params, set, query }) => {
const dirPath = path.join(musicRoot, decodePath(params["*"]));
const fileList = await listFiles(dirPath, query.recursive || false);
if (fileList instanceof ServerError) {
set.status = fileList.status;
return fileList.error;
}
set.headers["Content-Type"] = "application/json";
set.status = "OK";
return stringifyMap(fileList);
},
{ query: t.Optional(t.Object({ recursive: t.Boolean() })) },
)
.get(
"/cover/*",
async ({ params, set, query }) => {
set.status = "Not Found";
const dirPath = path.join(musicRoot, decodePath(params["*"]));
const fileScanResult = await findCover(dirPath);
const setContentType = (type: string) => {
set.headers["Content-Type"] = type;
};
if ("path" in fileScanResult) {
set.headers["Content-Type"] = fileScanResult.info.mimeType;
set.status = "OK";
if (query.transcode)
return new Response(
await toAvif(
await Bun.file(fileScanResult.path).bytes(),
fileScanResult.info.mimeType,
setContentType,
),
);
//wrapping in new response discards the accept-range header, which we don't support here
return new Response(Bun.file(fileScanResult.path));
}
if ("content" in fileScanResult) {
set.headers["Content-Type"] = fileScanResult.info.mimeType;
set.status = "OK";
if (query.transcode)
return new Response(await toAvif(fileScanResult.content, fileScanResult.info.mimeType, setContentType));
return new Response(fileScanResult.content);
}
set.status = fileScanResult.info.status;
return fileScanResult.info.error;
},
{ query: t.Optional(t.Object({ transcode: t.Boolean() })) },
)
.post("/prepare-playlist", async ({ set, body }) => {
set.status = "OK";
set.headers["Content-Type"] = "text/plain";
const id = randomUUIDv7();
generatedPlaylistIds.set(id, JSON.parse(body as string) as string[]);
setTimeout(
() => {
generatedPlaylistIds.delete(id);
},
1000 * 60 * 60, // 1 hour
);
return id;
})
.get(
"/download-playlist/:id",
async ({ set, params }) => {
const playlist = generatedPlaylistIds.get(params.id);
if (!playlist) {
set.status = "Not Found";
return "Playlist ID not found";
}
set.status = "OK";
if (playlist.length === 1) {
set.headers["Content-Disposition"] = `attachment; filename="${basename(playlist[0])}"`;
return new Response(Bun.file(path.join(musicRoot, playlist[0])));
}
set.headers["Content-Type"] = "application/x-tar";
set.headers["Content-Disposition"] = `attachment; filename="playlist.tar"`;
return new Response(packWithTar(playlist).stdout);
},
{ params: t.Object({ id: t.String({ minLength: 1 }) }) },
)
.post("remote-log", ({ body }) => {
console.log(body);
}),
)
.listen(3000);
console.log(`🦊 Elysia is running at ${app.server?.hostname}:${app.server?.port}`);
▾Mserver/src/shared.ts
@@ -9,13 +9,31 @@ if (musicRoot === "") {
process.exit(1);
}
export const [username, password] = process.env.AUTH
? Buffer.from(process.env.AUTH, "base64").toString().split(":", 2)
: [undefined, undefined];
export const authTokens = new Map<string, Date>();
//clear outdated tokens once a day
setInterval(
() => {
for (const [token, endDate] of authTokens.entries()) {
if (endDate.getTime() < Date.now()) authTokens.delete(token);
}
},
1000 * 60 * 60 * 24,
);
if (!path.isAbsolute(musicRoot)) {
console.error(`Music root ${musicRoot} is not absolute`);
process.exit(1);
}
export const excludeExtension =
process.env.EXCLUDE_EXTENSION === undefined ? ["txt", "log", "nfo", "m3u", "htm", "html"] : process.env.EXCLUDE_EXTENSION.split(",");
process.env.EXCLUDE_EXTENSION === undefined
? ["txt", "log", "nfo", "m3u", "htm", "html"]
: process.env.EXCLUDE_EXTENSION.split(",");
if (!commandExists.sync("ffmpeg")) {
console.error("Required command ffmpeg not found in PATH");
▾Mwebclient/src/App.tsx
@@ -9,6 +9,7 @@ import {
CogSVG,
DownloadManagerSVG,
FullscreenSVG,
LoginSVG,
OpenFolderSVG,
PlaylistSVG,
} from "./components/SVG";
@@ -21,6 +22,7 @@ import { BUILD_HASH } from "./build-info";
import { createStore, reconcile } from "solid-js/store";
import { decodePath, encodePath } from "music-server-shared/utils";
import DownloadManager from "./components/DownloadManager";
import { SignIn } from "./components/SignIn";
//TODO: add bitrate+format to metadata
//TODO: more file display options
@@ -50,6 +52,7 @@ const App: Component = () => {
const [showOptions, setShowOptions] = createSignal(false);
const [showPlaylistManager, setShowPlaylistManager] = createSignal(false);
const [showDownloadManager, setShowDownloadManager] = createSignal(false);
const [showSignIn, setShowSignIn] = createSignal(false);
const [options, setOptions] = createStore<AppOptions>({
//default settings
enableTranscoding: true,
@@ -372,13 +375,24 @@ const App: Component = () => {
<div class="spacer" />
<div class="app-buttons">
<button
title="Switch between online and offline mode. In offline mode only locally cached files are listed"
class="offline-button"
style={{ background: isOffline() ? "lightgreen" : "indianred" }}
textContent={isOffline() ? "Go online" : "Go offline"}
onclick={() => setOffline(!isOffline())}
/>
<div class="signin-offline-group">
<SignIn showSignIn={showSignIn} setShowSignIn={setShowSignIn} />
<button
style={{ display: isOffline() ? "none" : undefined }}
title="Sign in"
onclick={() => setShowSignIn(true)}
>
<LoginSVG />
</button>
<button
title="Switch between online and offline mode. In offline mode only locally cached files are listed"
class="offline-button"
style={{ background: isOffline() ? "lightgreen" : "indianred" }}
textContent={isOffline() ? "Go online" : "Go offline"}
onclick={() => setOffline(!isOffline())}
/>
</div>
<div>
<button
title="Toggle File Browser"
▾Mwebclient/src/components/OptionsModal.tsx
@@ -31,7 +31,8 @@ export default function OptionsModal(props: OptionsModalProps) {
}),
);
function handleSave() {
function handleSave(e: Event) {
e.preventDefault();
if (!formElement.reportValidity()) return;
toast("Saved changes", "success");
props.setOptions(newOptions);
@@ -44,7 +45,7 @@ export default function OptionsModal(props: OptionsModalProps) {
}
async function deleteServerCache() {
const response = await fetch("/reset-cache", { method: "POST" }).catch(() => {});
const response = await fetch("/reset-cache", { method: "POST", credentials: "same-origin" }).catch(() => {});
if (!response || response.status !== 200) {
toast("Clearing server cache failed", "error");
} else {
▾Mwebclient/src/components/Playlist.tsx
@@ -57,7 +57,11 @@ export default function Playlist(props: PlaylistProps) {
try {
const playlist = props.playlist();
const filePaths = playlist.map((item) => item.path);
const response = await fetch("/prepare-playlist", { body: JSON.stringify(filePaths), method: "POST" });
const response = await fetch("/prepare-playlist", {
body: JSON.stringify(filePaths),
method: "POST",
credentials: "same-origin",
});
if (!response.ok) throw new Error(`Generating playlist id not successful: ${await response.text()}`);
const id = await response.text();
if (download) {
▾Mwebclient/src/components/SVG.tsx
@@ -535,3 +535,23 @@ export function InfoSVG() {
</svg>
);
}
export function LoginSVG() {
const [hovered, setHovered] = createSignal();
return (
<svg
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 24 24"
onMouseEnter={() => setHovered(true)}
onMouseLeave={() => setHovered(false)}
>
<path
fill={hovered() ? highlightColor : defaultColor}
d="M20 2h3v20h-3zM8 4h2v1h1v1h1v1h1v1h1v1h1v1h1v1h1v2h-1v1h-1v1h-1v1h-1v1h-1v1h-1v1h-1v1H8v-1H7v-2h1v-1h1v-1h1v-1H1v-4h9V9H9V8H8V7H7V5h1z"
/>
</svg>
);
}
▾Awebclient/src/components/SignIn.css
@@ -0,0 +1,9 @@
.signin-header {
display: flex;
gap: 1.5rem;
justify-content: space-between;
}
.signin-button {
margin-left: auto;
}
▾Awebclient/src/components/SignIn.tsx
@@ -0,0 +1,102 @@
import { type Accessor, createEffect, type Setter } from "solid-js";
import { ClearSVG } from "./SVG";
import "./SignIn.css";
import { LoadingSpinner } from "./LoadingScreen";
import { toast } from "../utils";
interface SignInProps {
showSignIn: Accessor<boolean>;
setShowSignIn: Setter<boolean>;
}
export function SignIn(props: SignInProps) {
let dialogElement!: HTMLDialogElement;
let formElement!: HTMLFormElement;
let usernameInput!: HTMLInputElement;
let passwordInput!: HTMLInputElement;
let loadingElement!: HTMLDivElement;
let submitButton!: HTMLButtonElement;
createEffect(() => {
if (props.showSignIn()) {
dialogElement.showModal();
}
});
function handleClose() {
dialogElement.close();
usernameInput.value = "";
passwordInput.value = "";
props.setShowSignIn(false);
}
async function doSignIn(e: Event) {
e.preventDefault();
usernameInput.setCustomValidity("");
if (!formElement.reportValidity()) return;
if (usernameInput.value.includes(":")) {
usernameInput.setCustomValidity('Username can\'t contain a colon ":"');
usernameInput.reportValidity();
return;
}
loadingElement.style.display = "block";
submitButton.disabled = true;
try {
const response = await fetch("/login", {
method: "POST",
body: `${usernameInput.value}:${passwordInput.value}`,
});
if (response.status === 200) {
toast("Login successful", "success");
handleClose();
} else if (response.status === 401) {
toast("Login failed: wrong credentials", "error");
} else {
toast(`Login failed: unknown error, code: ${response.status}`, "error", await (await response.blob()).text());
}
} finally {
loadingElement.style.display = "none";
submitButton.disabled = false;
}
}
return (
<dialog ref={dialogElement} onClose={handleClose} onclick={(e) => e.target.nodeName === "DIALOG" && handleClose()}>
<form ref={formElement}>
<div class="signin-header title">
Sign in
<button title="Close dialog" onClick={handleClose}>
<ClearSVG />
</button>
</div>
<hr />
<label for="username-input">Username:</label>
<input
ref={usernameInput}
id="username-input"
type="text"
minlength={1}
required
value=""
onChange={() => {}}
/>
<label for="password-input">Password:</label>
<input
ref={passwordInput}
id="password-input"
type="password"
minlength={1}
required
value=""
onChange={() => {}}
/>
<button ref={submitButton} onclick={doSignIn} class="signin-button" title="Submit sign in">
Submit
<div ref={loadingElement} style={{ display: "none", width: "24px", height: "24px" }}>
<LoadingSpinner center />
</div>
</button>
</form>
</dialog>
);
}
▾Mwebclient/src/index.tsx
@@ -96,6 +96,6 @@ if (version && version !== currentVersion) {
})
.catch((e) => {
root.innerHTML = e.toString();
console.log(e)
console.error(e)
});
}
▾Mwebclient/src/offline.ts
@@ -222,7 +222,9 @@ export const clearDirectoryCache = async (path: string) => {
}
};
async function listOnlineFiles(dir: string, recursive: boolean): Promise<FileListingWithStatus | undefined> {
const response = await fetch(`/list/${encodePath(dir)}?recursive=${recursive}`).catch(() => {});
const response = await fetch(`/list/${encodePath(dir)}?recursive=${recursive}`, { credentials: "same-origin" }).catch(
() => {},
);
if (response?.status === 404) {
toast(`Directory not found: ${dir}`, "error");
return;
@@ -325,6 +327,7 @@ export async function getCover(
const response = await fetch(`/cover/${encodePath(dir)}${options.transcodeCovers ? "?transcode=true" : ""}`, {
method: "GET",
credentials: "same-origin",
}).catch(() => {});
if (!response || response.status === 404) {
coverCache.set(dir, { data: null });
@@ -474,7 +477,10 @@ async function downloadFile(
const semaphorePromise = downloadSemaphore.acquire();
await Promise.race([abortPromise, semaphorePromise]);
try {
const res = await fetch(await getSrc(path, options, false, true), { signal: controller.signal });
const res = await fetch(await getSrc(path, options, false, true), {
signal: controller.signal,
credentials: "same-origin",
});
//let the browser do the actual download
const blobPromise = res.clone().blob();
▾Mwebclient/src/style.css
@@ -102,9 +102,11 @@ body,
flex-wrap: wrap;
gap: 1.5rem;
justify-content: end;
& > .offline-button {
text-wrap: nowrap;
width: min-content;
& > .signin-offline-group {
& > .offline-button {
text-wrap: nowrap;
width: min-content;
}
}
& > div {
display: flex;
▾Mwebclient/src/utils.ts
@@ -67,7 +67,7 @@ export function toast(text: string, type: "progress" | "info" | "error" | "succe
}
export function remoteLog(e: unknown) {
fetch("remote-log", { body: JSON.stringify(e?.toString()), method: "POST" });
fetch("remote-log", { body: JSON.stringify(e?.toString()), method: "POST", credentials: "same-origin" });
}
export type FileListingRecord = Record<string, MediaFile | { files: FileListingRecord }>;