Mobile sync support (#4173)
* WIP on mobile connections todo: register devices todo: data sync or connection * improve connection flow and registration add streaming from service TODO: user scoping * dev build mobile support * fix path * handle relative URLs * handle localhost access in product * add device de-register * sync styles * move UI to be out of the normal path since beta only * Add user scoping to mobile connection requests Remigrate DB for user associations Implement temp token registration to prevent unauthorized device registration requests cleanup middlewares
This commit is contained in:
parent
755ef4bb80
commit
c218a0dfe3
2
.github/workflows/dev-build.yaml
vendored
2
.github/workflows/dev-build.yaml
vendored
@ -6,7 +6,7 @@ concurrency:
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: ['multilingual-native-embedder-selection'] # put your current branch to create a build. Core team only.
|
||||
branches: ['mobile-support'] # put your current branch to create a build. Core team only.
|
||||
paths-ignore:
|
||||
- '**.md'
|
||||
- 'cloud-deployments/*'
|
||||
|
||||
@ -28,6 +28,7 @@
|
||||
"moment": "^2.30.1",
|
||||
"onnxruntime-web": "^1.18.0",
|
||||
"pluralize": "^8.0.0",
|
||||
"qrcode.react": "^4.2.0",
|
||||
"react": "^18.2.0",
|
||||
"react-beautiful-dnd": "13.1.1",
|
||||
"react-confetti-explosion": "^2.1.2",
|
||||
@ -74,4 +75,4 @@
|
||||
"tailwindcss": "^3.3.1",
|
||||
"vite": "^4.3.0"
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -89,6 +89,9 @@ const CommunityHubImportItem = lazy(
|
||||
const SystemPromptVariables = lazy(
|
||||
() => import("@/pages/Admin/SystemPromptVariables")
|
||||
);
|
||||
const MobileConnections = lazy(
|
||||
() => import("@/pages/GeneralSettings/MobileConnections")
|
||||
);
|
||||
|
||||
export default function App() {
|
||||
return (
|
||||
@ -264,6 +267,11 @@ export default function App() {
|
||||
path="/settings/community-hub/import-item"
|
||||
element={<AdminRoute Component={CommunityHubImportItem} />}
|
||||
/>
|
||||
|
||||
<Route
|
||||
path="/settings/mobile-connections"
|
||||
element={<ManagerRoute Component={MobileConnections} />}
|
||||
/>
|
||||
</Routes>
|
||||
<ToastContainer />
|
||||
<KeyboardShortcutsHelp />
|
||||
|
||||
70
frontend/src/models/mobile.js
Normal file
70
frontend/src/models/mobile.js
Normal file
@ -0,0 +1,70 @@
|
||||
import { API_BASE } from "@/utils/constants";
|
||||
import { baseHeaders } from "@/utils/request";
|
||||
|
||||
/**
|
||||
* @typedef {Object} MobileConnection
|
||||
* @property {string} id - The database ID of the device.
|
||||
* @property {string} deviceId - The device ID of the device.
|
||||
* @property {string} deviceOs - The operating system of the device.
|
||||
* @property {boolean} approved - Whether the device is approved.
|
||||
* @property {string} createdAt - The date and time the device was created.
|
||||
*/
|
||||
|
||||
const MobileConnection = {
|
||||
/**
|
||||
* Get the connection info for the mobile app.
|
||||
* @returns {Promise<{connectionUrl: string|null}>} The connection info.
|
||||
*/
|
||||
getConnectionInfo: async function () {
|
||||
return await fetch(`${API_BASE}/mobile/connect-info`, {
|
||||
headers: baseHeaders(),
|
||||
})
|
||||
.then((res) => res.json())
|
||||
.catch(() => false);
|
||||
},
|
||||
|
||||
/**
|
||||
* Get all the devices from the database.
|
||||
* @returns {Promise<MobileDevice[]>} The devices.
|
||||
*/
|
||||
getDevices: async function () {
|
||||
return await fetch(`${API_BASE}/mobile/devices`, {
|
||||
headers: baseHeaders(),
|
||||
})
|
||||
.then((res) => res.json())
|
||||
.then((res) => res.devices || [])
|
||||
.catch(() => []);
|
||||
},
|
||||
|
||||
/**
|
||||
* Delete a device from the database.
|
||||
* @param {string} deviceId - The database ID of the device to delete.
|
||||
* @returns {Promise<{message: string}>} The deleted device.
|
||||
*/
|
||||
deleteDevice: async function (id) {
|
||||
return await fetch(`${API_BASE}/mobile/${id}`, {
|
||||
method: "DELETE",
|
||||
headers: baseHeaders(),
|
||||
})
|
||||
.then((res) => res.json())
|
||||
.catch(() => false);
|
||||
},
|
||||
|
||||
/**
|
||||
* Update a device in the database.
|
||||
* @param {string} id - The database ID of the device to update.
|
||||
* @param {Object} updates - The updates to apply to the device.
|
||||
* @returns {Promise<{updates: MobileDevice}>} The updated device.
|
||||
*/
|
||||
updateDevice: async function (id, updates = {}) {
|
||||
return await fetch(`${API_BASE}/mobile/update/${id}`, {
|
||||
method: "POST",
|
||||
body: JSON.stringify(updates),
|
||||
headers: baseHeaders(),
|
||||
})
|
||||
.then((res) => res.json())
|
||||
.catch(() => false);
|
||||
},
|
||||
};
|
||||
|
||||
export default MobileConnection;
|
||||
@ -1,4 +1,5 @@
|
||||
import LiveSyncToggle from "./Features/LiveSync/toggle";
|
||||
import paths from "@/utils/paths";
|
||||
|
||||
export const configurableFeatures = {
|
||||
experimental_live_file_sync: {
|
||||
@ -6,4 +7,10 @@ export const configurableFeatures = {
|
||||
component: LiveSyncToggle,
|
||||
key: "experimental_live_file_sync",
|
||||
},
|
||||
experimental_mobile_connections: {
|
||||
title: "AnythingLLM Mobile",
|
||||
href: paths.settings.mobileConnections(),
|
||||
key: "experimental_mobile_connections",
|
||||
autoEnabled: true,
|
||||
},
|
||||
};
|
||||
|
||||
@ -131,18 +131,32 @@ function FeatureList({
|
||||
? "bg-white/10 light:bg-theme-bg-sidebar "
|
||||
: ""
|
||||
}`}
|
||||
onClick={() => handleClick?.(feature)}
|
||||
onClick={() => {
|
||||
if (settings?.href) window.location.replace(settings.href);
|
||||
else handleClick?.(feature);
|
||||
}}
|
||||
>
|
||||
<div className="text-sm font-light">{settings.title}</div>
|
||||
<div className="flex items-center gap-x-2">
|
||||
<div className="text-sm text-theme-text-secondary font-medium">
|
||||
{activeFeatures.includes(settings.key) ? "On" : "Off"}
|
||||
</div>
|
||||
<CaretRight
|
||||
size={14}
|
||||
weight="bold"
|
||||
className="text-theme-text-secondary"
|
||||
/>
|
||||
{settings.autoEnabled ? (
|
||||
<>
|
||||
<div className="text-sm text-theme-text-secondary font-medium">
|
||||
On
|
||||
</div>
|
||||
<div className="w-[14px]" />
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<div className="text-sm text-theme-text-secondary font-medium">
|
||||
{activeFeatures.includes(settings.key) ? "On" : "Off"}
|
||||
</div>
|
||||
<CaretRight
|
||||
size={14}
|
||||
weight="bold"
|
||||
className="text-theme-text-secondary"
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 329 KiB |
@ -0,0 +1,149 @@
|
||||
import { X } from "@phosphor-icons/react";
|
||||
import ModalWrapper from "@/components/ModalWrapper";
|
||||
import BG from "./bg.png";
|
||||
import { QRCodeSVG } from "qrcode.react";
|
||||
import { Link } from "react-router-dom";
|
||||
import { useEffect, useState } from "react";
|
||||
import MobileConnection from "@/models/mobile";
|
||||
import PreLoader from "@/components/Preloader";
|
||||
import Logo from "@/media/logo/anything-llm-infinity.png";
|
||||
|
||||
export default function MobileConnectModal({ isOpen, onClose }) {
|
||||
return (
|
||||
<ModalWrapper isOpen={isOpen}>
|
||||
<div
|
||||
className="relative w-full rounded-lg shadow"
|
||||
style={{
|
||||
minHeight: "60vh",
|
||||
maxWidth: "70vw",
|
||||
backgroundImage: `url(${BG})`,
|
||||
backgroundSize: "cover",
|
||||
backgroundPosition: "center",
|
||||
}}
|
||||
>
|
||||
<button
|
||||
onClick={onClose}
|
||||
type="button"
|
||||
className="absolute top-4 right-4 transition-all duration-300 bg-transparent rounded-lg text-sm p-1 inline-flex items-center hover:bg-theme-modal-border hover:border-theme-modal-border hover:border-opacity-50 border-transparent border"
|
||||
>
|
||||
<X size={24} weight="bold" className="text-[#FFF]" />
|
||||
</button>
|
||||
|
||||
<div className="flex w-full h-full justify-between p-[35px]">
|
||||
{/* left column */}
|
||||
<div className="flex flex-col w-1/2 gap-y-[16px]">
|
||||
<p className="text-[#FFF] text-xl font-bold">
|
||||
Go mobile. Stay local. AnythingLLM Mobile.
|
||||
</p>
|
||||
<p className="text-[#FFF] text-lg">
|
||||
AnythingLLM for mobile allows you to connect or clone your
|
||||
workspace's chats, threads and documents for you to use on the go.
|
||||
<br />
|
||||
<br />
|
||||
Run with local models on your phone privately or relay chats
|
||||
directly to this instance seamlessly.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* right column */}
|
||||
<div className="flex flex-col items-center justify-center shrink-0 w-1/2 gap-y-[16px]">
|
||||
<div className="bg-white/10 rounded-lg p-[40px] w-[300px] h-[300px] flex flex-col gap-y-[16px] items-center justify-center">
|
||||
<ConnectionQrCode isOpen={isOpen} />
|
||||
</div>
|
||||
<p className="text-[#FFF] text-sm w-[300px] text-center">
|
||||
Scan the QR code with the AnythingLLM Mobile app to enable live
|
||||
sync of your workspaces, chats, threads and documents.
|
||||
<br />
|
||||
<Link
|
||||
to="https://docs.anythingllm.com/mobile"
|
||||
className="text-cta-button font-semibold"
|
||||
>
|
||||
Learn more
|
||||
</Link>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</ModalWrapper>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Process the connection url to make it absolute if it is a relative path
|
||||
* @param {string} url
|
||||
* @returns {string}
|
||||
*/
|
||||
function processConnectionUrl(url) {
|
||||
/*
|
||||
* In dev mode, the connectionURL() method uses the `ip` module
|
||||
* see server/models/mobileDevice.js `connectionURL()` method.
|
||||
*
|
||||
* In prod mode, this method returns the absolute path since we will always want to use
|
||||
* the real instance hostname. If the domain changes, we should be able to inherit it from the client side
|
||||
* since the backend has no knowledge of the domain since typically it is run behind a reverse proxy or in a container - or both.
|
||||
* So `ip` is useless in prod mode since it would only resolve to the internal IP address of the container or if non-containerized,
|
||||
* the local IP address may not be the preferred instance access point (eg: using custom domain)
|
||||
*
|
||||
* If the url does not start with http, we assume it is a relative path and add the origin to it.
|
||||
* Then we check if the hostname is localhost, 127.0.0.1, or 0.0.0.0. If it is, we throw an error since that is not
|
||||
* a LAN resolvable address that other devices can use to connect to the instance.
|
||||
*/
|
||||
if (url.startsWith("http")) return new URL(url);
|
||||
const connectionUrl = new URL(`${window.location.origin}${url}`);
|
||||
if (["localhost", "127.0.0.1", "0.0.0.0"].includes(connectionUrl.hostname))
|
||||
throw new Error(
|
||||
"Please open this page via your machines private IP address or custom domain. Localhost URLs will not work with the mobile app."
|
||||
);
|
||||
return connectionUrl.toString();
|
||||
}
|
||||
|
||||
const ConnectionQrCode = ({ isOpen }) => {
|
||||
const [connectionInfo, setConnectionInfo] = useState(null);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [error, setError] = useState(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isOpen) return;
|
||||
setIsLoading(true);
|
||||
MobileConnection.getConnectionInfo()
|
||||
.then((res) => {
|
||||
if (res.error) throw new Error(res.error);
|
||||
const url = processConnectionUrl(res.connectionUrl);
|
||||
setConnectionInfo(url);
|
||||
})
|
||||
.catch((err) => {
|
||||
setError(err.message);
|
||||
})
|
||||
.finally(() => {
|
||||
setIsLoading(false);
|
||||
});
|
||||
}, [isOpen]);
|
||||
|
||||
if (isLoading) return <PreLoader size="[100px]" />;
|
||||
if (error)
|
||||
return (
|
||||
<p className="text-red-500 text-sm w-[300px] p-4 text-center">{error}</p>
|
||||
);
|
||||
|
||||
const size = {
|
||||
width: 35 * 1.5,
|
||||
height: 22 * 1.5,
|
||||
};
|
||||
return (
|
||||
<QRCodeSVG
|
||||
value={connectionInfo}
|
||||
size={300}
|
||||
bgColor="transparent"
|
||||
fgColor="white"
|
||||
level="L"
|
||||
imageSettings={{
|
||||
src: Logo,
|
||||
x: 300 / 2 - size.width / 2,
|
||||
y: 300 / 2 - size.height / 2,
|
||||
height: size.height,
|
||||
width: size.width,
|
||||
excavate: true,
|
||||
}}
|
||||
/>
|
||||
);
|
||||
};
|
||||
@ -0,0 +1,90 @@
|
||||
import showToast from "@/utils/toast";
|
||||
import MobileConnection from "@/models/mobile";
|
||||
import { useState } from "react";
|
||||
import moment from "moment";
|
||||
import { BugDroid, AppleLogo } from "@phosphor-icons/react";
|
||||
import { Link } from "react-router-dom";
|
||||
import paths from "@/utils/paths";
|
||||
|
||||
export default function DeviceRow({ device, removeDevice }) {
|
||||
const [status, setStatus] = useState(device.approved);
|
||||
|
||||
const handleApprove = async () => {
|
||||
await MobileConnection.updateDevice(device.id, { approved: true });
|
||||
showToast("Device access granted", "info");
|
||||
setStatus(true);
|
||||
};
|
||||
|
||||
const handleDeny = async () => {
|
||||
await MobileConnection.deleteDevice(device.id);
|
||||
showToast("Device access denied", "info");
|
||||
setStatus(false);
|
||||
removeDevice(device.id);
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<tr className="bg-transparent text-white text-opacity-80 text-xs font-medium border-b border-white/10 h-10">
|
||||
<td scope="row" className="px-6 whitespace-nowrap">
|
||||
<div className="flex items-center gap-x-2">
|
||||
{device.deviceOs === "ios" ? (
|
||||
<AppleLogo
|
||||
weight="fill"
|
||||
size={16}
|
||||
className="fill-theme-text-primary"
|
||||
/>
|
||||
) : (
|
||||
<BugDroid
|
||||
weight="fill"
|
||||
size={16}
|
||||
className="fill-theme-text-primary"
|
||||
/>
|
||||
)}
|
||||
<span className="text-sm">{device.deviceName}</span>
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-6">
|
||||
<div className="flex items-center gap-x-2">
|
||||
{moment(device.createdAt).format("lll")}
|
||||
{device.user && (
|
||||
<div className="flex items-center gap-x-1">
|
||||
<span className="text-xs text-theme-text-secondary">by</span>
|
||||
<Link
|
||||
to={paths.settings.users()}
|
||||
className="text-xs text-theme-text-secondary hover:underline hover:text-cta-button"
|
||||
>
|
||||
{device.user.username}
|
||||
</Link>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-6 flex items-center gap-x-6 h-full mt-1">
|
||||
{status ? (
|
||||
<button
|
||||
onClick={handleDeny}
|
||||
className={`border-none flex items-center justify-center text-xs font-medium text-white/80 light:text-black/80 rounded-lg p-1 hover:bg-white hover:light:bg-red-50 hover:bg-opacity-10`}
|
||||
>
|
||||
Revoke
|
||||
</button>
|
||||
) : (
|
||||
<>
|
||||
<button
|
||||
onClick={handleApprove}
|
||||
className={`border-none flex items-center justify-center text-xs font-medium text-white/80 light:text-black/80 rounded-lg p-1 hover:bg-white hover:bg-opacity-10 hover:light:bg-green-50 hover:light:text-green-500 hover:text-green-300`}
|
||||
>
|
||||
Approve Access
|
||||
</button>
|
||||
<button
|
||||
onClick={handleDeny}
|
||||
className={`border-none flex items-center justify-center text-xs font-medium text-white/80 light:text-black/80 rounded-lg p-1 hover:bg-white hover:bg-opacity-10 hover:light:bg-red-50 hover:light:text-red-500 hover:text-red-300`}
|
||||
>
|
||||
Deny
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
</>
|
||||
);
|
||||
}
|
||||
123
frontend/src/pages/GeneralSettings/MobileConnections/index.jsx
Normal file
123
frontend/src/pages/GeneralSettings/MobileConnections/index.jsx
Normal file
@ -0,0 +1,123 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import Sidebar from "@/components/SettingsSidebar";
|
||||
import * as Skeleton from "react-loading-skeleton";
|
||||
import "react-loading-skeleton/dist/skeleton.css";
|
||||
import { QrCode } from "@phosphor-icons/react";
|
||||
import { useModal } from "@/hooks/useModal";
|
||||
import CTAButton from "@/components/lib/CTAButton";
|
||||
import MobileConnection from "@/models/mobile";
|
||||
import ConnectionModal from "./ConnectionModal";
|
||||
import DeviceRow from "./DeviceRow";
|
||||
import { isMobile } from "react-device-detect";
|
||||
|
||||
export default function MobileDevices() {
|
||||
const { isOpen, openModal, closeModal } = useModal();
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [devices, setDevices] = useState([]);
|
||||
|
||||
const fetchDevices = async () => {
|
||||
const foundDevices = await MobileConnection.getDevices();
|
||||
setDevices(foundDevices);
|
||||
if (foundDevices.length !== 0 && !isOpen) closeModal();
|
||||
return foundDevices;
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
fetchDevices()
|
||||
.then((devices) => {
|
||||
if (devices.length === 0) openModal();
|
||||
return devices;
|
||||
})
|
||||
.finally(() => {
|
||||
setLoading(false);
|
||||
});
|
||||
|
||||
const interval = setInterval(fetchDevices, 5_000);
|
||||
return () => clearInterval(interval);
|
||||
}, []);
|
||||
|
||||
const removeDevice = (id) => {
|
||||
setDevices((prevDevices) =>
|
||||
prevDevices.filter((device) => device.id !== id)
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="w-screen h-screen overflow-hidden bg-theme-bg-container flex md:mt-0 mt-6">
|
||||
<Sidebar />
|
||||
<div
|
||||
style={{ height: isMobile ? "100%" : "calc(100% - 32px)" }}
|
||||
className="relative md:ml-[2px] md:mr-[16px] md:my-[16px] md:rounded-[16px] bg-theme-bg-secondary w-full h-full overflow-y-scroll p-4 md:p-0"
|
||||
>
|
||||
<div className="flex flex-col w-full px-1 md:pl-6 md:pr-[50px] md:py-6 py-16">
|
||||
<div className="w-full flex flex-col gap-y-1 pb-6 border-white/10 border-b-2">
|
||||
<div className="items-center flex gap-x-4">
|
||||
<p className="text-lg leading-6 font-bold text-theme-text-primary">
|
||||
Connected Mobile Devices
|
||||
</p>
|
||||
</div>
|
||||
<p className="text-xs leading-[18px] font-base text-theme-text-secondary mt-2">
|
||||
These are the devices that are connected to your desktop
|
||||
application to sync chats, workspaces, and more.
|
||||
</p>
|
||||
</div>
|
||||
<div className="w-full justify-end flex">
|
||||
<CTAButton
|
||||
onClick={openModal}
|
||||
className="mt-3 mr-0 mb-4 md:-mb-14 z-10"
|
||||
>
|
||||
<QrCode className="h-4 w-4" weight="bold" /> Register New Device
|
||||
</CTAButton>
|
||||
</div>
|
||||
<div className="overflow-x-auto mt-6">
|
||||
{loading ? (
|
||||
<Skeleton.default
|
||||
height="80vh"
|
||||
width="100%"
|
||||
highlightColor="var(--theme-bg-primary)"
|
||||
baseColor="var(--theme-bg-secondary)"
|
||||
count={1}
|
||||
className="w-full p-4 rounded-b-2xl rounded-tr-2xl rounded-tl-sm"
|
||||
containerClassName="flex w-full"
|
||||
/>
|
||||
) : (
|
||||
<table className="w-full text-xs text-left rounded-lg min-w-[640px] border-spacing-0">
|
||||
<thead className="text-theme-text-secondary text-xs leading-[18px] font-bold uppercase border-white/10 border-b">
|
||||
<tr>
|
||||
<th scope="col" className="px-6 py-3">
|
||||
Device Name
|
||||
</th>
|
||||
<th scope="col" className="px-6 py-3">
|
||||
Registered
|
||||
</th>
|
||||
<th scope="col" className="px-6 py-3">
|
||||
{" "}
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{devices.length === 0 ? (
|
||||
<tr className="bg-transparent text-theme-text-secondary text-sm font-medium">
|
||||
<td colSpan="4" className="px-6 py-4 text-center">
|
||||
No devices found
|
||||
</td>
|
||||
</tr>
|
||||
) : (
|
||||
devices.map((device) => (
|
||||
<DeviceRow
|
||||
key={device.id}
|
||||
device={device}
|
||||
removeDevice={removeDevice}
|
||||
/>
|
||||
))
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<ConnectionModal isOpen={isOpen} onClose={closeModal} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@ -164,6 +164,9 @@ export default {
|
||||
experimental: () => {
|
||||
return `/settings/beta-features`;
|
||||
},
|
||||
mobileConnections: () => {
|
||||
return `/settings/mobile-connections`;
|
||||
},
|
||||
},
|
||||
agents: {
|
||||
builder: () => {
|
||||
|
||||
@ -3136,6 +3136,11 @@ punycode@^2.1.0:
|
||||
resolved "https://registry.yarnpkg.com/punycode/-/punycode-2.3.1.tgz#027422e2faec0b25e1549c3e1bd8309b9133b6e5"
|
||||
integrity sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==
|
||||
|
||||
qrcode.react@^4.2.0:
|
||||
version "4.2.0"
|
||||
resolved "https://registry.yarnpkg.com/qrcode.react/-/qrcode.react-4.2.0.tgz#1bce8363f348197d145c0da640929a24c83cbca3"
|
||||
integrity sha512-QpgqWi8rD9DsS9EP3z7BT+5lY5SFhsqGjpgW5DY/i3mK4M9DTBNz3ErMi8BWYEfI3L0d8GIbGmcdFAS1uIRGjA==
|
||||
|
||||
queue-microtask@^1.2.2:
|
||||
version "1.2.3"
|
||||
resolved "https://registry.yarnpkg.com/queue-microtask/-/queue-microtask-1.2.3.tgz#4929228bbc724dfac43e0efb058caf7b6cfb6243"
|
||||
|
||||
160
server/endpoints/mobile/index.js
Normal file
160
server/endpoints/mobile/index.js
Normal file
@ -0,0 +1,160 @@
|
||||
const { validatedRequest } = require("../../utils/middleware/validatedRequest");
|
||||
const { MobileDevice } = require("../../models/mobileDevice");
|
||||
const { handleMobileCommand } = require("./utils");
|
||||
const { validDeviceToken, validRegistrationToken } = require("./middleware");
|
||||
const { reqBody } = require("../../utils/http");
|
||||
const {
|
||||
flexUserRoleValid,
|
||||
ROLES,
|
||||
} = require("../../utils/middleware/multiUserProtected");
|
||||
|
||||
function mobileEndpoints(app) {
|
||||
if (!app) return;
|
||||
|
||||
/**
|
||||
* Gets all the devices from the database.
|
||||
* @param {import("express").Request} request
|
||||
* @param {import("express").Response} response
|
||||
*/
|
||||
app.get(
|
||||
"/mobile/devices",
|
||||
[validatedRequest, flexUserRoleValid([ROLES.admin])],
|
||||
async (_request, response) => {
|
||||
try {
|
||||
const devices = await MobileDevice.where({}, null, null, {
|
||||
user: { select: { id: true, username: true } },
|
||||
});
|
||||
return response.status(200).json({ devices });
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
response.sendStatus(500).end();
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
/**
|
||||
* Updates the device status via an updates object.
|
||||
* @param {import("express").Request} request
|
||||
* @param {import("express").Response} response
|
||||
*/
|
||||
app.post(
|
||||
"/mobile/update/:id",
|
||||
[validatedRequest, flexUserRoleValid([ROLES.admin])],
|
||||
async (request, response) => {
|
||||
try {
|
||||
const body = reqBody(request);
|
||||
const updates = await MobileDevice.update(
|
||||
Number(request.params.id),
|
||||
body
|
||||
);
|
||||
if (updates.error)
|
||||
return response.status(400).json({ error: updates.error });
|
||||
return response.status(200).json({ updates });
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
response.sendStatus(500).end();
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
/**
|
||||
* Deletes a device from the database.
|
||||
* @param {import("express").Request} request
|
||||
* @param {import("express").Response} response
|
||||
*/
|
||||
app.delete(
|
||||
"/mobile/:id",
|
||||
[validatedRequest, flexUserRoleValid([ROLES.admin])],
|
||||
async (request, response) => {
|
||||
try {
|
||||
const device = await MobileDevice.get({
|
||||
id: Number(request.params.id),
|
||||
});
|
||||
if (!device)
|
||||
return response.status(404).json({ error: "Device not found" });
|
||||
await MobileDevice.delete(device.id);
|
||||
return response.status(200).json({ message: "Device deleted" });
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
response.sendStatus(500).end();
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
app.get(
|
||||
"/mobile/connect-info",
|
||||
[validatedRequest, flexUserRoleValid([ROLES.admin])],
|
||||
async (_request, response) => {
|
||||
try {
|
||||
return response.status(200).json({
|
||||
connectionUrl: MobileDevice.connectionURL(response.locals?.user),
|
||||
});
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
response.sendStatus(500).end();
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
/**
|
||||
* Checks if the device auth token is valid
|
||||
* against approved devices.
|
||||
*/
|
||||
app.get("/mobile/auth", [validDeviceToken], async (_, response) => {
|
||||
try {
|
||||
return response
|
||||
.status(200)
|
||||
.json({ success: true, message: "Device authenticated" });
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
response.sendStatus(500).end();
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* Registers a new device (is open so that the mobile app can register itself)
|
||||
* Will create a new device in the database but requires approval by the user
|
||||
* before it can be used.
|
||||
* @param {import("express").Request} request
|
||||
* @param {import("express").Response} response
|
||||
*/
|
||||
app.post(
|
||||
"/mobile/register",
|
||||
[validRegistrationToken],
|
||||
async (request, response) => {
|
||||
try {
|
||||
const body = reqBody(request);
|
||||
const result = await MobileDevice.create({
|
||||
deviceOs: body.deviceOs,
|
||||
deviceName: body.deviceName,
|
||||
userId: response.locals?.user?.id,
|
||||
});
|
||||
|
||||
if (result.error)
|
||||
return response.status(400).json({ error: result.error });
|
||||
return response.status(200).json({
|
||||
token: result.device.token,
|
||||
platform: MobileDevice.platform,
|
||||
});
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
response.sendStatus(500).end();
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
app.post(
|
||||
"/mobile/send/:command",
|
||||
[validDeviceToken],
|
||||
async (request, response) => {
|
||||
try {
|
||||
return handleMobileCommand(request, response);
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
response.sendStatus(500).end();
|
||||
}
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
module.exports = { mobileEndpoints };
|
||||
97
server/endpoints/mobile/middleware/index.js
Normal file
97
server/endpoints/mobile/middleware/index.js
Normal file
@ -0,0 +1,97 @@
|
||||
const { MobileDevice } = require("../../../models/mobileDevice");
|
||||
const { SystemSettings } = require("../../../models/systemSettings");
|
||||
const { User } = require("../../../models/user");
|
||||
|
||||
/**
|
||||
* Validates the device id from the request headers by checking if the device
|
||||
* exists in the database and is approved.
|
||||
* @param {import("express").Request} request
|
||||
* @param {import("express").Response} response
|
||||
* @param {import("express").NextFunction} next
|
||||
*/
|
||||
async function validDeviceToken(request, response, next) {
|
||||
try {
|
||||
const token = request.header("x-anythingllm-mobile-device-token");
|
||||
if (!token)
|
||||
return response.status(400).json({ error: "Device token is required" });
|
||||
|
||||
const device = await MobileDevice.get(
|
||||
{ token: String(token) },
|
||||
{ user: true }
|
||||
);
|
||||
if (!device)
|
||||
return response.status(400).json({ error: "Device not found" });
|
||||
if (!device.approved)
|
||||
return response.status(400).json({ error: "Device not approved" });
|
||||
|
||||
// If the device is associated with a user then we can associate it with the locals
|
||||
// so we can reuse it later.
|
||||
if (device.user) {
|
||||
if (device.user.suspended)
|
||||
return response.status(400).json({ error: "User is suspended." });
|
||||
response.locals.user = device.user;
|
||||
}
|
||||
|
||||
delete device.user;
|
||||
response.locals.device = device;
|
||||
next();
|
||||
} catch (error) {
|
||||
console.error("validDeviceToken", error);
|
||||
response.status(500).json({ error: "Invalid middleware response" });
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Validates a temporary registration token that is passed in the request
|
||||
* and associates the user with the token (if valid). Temporary token is consumed
|
||||
* and cannot be used again after this middleware is called.
|
||||
* @param {*} request
|
||||
* @param {*} response
|
||||
* @param {*} next
|
||||
*/
|
||||
async function validRegistrationToken(request, response, next) {
|
||||
try {
|
||||
const authHeader = request.header("Authorization");
|
||||
const tempToken = authHeader ? authHeader.split(" ")[1] : null;
|
||||
if (!tempToken)
|
||||
return response
|
||||
.status(400)
|
||||
.json({ error: "Registration token is required" });
|
||||
|
||||
const tempTokenData = MobileDevice.tempToken(tempToken);
|
||||
if (!tempTokenData)
|
||||
return response
|
||||
.status(400)
|
||||
.json({ error: "Invalid or expired registration token" });
|
||||
|
||||
// If in multi-user mode, we need to validate the user id
|
||||
// associated exists, is not banned and then associate with locals so we can reuse it later.
|
||||
// If not in multi-user mode then simply having a valid token is enough.
|
||||
const multiUserMode = await SystemSettings.isMultiUserMode();
|
||||
if (multiUserMode) {
|
||||
if (!tempTokenData.userId)
|
||||
return response
|
||||
.status(400)
|
||||
.json({ error: "User id not found in registration token" });
|
||||
const user = await User.get({ id: Number(tempTokenData.userId) });
|
||||
if (!user) return response.status(400).json({ error: "User not found" });
|
||||
if (user.suspended)
|
||||
return response
|
||||
.status(400)
|
||||
.json({ error: "User is suspended - cannot register device" });
|
||||
response.locals.user = user;
|
||||
}
|
||||
|
||||
next();
|
||||
} catch (error) {
|
||||
console.error("validRegistrationToken:error", error);
|
||||
response.status(500).json({
|
||||
error: "Invalid middleware response from validRegistrationToken",
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
validDeviceToken,
|
||||
validRegistrationToken,
|
||||
};
|
||||
195
server/endpoints/mobile/utils/index.js
Normal file
195
server/endpoints/mobile/utils/index.js
Normal file
@ -0,0 +1,195 @@
|
||||
const { Workspace } = require("../../../models/workspace");
|
||||
const { WorkspaceChats } = require("../../../models/workspaceChats");
|
||||
const { WorkspaceThread } = require("../../../models/workspaceThread");
|
||||
const { ApiChatHandler } = require("../../../utils/chats/apiChatHandler");
|
||||
const { reqBody } = require("../../../utils/http");
|
||||
const prisma = require("../../../utils/prisma");
|
||||
const { getModelTag } = require("../../utils");
|
||||
const { MobileDevice } = require("../../../models/mobileDevice");
|
||||
|
||||
/**
|
||||
*
|
||||
* @param {import("express").Request} request
|
||||
* @param {import("express").Response} response
|
||||
* @returns
|
||||
*/
|
||||
async function handleMobileCommand(request, response) {
|
||||
const { command } = request.params;
|
||||
const user = response.locals.user ?? null;
|
||||
const body = reqBody(request);
|
||||
|
||||
if (command === "workspaces") {
|
||||
const workspaces = user
|
||||
? await Workspace.whereWithUser(user, {})
|
||||
: await Workspace.where({});
|
||||
for (const workspace of workspaces) {
|
||||
const [threadCount, chatCount] = await Promise.all([
|
||||
prisma.workspace_threads.count({
|
||||
where: {
|
||||
workspace_id: workspace.id,
|
||||
...(user ? { user_id: user.id } : {}),
|
||||
},
|
||||
}),
|
||||
prisma.workspace_chats.count({
|
||||
where: {
|
||||
workspaceId: workspace.id,
|
||||
include: true,
|
||||
...(user ? { user_id: user.id } : {}),
|
||||
},
|
||||
}),
|
||||
]);
|
||||
workspace.threadCount = threadCount;
|
||||
workspace.chatCount = chatCount;
|
||||
workspace.platform = MobileDevice.platform;
|
||||
}
|
||||
return response.status(200).json({ workspaces });
|
||||
}
|
||||
|
||||
if (command === "workspace-content") {
|
||||
const workspace = user
|
||||
? await Workspace.getWithUser(user, { slug: String(body.workspaceSlug) })
|
||||
: await Workspace.get({ slug: String(body.workspaceSlug) });
|
||||
|
||||
if (!workspace)
|
||||
return response.status(400).json({ error: "Workspace not found" });
|
||||
const threads = [
|
||||
{
|
||||
id: 0,
|
||||
name: "Default Thread",
|
||||
slug: "default-thread",
|
||||
workspace_id: workspace.id,
|
||||
createdAt: new Date(),
|
||||
lastUpdatedAt: new Date(),
|
||||
},
|
||||
...(await prisma.workspace_threads.findMany({
|
||||
where: {
|
||||
workspace_id: workspace.id,
|
||||
...(user ? { user_id: user.id } : {}),
|
||||
},
|
||||
})),
|
||||
];
|
||||
const chats = (
|
||||
await prisma.workspace_chats.findMany({
|
||||
where: {
|
||||
workspaceId: workspace.id,
|
||||
include: true,
|
||||
...(user ? { user_id: user.id } : {}),
|
||||
},
|
||||
})
|
||||
).map((chat) => ({
|
||||
...chat,
|
||||
// Create a dummy thread_id for the default thread so the chats can be mapped correctly.
|
||||
...(chat.thread_id === null ? { thread_id: 0 } : {}),
|
||||
createdAt: chat.createdAt.toISOString(),
|
||||
lastUpdatedAt: chat.lastUpdatedAt.toISOString(),
|
||||
}));
|
||||
return response.status(200).json({ threads, chats });
|
||||
}
|
||||
|
||||
// Get the model for this workspace (workspace -> system)
|
||||
if (command === "model-tag") {
|
||||
const { workspaceSlug } = body;
|
||||
const workspace = user
|
||||
? await Workspace.getWithUser(user, { slug: String(workspaceSlug) })
|
||||
: await Workspace.get({ slug: String(workspaceSlug) });
|
||||
|
||||
if (!workspace)
|
||||
return response.status(400).json({ error: "Workspace not found" });
|
||||
if (workspace.chatModel)
|
||||
return response.status(200).json({ model: workspace.chatModel });
|
||||
else return response.status(200).json({ model: getModelTag() });
|
||||
}
|
||||
|
||||
if (command === "reset-chat") {
|
||||
const { workspaceSlug, threadSlug } = body;
|
||||
const workspace = user
|
||||
? await Workspace.getWithUser(user, { slug: String(workspaceSlug) })
|
||||
: await Workspace.get({ slug: String(workspaceSlug) });
|
||||
|
||||
if (!workspace)
|
||||
return response.status(400).json({ error: "Workspace not found" });
|
||||
const threadId = threadSlug
|
||||
? await prisma.workspace_threads.findFirst({
|
||||
where: {
|
||||
workspace_id: workspace.id,
|
||||
slug: String(threadSlug),
|
||||
...(user ? { user_id: user.id } : {}),
|
||||
},
|
||||
})?.id
|
||||
: null;
|
||||
|
||||
await WorkspaceChats.markThreadHistoryInvalidV2({
|
||||
workspaceId: workspace.id,
|
||||
...(user ? { user_id: user.id } : {}),
|
||||
thread_id: threadId, // if threadId is null, this will reset the default thread.
|
||||
});
|
||||
return response.status(200).json({ success: true });
|
||||
}
|
||||
|
||||
if (command === "new-thread") {
|
||||
const { workspaceSlug } = body;
|
||||
const workspace = user
|
||||
? await Workspace.getWithUser(user, { slug: String(workspaceSlug) })
|
||||
: await Workspace.get({ slug: String(workspaceSlug) });
|
||||
|
||||
if (!workspace)
|
||||
return response.status(400).json({ error: "Workspace not found" });
|
||||
const { thread } = await WorkspaceThread.new(workspace, user?.id);
|
||||
return response.status(200).json({ thread });
|
||||
}
|
||||
|
||||
if (command === "stream-chat") {
|
||||
const { workspaceSlug = null, threadSlug = null, message } = body;
|
||||
if (!workspaceSlug)
|
||||
return response.status(400).json({ error: "Workspace ID is required" });
|
||||
else if (!message)
|
||||
return response.status(400).json({ error: "Message is required" });
|
||||
|
||||
const workspace = user
|
||||
? await Workspace.getWithUser(user, { slug: String(workspaceSlug) })
|
||||
: await Workspace.get({ slug: String(workspaceSlug) });
|
||||
|
||||
if (!workspace)
|
||||
return response.status(400).json({ error: "Workspace not found" });
|
||||
const thread = threadSlug
|
||||
? await prisma.workspace_threads.findFirst({
|
||||
where: {
|
||||
workspace_id: workspace.id,
|
||||
slug: String(threadSlug),
|
||||
...(user ? { user_id: user.id } : {}),
|
||||
},
|
||||
})
|
||||
: null;
|
||||
|
||||
response.setHeader("Cache-Control", "no-cache");
|
||||
response.setHeader("Content-Type", "text/event-stream");
|
||||
response.setHeader("Access-Control-Allow-Origin", "*");
|
||||
response.setHeader("Connection", "keep-alive");
|
||||
response.flushHeaders();
|
||||
await ApiChatHandler.streamChat({
|
||||
response,
|
||||
workspace,
|
||||
thread,
|
||||
message,
|
||||
mode: "chat",
|
||||
user: user,
|
||||
sessionId: null,
|
||||
attachments: [],
|
||||
reset: false,
|
||||
});
|
||||
return response.end();
|
||||
}
|
||||
|
||||
if (command === "unregister-device") {
|
||||
if (!response.locals.device)
|
||||
return response.status(200).json({ success: true });
|
||||
await MobileDevice.delete(response.locals.device.id);
|
||||
return response.status(200).json({ success: true });
|
||||
}
|
||||
|
||||
return response.status(400).json({ error: "Invalid command" });
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
handleMobileCommand,
|
||||
};
|
||||
@ -28,6 +28,7 @@ const { browserExtensionEndpoints } = require("./endpoints/browserExtension");
|
||||
const { communityHubEndpoints } = require("./endpoints/communityHub");
|
||||
const { agentFlowEndpoints } = require("./endpoints/agentFlows");
|
||||
const { mcpServersEndpoints } = require("./endpoints/mcpServers");
|
||||
const { mobileEndpoints } = require("./endpoints/mobile");
|
||||
const app = express();
|
||||
const apiRouter = express.Router();
|
||||
const FILE_LIMIT = "3GB";
|
||||
@ -65,6 +66,7 @@ developerEndpoints(app, apiRouter);
|
||||
communityHubEndpoints(apiRouter);
|
||||
agentFlowEndpoints(apiRouter);
|
||||
mcpServersEndpoints(apiRouter);
|
||||
mobileEndpoints(apiRouter);
|
||||
|
||||
// Externally facing embedder endpoints
|
||||
embeddedEndpoints(apiRouter);
|
||||
|
||||
230
server/models/mobileDevice.js
Normal file
230
server/models/mobileDevice.js
Normal file
@ -0,0 +1,230 @@
|
||||
const prisma = require("../utils/prisma");
|
||||
const { v4: uuidv4 } = require("uuid");
|
||||
const ip = require("ip");
|
||||
|
||||
/**
|
||||
* @typedef {Object} TemporaryMobileDeviceRequest
|
||||
* @property {number|null} userId - User id to associate creation of key with.
|
||||
* @property {number} createdAt - Timestamp of when the token was created.
|
||||
* @property {number} expiresAt - Timestamp of when the token expires.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Temporary map to store mobile device requests
|
||||
* that are not yet approved. Generates a simple JWT
|
||||
* that expires and is tied to the user (if provided)
|
||||
* This token must be provided during /register event.
|
||||
* @type {Map<string, TemporaryMobileDeviceRequest>}
|
||||
*/
|
||||
const TemporaryMobileDeviceRequests = new Map();
|
||||
|
||||
const MobileDevice = {
|
||||
platform: "server",
|
||||
validDeviceOs: ["android"],
|
||||
tablename: "desktop_mobile_devices",
|
||||
writable: ["approved"],
|
||||
validators: {
|
||||
approved: (value) => {
|
||||
if (typeof value !== "boolean") return "Must be a boolean";
|
||||
return null;
|
||||
},
|
||||
},
|
||||
|
||||
/**
|
||||
* Looks up and consumes a temporary token that was registered
|
||||
* Will return null if the token is not found or expired.
|
||||
* @param {string} token - The temporary token to lookup
|
||||
* @returns {TemporaryMobileDeviceRequest|null} Temp token details
|
||||
*/
|
||||
tempToken: (token = null) => {
|
||||
try {
|
||||
if (!token || !TemporaryMobileDeviceRequests.has(token)) return null;
|
||||
const tokenData = TemporaryMobileDeviceRequests.get(token);
|
||||
if (tokenData.expiresAt < Date.now()) return null;
|
||||
return tokenData;
|
||||
} catch (error) {
|
||||
return null;
|
||||
} finally {
|
||||
TemporaryMobileDeviceRequests.delete(token);
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Registers a temporary token for a mobile device request
|
||||
* This is just using a random token to identify the request
|
||||
* @security Note: If we use a JWT the QR code that encodes it becomes extremely complex
|
||||
* and noisy as QR codes have byte limits that could be exceeded with JWTs. Since this is
|
||||
* a temporary token that is only used to register a device and is short lived we can use UUIDs.
|
||||
* @param {import("@prisma/client").users|null} user - User to get connection URL for in Multi-User Mode
|
||||
* @returns {string} The temporary token
|
||||
*/
|
||||
registerTempToken: function (user = null) {
|
||||
let tokenData = {};
|
||||
if (user) tokenData.userId = user.id;
|
||||
else tokenData.userId = null;
|
||||
|
||||
// Set short lived expiry to this mapping
|
||||
const createdAt = Date.now();
|
||||
tokenData.createdAt = createdAt;
|
||||
tokenData.expiresAt = createdAt + 3 * 60_000;
|
||||
|
||||
const tempToken = uuidv4().split("-").slice(0, 3).join("");
|
||||
TemporaryMobileDeviceRequests.set(tempToken, tokenData);
|
||||
|
||||
// Run this on register since there is no BG task to do this.
|
||||
this.cleanupExpiredTokens();
|
||||
return tempToken;
|
||||
},
|
||||
|
||||
/**
|
||||
* Cleans up expired temporary registration tokens
|
||||
* Should run quick since this mapping is wiped often
|
||||
* and does not live past restarts.
|
||||
*/
|
||||
cleanupExpiredTokens: function () {
|
||||
const now = Date.now();
|
||||
for (const [token, data] of TemporaryMobileDeviceRequests.entries()) {
|
||||
if (data.expiresAt < now) TemporaryMobileDeviceRequests.delete(token);
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Returns the connection URL for the mobile app to use to connect to the backend.
|
||||
* Since you have to have a valid session to call /mobile/connect-info we can pre-register
|
||||
* a temporary token for the user that is passed back to /mobile/register and can lookup
|
||||
* who a device belongs to so we can scope it's access token.
|
||||
* @param {import("@prisma/client").users|null} user - User to get connection URL for in Multi-User Mode
|
||||
* @returns {string}
|
||||
*/
|
||||
connectionURL: function (user = null) {
|
||||
let baseUrl = "/api/mobile";
|
||||
if (process.env.NODE_ENV === "production") baseUrl = "/api/mobile";
|
||||
else
|
||||
baseUrl = `http://${ip.address()}:${process.env.SERVER_PORT || 3001}/api/mobile`;
|
||||
|
||||
const tempToken = this.registerTempToken(user);
|
||||
baseUrl = `${baseUrl}?t=${tempToken}`;
|
||||
return baseUrl;
|
||||
},
|
||||
|
||||
/**
|
||||
* Creates a new device for the mobile app
|
||||
* @param {object} params - The params to create the device with.
|
||||
* @param {string} params.deviceOs - Device os to associate creation of key with.
|
||||
* @param {string} params.deviceName - Device name to associate creation of key with.
|
||||
* @param {number|null} params.userId - User id to associate creation of key with.
|
||||
* @returns {Promise<{device: import("@prisma/client").desktop_mobile_devices|null, error:string|null}>}
|
||||
*/
|
||||
create: async function ({ deviceOs, deviceName, userId = null }) {
|
||||
try {
|
||||
if (!deviceOs || !deviceName)
|
||||
return { device: null, error: "Device OS and name are required" };
|
||||
if (!this.validDeviceOs.includes(deviceOs))
|
||||
return { device: null, error: `Invalid device OS - ${deviceOs}` };
|
||||
|
||||
const device = await prisma.desktop_mobile_devices.create({
|
||||
data: {
|
||||
deviceName: String(deviceName),
|
||||
deviceOs: String(deviceOs).toLowerCase(),
|
||||
token: uuidv4(),
|
||||
userId: userId ? Number(userId) : null,
|
||||
},
|
||||
});
|
||||
return { device, error: null };
|
||||
} catch (error) {
|
||||
console.error("Failed to create mobile device", error);
|
||||
return { device: null, error: error.message };
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Validated existing API key
|
||||
* @param {string} id - Device id (db id)
|
||||
* @param {object} updates - Updates to apply to device
|
||||
* @returns {Promise<{device: import("@prisma/client").desktop_mobile_devices|null, error:string|null}>}
|
||||
*/
|
||||
update: async function (id, updates = {}) {
|
||||
const device = await this.get({ id: parseInt(id) });
|
||||
if (!device) return { device: null, error: "Device not found" };
|
||||
|
||||
const validUpdates = {};
|
||||
for (const [key, value] of Object.entries(updates)) {
|
||||
if (!this.writable.includes(key)) continue;
|
||||
const validation = this.validators[key](value);
|
||||
if (validation !== null) return { device: null, error: validation };
|
||||
validUpdates[key] = value;
|
||||
}
|
||||
// If no updates, return the device.
|
||||
if (Object.keys(validUpdates).length === 0) return { device, error: null };
|
||||
|
||||
const updatedDevice = await prisma.desktop_mobile_devices.update({
|
||||
where: { id: device.id },
|
||||
data: validUpdates,
|
||||
});
|
||||
return { device: updatedDevice, error: null };
|
||||
},
|
||||
|
||||
/**
|
||||
* Fetches mobile device by params.
|
||||
* @param {object} clause - Prisma props for search
|
||||
* @returns {Promise<import("@prisma/client").desktop_mobile_devices[]>}
|
||||
*/
|
||||
get: async function (clause = {}, include = null) {
|
||||
try {
|
||||
const device = await prisma.desktop_mobile_devices.findFirst({
|
||||
where: clause,
|
||||
...(include !== null ? { include } : {}),
|
||||
});
|
||||
return device;
|
||||
} catch (error) {
|
||||
console.error("FAILED TO GET MOBILE DEVICE.", error);
|
||||
return [];
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Deletes mobile device by db id.
|
||||
* @param {number} id - database id of mobile device
|
||||
* @returns {Promise<{success: boolean, error:string|null}>}
|
||||
*/
|
||||
delete: async function (id) {
|
||||
try {
|
||||
await prisma.desktop_mobile_devices.delete({
|
||||
where: { id: parseInt(id) },
|
||||
});
|
||||
return { success: true, error: null };
|
||||
} catch (error) {
|
||||
console.error("Failed to delete mobile device", error);
|
||||
return { success: false, error: error.message };
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Gets mobile devices by params
|
||||
* @param {object} clause
|
||||
* @param {number|null} limit
|
||||
* @param {object|null} orderBy
|
||||
* @returns {Promise<import("@prisma/client").desktop_mobile_devices[]>}
|
||||
*/
|
||||
where: async function (
|
||||
clause = {},
|
||||
limit = null,
|
||||
orderBy = null,
|
||||
include = null
|
||||
) {
|
||||
try {
|
||||
const devices = await prisma.desktop_mobile_devices.findMany({
|
||||
where: clause,
|
||||
...(limit !== null ? { take: limit } : {}),
|
||||
...(orderBy !== null ? { orderBy } : {}),
|
||||
...(include !== null ? { include } : {}),
|
||||
});
|
||||
return devices;
|
||||
} catch (error) {
|
||||
console.error("FAILED TO GET MOBILE DEVICES.", error.message);
|
||||
return [];
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
module.exports = { MobileDevice };
|
||||
@ -216,6 +216,11 @@ const User = {
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Returns a user object based on the clause provided.
|
||||
* @param {Object} clause - The clause to use to find the user.
|
||||
* @returns {Promise<import("@prisma/client").users|null>} The user object or null if not found.
|
||||
*/
|
||||
get: async function (clause = {}) {
|
||||
try {
|
||||
const user = await prisma.users.findFirst({ where: clause });
|
||||
|
||||
@ -54,6 +54,7 @@
|
||||
"extract-json-from-string": "^1.0.1",
|
||||
"fast-levenshtein": "^3.0.0",
|
||||
"graphql": "^16.7.1",
|
||||
"ip": "^2.0.1",
|
||||
"joi": "^17.11.0",
|
||||
"joi-password-complexity": "^5.2.0",
|
||||
"js-tiktoken": "^1.0.8",
|
||||
@ -100,4 +101,4 @@
|
||||
"nodemon": "^2.0.22",
|
||||
"prettier": "^3.0.3"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
17
server/prisma/migrations/20250725194841_init/migration.sql
Normal file
17
server/prisma/migrations/20250725194841_init/migration.sql
Normal file
@ -0,0 +1,17 @@
|
||||
-- CreateTable
|
||||
CREATE TABLE "desktop_mobile_devices" (
|
||||
"id" INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT,
|
||||
"deviceOs" TEXT NOT NULL,
|
||||
"deviceName" TEXT NOT NULL,
|
||||
"token" TEXT NOT NULL,
|
||||
"approved" BOOLEAN NOT NULL DEFAULT false,
|
||||
"userId" INTEGER,
|
||||
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
CONSTRAINT "desktop_mobile_devices_userId_fkey" FOREIGN KEY ("userId") REFERENCES "users" ("id") ON DELETE CASCADE ON UPDATE CASCADE
|
||||
);
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "desktop_mobile_devices_token_key" ON "desktop_mobile_devices"("token");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "desktop_mobile_devices_userId_idx" ON "desktop_mobile_devices"("userId");
|
||||
@ -82,6 +82,7 @@ model users {
|
||||
temporary_auth_tokens temporary_auth_tokens[]
|
||||
system_prompt_variables system_prompt_variables[]
|
||||
prompt_history prompt_history[]
|
||||
desktop_mobile_devices desktop_mobile_devices[]
|
||||
}
|
||||
|
||||
model recovery_codes {
|
||||
@ -356,3 +357,17 @@ model prompt_history {
|
||||
|
||||
@@index([workspaceId])
|
||||
}
|
||||
|
||||
// Schema specific to mobile app <> Desktop app connection
|
||||
model desktop_mobile_devices {
|
||||
id Int @id @default(autoincrement())
|
||||
deviceOs String
|
||||
deviceName String
|
||||
token String @unique
|
||||
approved Boolean @default(false)
|
||||
userId Int?
|
||||
createdAt DateTime @default(now())
|
||||
user users? @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||
|
||||
@@index([userId])
|
||||
}
|
||||
|
||||
@ -16,6 +16,12 @@ function queryParams(request) {
|
||||
return request.query;
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a JWT with the given info and expiry
|
||||
* @param {object} info - The info to include in the JWT
|
||||
* @param {string} expiry - The expiry time for the JWT (default: 30 days)
|
||||
* @returns {string} The JWT
|
||||
*/
|
||||
function makeJWT(info = {}, expiry = "30d") {
|
||||
if (!process.env.JWT_SECRET)
|
||||
throw new Error("Cannot create JWT as JWT_SECRET is unset.");
|
||||
|
||||
@ -5122,6 +5122,11 @@ internal-slot@^1.0.7:
|
||||
hasown "^2.0.0"
|
||||
side-channel "^1.0.4"
|
||||
|
||||
ip@^2.0.1:
|
||||
version "2.0.1"
|
||||
resolved "https://registry.yarnpkg.com/ip/-/ip-2.0.1.tgz#e8f3595d33a3ea66490204234b77636965307105"
|
||||
integrity sha512-lJUL9imLTNi1ZfXT+DU6rBBdbiKGBuay9B6xGSPVjUeQwaH1RIGqef8RZkUtHioLmSNpPR5M4HVKJGm1j8FWVQ==
|
||||
|
||||
ipaddr.js@1.9.1:
|
||||
version "1.9.1"
|
||||
resolved "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz"
|
||||
|
||||
Loading…
Reference in New Issue
Block a user