diff --git a/.watchmanconfig b/.watchmanconfig
new file mode 100644
index 0000000..7ed8f4b
--- /dev/null
+++ b/.watchmanconfig
@@ -0,0 +1,5 @@
+{
+ "ignore_dirs": [
+ "testdata"
+ ]
+}
diff --git a/README.md b/README.md
index 8011b54..66543e8 100644
--- a/README.md
+++ b/README.md
@@ -61,6 +61,7 @@ The following changes are already implemented:
* [Restrict actions on specific users](https://github.com/etkecc/synapse-admin/pull/42)
* [Add `Contact support` menu item](https://github.com/etkecc/synapse-admin/pull/45)
* [Provide options to delete media and redact events on user erase](https://github.com/etkecc/synapse-admin/pull/49)
+* [Authenticated Media support](https://github.com/etkecc/synapse-admin/pull/51)
_the list will be updated as new changes are added_
diff --git a/src/components/AvatarField.test.tsx b/src/components/AvatarField.test.tsx
index 2ac9234..c9698f7 100644
--- a/src/components/AvatarField.test.tsx
+++ b/src/components/AvatarField.test.tsx
@@ -1,18 +1,43 @@
-import { render, screen } from "@testing-library/react";
+import { render, screen, waitFor } from "@testing-library/react";
import { RecordContextProvider } from "react-admin";
-
+import { act } from "react";
import AvatarField from "./AvatarField";
describe("AvatarField", () => {
- it("shows image", () => {
+ beforeEach(() => {
+ // Mock fetch
+ global.fetch = jest.fn(() =>
+ Promise.resolve({
+ blob: () => Promise.resolve(new Blob(["mock image data"], { type: 'image/jpeg' })),
+ })
+ ) as jest.Mock;
+
+ // Mock URL.createObjectURL
+ global.URL.createObjectURL = jest.fn(() => "mock-object-url");
+ });
+
+ afterEach(() => {
+ jest.restoreAllMocks();
+ });
+
+ it.only("shows image", async () => {
const value = {
- avatar: "foo",
+ avatar: "mxc://serverName/mediaId",
};
- render(
-
-
-
- );
- expect(screen.getByRole("img").getAttribute("src")).toBe("foo");
+
+ await act(async () => {
+ render(
+
+
+
+ );
+ });
+
+ await waitFor(() => {
+ const img = screen.getByRole("img");
+ expect(img.getAttribute("src")).toBe("mock-object-url");
+ });
+
+ expect(global.fetch).toHaveBeenCalled();
});
});
diff --git a/src/components/AvatarField.tsx b/src/components/AvatarField.tsx
index 0a8e232..9467664 100644
--- a/src/components/AvatarField.tsx
+++ b/src/components/AvatarField.tsx
@@ -1,12 +1,37 @@
import { get } from "lodash";
-
-import { Avatar } from "@mui/material";
+import { Avatar, AvatarProps } from "@mui/material";
import { useRecordContext } from "react-admin";
+import { useState, useEffect, useCallback } from "react";
+import { fetchAuthenticatedMedia } from "../utils/fetchMedia";
-const AvatarField = ({ source, ...rest }) => {
- const record = useRecordContext(rest);
- const src = get(record, source)?.toString();
+const AvatarField = ({ source, ...rest }: AvatarProps & { source: string, label?: string }) => {
const { alt, classes, sizes, sx, variant } = rest;
+
+ const record = useRecordContext(rest);
+ const mxcURL = get(record, source)?.toString();
+
+ const [src, setSrc] = useState("");
+
+ const fetchAvatar = useCallback(async (mxcURL: string) => {
+ const response = await fetchAuthenticatedMedia(mxcURL, "thumbnail");
+ const blob = await response.blob();
+ const blobURL = URL.createObjectURL(blob);
+ setSrc(blobURL);
+ }, []);
+
+ useEffect(() => {
+ if (mxcURL) {
+ fetchAvatar(mxcURL);
+ }
+
+ // Cleanup function to revoke the object URL
+ return () => {
+ if (src) {
+ URL.revokeObjectURL(src);
+ }
+ };
+ }, [mxcURL, fetchAvatar]);
+
return ;
};
diff --git a/src/components/media.tsx b/src/components/media.tsx
index d147477..afbc1c6 100644
--- a/src/components/media.tsx
+++ b/src/components/media.tsx
@@ -1,6 +1,7 @@
import { get } from "lodash";
import { useState } from "react";
+import Typography from "@mui/material/Typography";
import BlockIcon from "@mui/icons-material/Block";
import IconCancel from "@mui/icons-material/Cancel";
import ClearIcon from "@mui/icons-material/Clear";
@@ -8,7 +9,10 @@ import DeleteSweepIcon from "@mui/icons-material/DeleteSweep";
import FileOpenIcon from "@mui/icons-material/FileOpen";
import LockIcon from "@mui/icons-material/Lock";
import LockOpenIcon from "@mui/icons-material/LockOpen";
-import { Box, Dialog, DialogContent, DialogContentText, DialogTitle, Tooltip } from "@mui/material";
+import IconButton from '@mui/material/IconButton';
+import CloseIcon from '@mui/icons-material/Close';
+import ZoomInIcon from '@mui/icons-material/ZoomIn';
+import { Box, Dialog, DialogContent, DialogContentText, DialogTitle, Tooltip, Link } from "@mui/material";
import { alpha, useTheme } from "@mui/material/styles";
import {
BooleanInput,
@@ -29,12 +33,11 @@ import {
useTranslate,
} from "react-admin";
import { useMutation } from "@tanstack/react-query";
-import { Link } from "react-router-dom";
import { dateParser } from "./date";
import { DeleteMediaParams, SynapseDataProvider } from "../synapse/dataProvider";
-import { getMediaUrl } from "../synapse/synapse";
import storage from "../storage";
+import { fetchAuthenticatedMedia } from "../utils/fetchMedia";
const DeleteMediaDialog = ({ open, onClose, onSubmit }) => {
const translate = useTranslate();
@@ -311,48 +314,125 @@ export const QuarantineMediaButton = (props: ButtonProps) => {
);
};
-export const ViewMediaButton = ({ media_id, label }) => {
+export const ViewMediaButton = ({ mxcURL, uploadName, label }) => {
const translate = useTranslate();
- const url = getMediaUrl(media_id);
+
+ const [open, setOpen] = useState(false);
+ const [blobURL, setBlobURL] = useState("");
+
+ const handleOpen = () => setOpen(true);
+ const handleClose = () => {
+ setOpen(false);
+ if (blobURL) {
+ URL.revokeObjectURL(blobURL);
+ }
+ };
+
+ const forceDownload = (url: string, filename: string) => {
+ const anchorElement = document.createElement("a");
+ anchorElement.href = url;
+ anchorElement.download = filename;
+ document.body.appendChild(anchorElement);
+ anchorElement.click();
+ document.body.removeChild(anchorElement);
+ URL.revokeObjectURL(blobURL);
+ };
+
+ const handleFile = async () => {
+ const response = await fetchAuthenticatedMedia(mxcURL, "original");
+ const blob = await response.blob();
+ const blobURL = URL.createObjectURL(blob);
+ setBlobURL(blobURL);
+
+ const mimeType = blob.type;
+ if (!mimeType.startsWith("image/")) {
+ forceDownload(blobURL, uploadName);
+ } else {
+ handleOpen();
+ }
+ };
+
return (
-
-
-
+ <>
+
+
+
+
+
+ {label}
+
+
-
- {label}
-
+
+
+
+
+
+
+
+
+
+
+
+ >
);
};
export const MediaIDField = ({ source }) => {
+ const record = useRecordContext();
+ if (!record) {
+ return null;
+ }
const homeserver = storage.getItem("home_server");
- const record = useRecordContext();
- if (!record) return null;
- const src = get(record, source)?.toString();
- if (!src) return null;
+ const mediaID = get(record, source)?.toString();
+ if (!mediaID) {
+ return null;
+ }
- return ;
+ const mxcURL = `mxc://${homeserver}/${mediaID}`;
+ const uploadName = decodeURIComponent(get(record, "upload_name")?.toString());
+
+ return ;
};
-export const MXCField = ({ source }) => {
+export const ReportMediaContent = ({ source }) => {
const record = useRecordContext();
- if (!record) return null;
+ if (!record) {
+ return null;
+ }
- const src = get(record, source)?.toString();
- if (!src) return null;
+ const mxcURL = get(record, source)?.toString();
+ if (!mxcURL) {
+ return null;
+ }
- const media_id = src.replace("mxc://", "");
+ const uploadName = decodeURIComponent(record.event_json.content.body);
- return ;
+ return ;
};
diff --git a/src/resources/reports.tsx b/src/resources/reports.tsx
index 2f7ec2f..d2ca6be 100644
--- a/src/resources/reports.tsx
+++ b/src/resources/reports.tsx
@@ -22,7 +22,7 @@ import {
} from "react-admin";
import { DATE_FORMAT } from "../components/date";
-import { MXCField } from "../components/media";
+import { ReportMediaContent } from "../components/media";
const ReportPagination = () => ;
@@ -62,7 +62,7 @@ export const ReportShow = (props: ShowProps) => {
-
+
diff --git a/src/resources/room_directory.tsx b/src/resources/room_directory.tsx
index 5664920..a24c872 100644
--- a/src/resources/room_directory.tsx
+++ b/src/resources/room_directory.tsx
@@ -26,9 +26,9 @@ import {
useUnselectAll,
} from "react-admin";
import { useMutation } from "@tanstack/react-query";
-
import AvatarField from "../components/AvatarField";
+
const RoomDirectoryPagination = () => ;
export const RoomDirectoryUnpublishButton = (props: DeleteButtonProps) => {
@@ -144,7 +144,6 @@ export const RoomDirectoryList = () => (
>
diff --git a/src/resources/rooms.tsx b/src/resources/rooms.tsx
index 2905ff6..53e5d30 100644
--- a/src/resources/rooms.tsx
+++ b/src/resources/rooms.tsx
@@ -47,6 +47,7 @@ import {
} from "./room_directory";
import { DATE_FORMAT } from "../components/date";
import DeleteRoomButton from "../components/DeleteRoomButton";
+import AvatarField from "../components/AvatarField";
const RoomPagination = () => ;
@@ -90,6 +91,11 @@ export const RoomShow = (props: ShowProps) => {
} title={}>
}>
+
diff --git a/src/resources/user_media_statistics.tsx b/src/resources/user_media_statistics.tsx
index 8b84370..596f0dd 100644
--- a/src/resources/user_media_statistics.tsx
+++ b/src/resources/user_media_statistics.tsx
@@ -1,4 +1,4 @@
-import EqualizerIcon from "@mui/icons-material/Equalizer";
+import PermMediaIcon from "@mui/icons-material/PermMedia";
import {
Datagrid,
ExportButton,
@@ -48,7 +48,7 @@ export const UserMediaStatsList = (props: ListProps) => (
const resource: ResourceProps = {
name: "user_media_statistics",
- icon: EqualizerIcon,
+ icon: PermMediaIcon,
list: UserMediaStatsList,
};
diff --git a/src/resources/users.tsx b/src/resources/users.tsx
index 7a3830e..ff99282 100644
--- a/src/resources/users.tsx
+++ b/src/resources/users.tsx
@@ -56,6 +56,7 @@ import {
RaRecord,
ImageInput,
ImageField,
+ FunctionField,
} from "react-admin";
import { Link } from "react-router-dom";
@@ -90,11 +91,6 @@ const UserListActions = () => {
);
};
-UserListActions.defaultProps = {
- selectedIds: [],
- onUnselectItems: () => null,
-};
-
const UserPagination = () => ;
const userFilters = [
@@ -165,7 +161,7 @@ export const UserList = (props: ListProps) => (
pagination={}
>
}>
-
+
@@ -323,14 +319,19 @@ export const UserEdit = (props: EditProps) => {
} actions={} mutationMode="pessimistic">
}>
}>
-
+
-
+
@@ -404,8 +405,8 @@ export const UserEdit = (props: EditProps) => {
-
-
+
+ decodeURIComponent(record.upload_name)} />
diff --git a/src/synapse/authProvider.ts b/src/synapse/authProvider.ts
index 438334c..19cb4ce 100644
--- a/src/synapse/authProvider.ts
+++ b/src/synapse/authProvider.ts
@@ -96,8 +96,13 @@ const authProvider: AuthProvider = {
};
if (typeof access_token === "string") {
- await fetchUtils.fetchJson(logout_api_url, options);
- storage.removeItem("access_token");
+ try {
+ await fetchUtils.fetchJson(logout_api_url, options);
+ } catch (err) {
+ console.log("Error logging out", err);
+ } finally {
+ storage.removeItem("access_token");
+ }
}
},
// called when the API returns an error
diff --git a/src/synapse/dataProvider.ts b/src/synapse/dataProvider.ts
index 235ca58..9193586 100644
--- a/src/synapse/dataProvider.ts
+++ b/src/synapse/dataProvider.ts
@@ -42,17 +42,6 @@ const jsonClient = async (url: string, options: Options = {}) => {
}
};
-const mxcUrlToHttp = (mxcUrl: string) => {
- const homeserver = storage.getItem("base_url");
- const re = /^mxc:\/\/([^/]+)\/(\w+)/;
- const ret = re.exec(mxcUrl);
- console.log("mxcClient " + ret);
- if (ret == null) return null;
- const serverName = ret[1];
- const mediaId = ret[2];
- return `${homeserver}/_matrix/media/r0/thumbnail/${serverName}/${mediaId}?width=24&height=24&method=scale`;
-};
-
const filterUndefined = (obj: Record) => {
return Object.fromEntries(Object.entries(obj).filter(([key, value]) => value !== undefined));
};
@@ -267,10 +256,10 @@ export interface SynapseDataProvider extends DataProvider {
const resourceMap = {
users: {
path: "/_synapse/admin/v2/users",
- map: (u: User) => ({
+ map: async (u: User) => ({
...u,
id: returnMXID(u.name),
- avatar_src: u.avatar_url ? mxcUrlToHttp(u.avatar_url) : undefined,
+ avatar_src: u.avatar_url ? u.avatar_url : undefined,
is_guest: !!u.is_guest,
admin: !!u.admin,
deactivated: !!u.deactivated,
@@ -458,7 +447,7 @@ const resourceMap = {
id: rd.room_id,
public: !!rd.public,
guest_access: !!rd.guest_access,
- avatar_src: rd.avatar_url ? mxcUrlToHttp(rd.avatar_url) : undefined,
+ avatar_src: rd.avatar_url ? rd.avatar_url : undefined,
}),
data: "chunk",
total: json => json.total_room_count_estimate,
@@ -564,8 +553,10 @@ const baseDataProvider: SynapseDataProvider = {
const url = `${endpoint_url}?${new URLSearchParams(filterUndefined(query)).toString()}`;
const { json } = await jsonClient(url);
+ const formattedData = await json[res.data].map(res.map);
+
return {
- data: json[res.data].map(res.map),
+ data: formattedData,
total: res.total(json, from, perPage),
};
},
diff --git a/src/synapse/synapse.ts b/src/synapse/synapse.ts
index 2e49501..afe09a3 100644
--- a/src/synapse/synapse.ts
+++ b/src/synapse/synapse.ts
@@ -54,11 +54,6 @@ export const getSupportedLoginFlows = async baseUrl => {
return response.json.flows;
};
-export const getMediaUrl = media_id => {
- const baseUrl = storage.getItem("base_url");
- return `${baseUrl}/_matrix/media/v1/download/${media_id}?allow_redirect=true`;
-};
-
/**
* Generate a random MXID for current homeserver
* @returns full MXID as string
diff --git a/src/utils/fetchMedia.ts b/src/utils/fetchMedia.ts
new file mode 100644
index 0000000..87972e2
--- /dev/null
+++ b/src/utils/fetchMedia.ts
@@ -0,0 +1,43 @@
+import storage from "../storage";
+
+export const getServerAndMediaIdFromMxcUrl = (mxcUrl: string): { serverName: string, mediaId: string } => {
+ const re = /^mxc:\/\/([^/]+)\/(\w+)/;
+ const ret = re.exec(mxcUrl);
+ console.log("mxcClient " + ret);
+ if (ret == null) {
+ throw new Error("Invalid mxcUrl");
+ }
+ const serverName = ret[1];
+ const mediaId = ret[2];
+ return { serverName, mediaId };
+};
+
+export type MediaType = "thumbnail" | "original";
+
+export const fetchAuthenticatedMedia = async (mxcUrl: string, type: MediaType): Promise => {
+ const homeserver = storage.getItem("base_url");
+ const accessToken = storage.getItem("access_token");
+
+ const { serverName, mediaId } = getServerAndMediaIdFromMxcUrl(mxcUrl);
+ if (!serverName || !mediaId) {
+ throw new Error("Invalid mxcUrl");
+ }
+
+ let url = "";
+ if (type === "thumbnail") {
+ // ref: https://spec.matrix.org/latest/client-server-api/#thumbnails
+ url = `${homeserver}/_matrix/client/v1/media/thumbnail/${serverName}/${mediaId}?width=320&height=240&method=scale`;
+ } else if (type === "original") {
+ url = `${homeserver}/_matrix/client/v1/media/download/${serverName}/${mediaId}`;
+ } else {
+ throw new Error("Invalid authenticated media type");
+ }
+
+ const response = await fetch(`${url}`, {
+ headers: {
+ authorization: `Bearer ${accessToken}`,
+ },
+ });
+
+ return response;
+};
\ No newline at end of file