homepage-plus

This commit is contained in:
Dominik 2024-12-12 15:15:24 +01:00 committed by Dominik Stahl
parent b2d75a99e7
commit aa211bcc14
Signed by: dominik
GPG key ID: 06A4003FC5049644
15 changed files with 271 additions and 56 deletions

View file

@ -22,7 +22,7 @@ export default function QuickLaunch({ servicesAndBookmarks, searchString, setSea
const [searchSuggestions, setSearchSuggestions] = useState([]);
const { data: widgets } = useSWR("/api/widgets");
const searchWidget = Object.values(widgets).find((w) => w.type === "search");
const searchWidget = widgets && Object.values(widgets).find((w) => w.type === "search");
let searchProvider;

17
src/pages/api/auth.js Normal file
View file

@ -0,0 +1,17 @@
import { checkAllowedGroup, readIdentitySettings } from "utils/identity/identity-helpers";
import { getSettings } from "utils/config/config";
export default async function handler(req, res) {
const { group } = req.query;
const { provider, groups } = readIdentitySettings(getSettings().identity);
try {
if (checkAllowedGroup(provider.getIdentity(req), groups, group)) {
res.json({ group });
} else {
res.status(401).json({ message: "Group unathorized" });
}
} catch (err) {
res.status(500).send("Error getting user identity");
}
}

View file

@ -1,5 +1,8 @@
import { readIdentitySettings } from "utils/identity/identity-helpers";
import { bookmarksResponse } from "utils/config/api-response";
import { getSettings } from "utils/config/config";
export default async function handler(req, res) {
res.send(await bookmarksResponse());
const { provider, groups } = readIdentitySettings(getSettings().identity);
res.send(await bookmarksResponse(provider.getIdentity(req), groups));
}

View file

@ -1,5 +1,8 @@
import { readIdentitySettings } from "utils/identity/identity-helpers";
import { servicesResponse } from "utils/config/api-response";
import { getSettings } from "utils/config/config";
export default async function handler(req, res) {
res.send(await servicesResponse());
const { provider, groups } = readIdentitySettings(getSettings().identity);
res.send(await servicesResponse(provider.getIdentity(req), groups));
}

View file

@ -1,5 +1,8 @@
import { readIdentitySettings } from "utils/identity/identity-helpers";
import { widgetsResponse } from "utils/config/api-response";
import { getSettings } from "utils/config/config";
export default async function handler(req, res) {
res.send(await widgetsResponse());
const { provider } = readIdentitySettings(getSettings().identity);
res.send(await widgetsResponse(provider.getIdentity(req)));
}

View file

@ -1,5 +1,5 @@
/* eslint-disable react/no-array-index-key */
import useSWR, { SWRConfig } from "swr";
import useSWR, { unstable_serialize as unstableSerialize, SWRConfig } from "swr";
import Head from "next/head";
import Script from "next/script";
import dynamic from "next/dynamic";
@ -10,6 +10,7 @@ import { BiError } from "react-icons/bi";
import { serverSideTranslations } from "next-i18next/serverSideTranslations";
import { useRouter } from "next/router";
import NullIdentityProvider from "utils/identity/null";
import Tab, { slugifyAndEncode } from "components/tab";
import ServicesGroup from "components/services/group";
import BookmarksGroup from "components/bookmarks/group";
@ -26,6 +27,7 @@ import { bookmarksResponse, servicesResponse, widgetsResponse } from "utils/conf
import ErrorBoundary from "components/errorboundry";
import themes from "utils/styles/themes";
import QuickLaunch from "components/quicklaunch";
import { fetchWithIdentity, readIdentitySettings } from "utils/identity/identity-helpers";
const ThemeToggle = dynamic(() => import("components/toggles/theme"), {
ssr: false,
@ -41,25 +43,28 @@ const Version = dynamic(() => import("components/version"), {
const rightAlignedWidgets = ["weatherapi", "openweathermap", "weather", "openmeteo", "search", "datetime"];
export async function getStaticProps() {
export async function getServerSideProps({ req }) {
let logger;
try {
logger = createLogger("index");
const { providers, ...settings } = getSettings();
const { providers, identity, ...settings } = getSettings();
const { provider, groups } = readIdentitySettings(identity);
const services = await servicesResponse();
const bookmarks = await bookmarksResponse();
const widgets = await widgetsResponse();
const services = await servicesResponse(provider.getIdentity(req), groups);
const bookmarks = await bookmarksResponse(provider.getIdentity(req), groups);
const widgets = await widgetsResponse(provider.getIdentity(req));
const identityContext = provider.getContext(req);
return {
props: {
initialSettings: settings,
fallback: {
"/api/services": services,
"/api/bookmarks": bookmarks,
"/api/widgets": widgets,
[unstableSerialize(["/api/services", identityContext])]: services,
[unstableSerialize(["/api/bookmarks", identityContext])]: bookmarks,
[unstableSerialize(["/api/widgets", identityContext])]: widgets,
"/api/hash": false,
},
identityContext,
...(await serverSideTranslations(settings.language ?? "en")),
},
};
@ -67,22 +72,24 @@ export async function getStaticProps() {
if (logger && e) {
logger.error(e);
}
const identityContext = NullIdentityProvider.create().getContext(req);
return {
props: {
initialSettings: {},
fallback: {
"/api/services": [],
"/api/bookmarks": [],
"/api/widgets": [],
[unstableSerialize(["/api/services", identityContext])]: [],
[unstableSerialize(["/api/bookmarks", identityContext])]: [],
[unstableSerialize(["/api/widgets", identityContext])]: [],
"/api/hash": false,
},
identityContext,
...(await serverSideTranslations("en")),
},
};
}
}
function Index({ initialSettings, fallback }) {
function Index({ initialSettings, fallback, identityContext }) {
const windowFocused = useWindowFocus();
const [stale, setStale] = useState(false);
const { data: errorsData } = useSWR("/api/validate");
@ -152,7 +159,7 @@ function Index({ initialSettings, fallback }) {
return (
<SWRConfig value={{ fallback, fetcher: (resource, init) => fetch(resource, init).then((res) => res.json()) }}>
<ErrorBoundary>
<Home initialSettings={initialSettings} />
<Home initialSettings={initialSettings} identityContext={identityContext} />
</ErrorBoundary>
</SWRConfig>
);
@ -166,7 +173,7 @@ const headerStyles = {
boxedWidgets: "m-5 mb-0 sm:m-9 sm:mb-0 sm:mt-1",
};
function Home({ initialSettings }) {
function Home({ initialSettings, identityContext }) {
const { i18n } = useTranslation();
const { theme, setTheme } = useContext(ThemeContext);
const { color, setColor } = useContext(ColorContext);
@ -178,9 +185,9 @@ function Home({ initialSettings }) {
setSettings(initialSettings);
}, [initialSettings, setSettings]);
const { data: services } = useSWR("/api/services");
const { data: bookmarks } = useSWR("/api/bookmarks");
const { data: widgets } = useSWR("/api/widgets");
const { data: services } = useSWR(["/api/services", identityContext], fetchWithIdentity);
const { data: bookmarks } = useSWR(["/api/bookmarks", identityContext], fetchWithIdentity);
const { data: widgets } = useSWR(["/api/widgets", identityContext], fetchWithIdentity);
const servicesAndBookmarks = [
...services.map((sg) => sg.services).flat(),
@ -452,7 +459,7 @@ function Home({ initialSettings }) {
);
}
export default function Wrapper({ initialSettings, fallback }) {
export default function Wrapper({ initialSettings, fallback, identityContext }) {
const { theme } = useContext(ThemeContext);
const wrappedStyle = {};
let backgroundBlur = false;
@ -505,7 +512,7 @@ export default function Wrapper({ initialSettings, fallback }) {
backgroundBrightness && `backdrop-brightness-${initialSettings.background.brightness}`,
)}
>
<Index initialSettings={initialSettings} fallback={fallback} />
<Index initialSettings={initialSettings} fallback={fallback} identityContext={identityContext} />
</div>
</div>
</div>

View file

@ -13,6 +13,7 @@ import {
findGroupByName,
} from "utils/config/service-helpers";
import { cleanWidgetGroups, widgetsFromConfig } from "utils/config/widget-helpers";
import { filterAllowedBookmarks, filterAllowedServices, filterAllowedWidgets } from "utils/identity/identity-helpers";
/**
* Compares services by weight then by name.
@ -25,7 +26,7 @@ function compareServices(service1, service2) {
return service1.name.localeCompare(service2.name);
}
export async function bookmarksResponse() {
export async function bookmarksResponse(perms, idGroups) {
checkAndCopyConfig("bookmarks.yaml");
const bookmarksYaml = path.join(CONF_DIR, "bookmarks.yaml");
@ -46,13 +47,17 @@ export async function bookmarksResponse() {
}
// map easy to write YAML objects into easy to consume JS arrays
const bookmarksArray = bookmarks.map((group) => ({
name: Object.keys(group)[0],
bookmarks: group[Object.keys(group)[0]].map((entries) => ({
name: Object.keys(entries)[0],
...entries[Object.keys(entries)[0]][0],
const bookmarksArray = filterAllowedBookmarks(
perms,
idGroups,
bookmarks.map((group) => ({
name: Object.keys(group)[0],
bookmarks: group[Object.keys(group)[0]].map((entries) => ({
name: Object.keys(entries)[0],
...entries[Object.keys(entries)[0]][0],
})),
})),
}));
);
const sortedGroups = [];
const unsortedGroups = [];
@ -71,11 +76,11 @@ export async function bookmarksResponse() {
return [...sortedGroups.filter((g) => g), ...unsortedGroups];
}
export async function widgetsResponse() {
export async function widgetsResponse(perms) {
let configuredWidgets;
try {
configuredWidgets = cleanWidgetGroups(await widgetsFromConfig());
configuredWidgets = filterAllowedWidgets(perms, await cleanWidgetGroups(await widgetsFromConfig()));
} catch (e) {
console.error("Failed to load widgets, please check widgets.yaml for errors or remove example entries.");
if (e) console.error(e);
@ -96,14 +101,14 @@ function mergeSubgroups(configuredGroups, mergedGroup) {
});
}
export async function servicesResponse() {
export async function servicesResponse(perms, idGroups) {
let discoveredDockerServices;
let discoveredKubernetesServices;
let configuredServices;
let initialSettings;
try {
discoveredDockerServices = cleanServiceGroups(await servicesFromDocker());
discoveredDockerServices = filterAllowedServices(perms, idGroups, cleanServiceGroups(await servicesFromDocker()));
if (discoveredDockerServices?.length === 0) {
console.debug("No containers were found with homepage labels.");
}
@ -114,7 +119,11 @@ export async function servicesResponse() {
}
try {
discoveredKubernetesServices = cleanServiceGroups(await servicesFromKubernetes());
discoveredKubernetesServices = filterAllowedServices(
perms,
idGroups,
cleanServiceGroups(await servicesFromKubernetes()),
);
} catch (e) {
console.error("Failed to discover services, please check kubernetes.yaml for errors or remove example entries.");
if (e) console.error(e.toString());
@ -122,7 +131,7 @@ export async function servicesResponse() {
}
try {
configuredServices = cleanServiceGroups(await servicesFromConfig());
configuredServices = filterAllowedServices(perms, idGroups, cleanServiceGroups(await servicesFromConfig()));
} catch (e) {
console.error("Failed to load services.yaml, please check for errors");
if (e) console.error(e.toString());

View file

@ -301,6 +301,12 @@ export async function servicesFromKubernetes() {
if (ingress.metadata.annotations[`${ANNOTATION_BASE}/statusStyle`]) {
constructedService.statusStyle = ingress.metadata.annotations[`${ANNOTATION_BASE}/statusStyle`];
}
if (ingress.metadata.annotations[`${ANNOTATION_BASE}/allowUsers`]) {
constructedService.allowUsers = ingress.metadata.annotations[`${ANNOTATION_BASE}/allowUsers`].split(",");
}
if (ingress.metadata.annotations[`${ANNOTATION_BASE}/allowGroups`]) {
constructedService.allowGroups = ingress.metadata.annotations[`${ANNOTATION_BASE}/allowGroups`].split(",");
}
Object.keys(ingress.metadata.annotations).forEach((annotation) => {
if (annotation.startsWith(ANNOTATION_WIDGET_BASE)) {
shvl.set(

View file

@ -0,0 +1,70 @@
import ProxyIdentityProvider from "./proxy";
import NullIdentityProvider from "./null";
const IdentityProviders = {
null: NullIdentityProvider,
proxy: ProxyIdentityProvider,
};
function getProviderByKey(key) {
return IdentityProviders[key] || NullIdentityProvider;
}
function identityAllow({ user, groups }, item) {
const groupAllow =
"allowGroups" in item && item.allowGroups && groups.some((group) => item.allowGroups.includes(group));
const userAllow = "allowUsers" in item && item.allowUsers && item.allowUsers.includes(user);
const allowAll = !("allowGroups" in item && item.allowGroups) && !("allowUsers" in item && item.allowUsers);
return userAllow || groupAllow || allowAll;
}
export function checkAllowedGroup(perms, idGroups, groupName) {
const testGroup = idGroups.find((group) => group.name === groupName);
return testGroup ? identityAllow(perms, testGroup) : true;
}
function filterAllowedItems(perms, idGroups, groups, groupKey) {
return groups
.filter((group) => checkAllowedGroup(perms, idGroups, group.name))
.map((group) => ({
name: group.name,
[groupKey]: group[groupKey].filter((item) => identityAllow(perms, item)),
}))
.filter((group) => group[groupKey].length);
}
export function readIdentitySettings({ provider, groups } = {}) {
let groupArray = [];
if (groups) {
if (Array.isArray(groups)) {
groupArray = groups.map((group) => ({
name: Object.keys(group)[0],
allowUsers: group.allowUsers,
allowGroups: group.allowGroups,
}));
} else {
groupArray = Object.keys(groups).map((group) => ({
name: group,
allowUsers: groups[group].allowUsers,
allowGroups: groups[group].allowGroups,
}));
}
}
return {
provider: provider ? getProviderByKey(provider.type).create(provider) : NullIdentityProvider.create(),
groups: groupArray,
};
}
export async function fetchWithIdentity(key, context) {
return getProviderByKey(context.provider).fetch([key, context]);
}
export const filterAllowedServices = (perms, idGroups, services) =>
filterAllowedItems(perms, idGroups, services, "services");
export const filterAllowedBookmarks = (perms, idGroups, bookmarks) =>
filterAllowedItems(perms, idGroups, bookmarks, "bookmarks");
export const filterAllowedWidgets = (perms, widgets) =>
widgets.filter((widget) => identityAllow(perms, widget.options));

View file

@ -0,0 +1,21 @@
const NullIdentity = { user: null, groups: [] };
function createNullIdentity() {
return {
getIdentity: () => NullIdentity,
getContext: () => ({
provider: "null",
}),
};
}
async function fetchNullIdentity([key]) {
return fetch(key).then((res) => res.json());
}
const NullIdentityProvider = {
create: createNullIdentity,
fetch: fetchNullIdentity,
};
export default NullIdentityProvider;

View file

@ -0,0 +1,34 @@
// 'proxy' identity provider is meant to be used by a reverse proxy that injects permission headers into the origin
// request. In this case we are relying on our proxy to authenitcate our users and validate their identity.
function getProxyPermissions(userHeader, groupHeader, groupSeparator, request) {
const user =
userHeader && request.headers[userHeader.toLowerCase()] ? request.headers[userHeader.toLowerCase()] : null;
const groupsString =
groupHeader && request.headers[groupHeader.toLowerCase()] ? request.headers[groupHeader.toLowerCase()] : "";
return { user, groups: groupsString ? groupsString.split(groupSeparator ?? "|").map((v) => v.trim()) : [] };
}
function createProxyIdentity({ groupHeader, groupSeparator, userHeader }) {
return {
getContext: (request) => ({
provider: "proxy",
...(userHeader &&
request.headers[userHeader] && { [userHeader.toLowerCase()]: request.headers[userHeader.toLowerCase()] }),
...(groupHeader &&
request.headers[groupHeader] && { [groupHeader.toLowerCase()]: request.headers[groupHeader.toLowerCase()] }),
}),
getIdentity: (request) => getProxyPermissions(userHeader, groupHeader, groupSeparator, request),
};
}
async function fetchProxyIdentity([key, context]) {
return fetch(key, { headers: context.headers }).then((res) => res.json());
}
const ProxyIdentityProvider = {
create: createProxyIdentity,
fetch: fetchProxyIdentity,
};
export default ProxyIdentityProvider;