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} + + + + {uploadName} + ({ + position: 'absolute', + right: 8, + top: 8, + color: theme.palette.grey[500], + })} > - - - - - {label} - + + + + + + {uploadName} +
+ + +
+ + ); }; 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