mirror of
https://github.com/its-a-feature/Mythic
synced 2026-06-08 14:55:38 +00:00
make and invalidate bot apitokens
This commit is contained in:
@@ -23,6 +23,7 @@ import {
|
||||
sanitizeTerminalOutput
|
||||
} from "./ResponseDisplayInteractive";
|
||||
import {isAllowedMarkdownLink, markdownPlugins} from "../../utilities/Markdown";
|
||||
import {markdownComponents} from "../Chat/Chat";
|
||||
|
||||
const MaxRenderSize = 2000000;
|
||||
const RenderModes = {
|
||||
@@ -47,7 +48,7 @@ const getInitialRenderMode = (props) => {
|
||||
}
|
||||
return props?.render_colors ? RenderModes.terminal : RenderModes.plaintext;
|
||||
}
|
||||
const markdownComponents = {
|
||||
const markdownComponentsOld = {
|
||||
a: ({href, children}) => {
|
||||
if(!isAllowedMarkdownLink(href)){
|
||||
return children;
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import React from 'react';
|
||||
import {gql, useLazyQuery, useMutation, useQuery, useSubscription} from '@apollo/client';
|
||||
import ReactMarkdown from 'react-markdown';
|
||||
import Split from 'react-split';
|
||||
import {alpha, useTheme} from '@mui/material/styles';
|
||||
import Box from '@mui/material/Box';
|
||||
import Button from '@mui/material/Button';
|
||||
@@ -31,6 +32,8 @@ import CampaignTwoToneIcon from '@mui/icons-material/CampaignTwoTone';
|
||||
import DeleteIcon from '@mui/icons-material/Delete';
|
||||
import EditIcon from '@mui/icons-material/Edit';
|
||||
import ForumTwoToneIcon from '@mui/icons-material/ForumTwoTone';
|
||||
import KeyboardDoubleArrowLeftIcon from '@mui/icons-material/KeyboardDoubleArrowLeft';
|
||||
import KeyboardDoubleArrowRightIcon from '@mui/icons-material/KeyboardDoubleArrowRight';
|
||||
import LockIcon from '@mui/icons-material/Lock';
|
||||
import LockOpenIcon from '@mui/icons-material/LockOpen';
|
||||
import RestartAltIcon from '@mui/icons-material/RestartAlt';
|
||||
@@ -49,9 +52,12 @@ import {snackActions} from "../../utilities/Snackbar";
|
||||
import {getSkewedNow} from "../../utilities/Time";
|
||||
import {isAllowedMarkdownLink, markdownPlugins} from "../../utilities/Markdown";
|
||||
import {EventStepUserInteractionDialog} from "../Eventing/EventStepRender";
|
||||
import {SettingsAPITokenDialog} from "../Settings/SettingsOperatorDialog";
|
||||
|
||||
const CHAT_MESSAGE_LIMIT = 250;
|
||||
const CHAT_REQUEST_LIMIT = 50;
|
||||
const CHAT_SPLIT_STORAGE_KEY = "chatSplitSizes";
|
||||
const CHAT_SPLIT_DEFAULT_SIZES = [20, 80];
|
||||
|
||||
const CHAT_CHANNEL_FIELDS = gql`
|
||||
fragment ChatChannelFields on chat_channel {
|
||||
@@ -67,7 +73,18 @@ fragment ChatChannelFields on chat_channel {
|
||||
chat_container_id
|
||||
chat_model
|
||||
ai_metadata
|
||||
apitokens_id
|
||||
updated_at
|
||||
apitoken {
|
||||
id
|
||||
name
|
||||
scopes
|
||||
token_type
|
||||
active
|
||||
deleted
|
||||
operator_id
|
||||
created_by
|
||||
}
|
||||
chat_container {
|
||||
id
|
||||
name
|
||||
@@ -195,6 +212,14 @@ query ChatCurrentOperator($operator_id: Int!, $operation_id: Int!) {
|
||||
id
|
||||
view_mode
|
||||
}
|
||||
operation_bot: operator(where: {account_type: {_eq: "bot"}, current_operation_id: {_eq: $operation_id}, active: {_eq: true}, deleted: {_eq: false}}, limit: 1, order_by: {id: asc}) {
|
||||
id
|
||||
username
|
||||
account_type
|
||||
current_operation_id
|
||||
active
|
||||
deleted
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
@@ -235,8 +260,8 @@ subscription ChatRequestsStream($channel_id: Int!, $now: timestamp!) {
|
||||
`;
|
||||
|
||||
const CREATE_CHANNEL = gql`
|
||||
mutation CreateChatChannel($name: String!, $description: String, $channel_type: String, $chat_container_id: Int, $chat_model: String, $locked: Boolean, $ai_metadata: jsonb) {
|
||||
chatCreateChannel(name: $name, description: $description, channel_type: $channel_type, chat_container_id: $chat_container_id, chat_model: $chat_model, locked: $locked, ai_metadata: $ai_metadata) {
|
||||
mutation CreateChatChannel($name: String!, $description: String, $channel_type: String, $chat_container_id: Int, $chat_model: String, $locked: Boolean, $ai_metadata: jsonb, $apitokens_id: Int) {
|
||||
chatCreateChannel(name: $name, description: $description, channel_type: $channel_type, chat_container_id: $chat_container_id, chat_model: $chat_model, locked: $locked, ai_metadata: $ai_metadata, apitokens_id: $apitokens_id) {
|
||||
status
|
||||
error
|
||||
id
|
||||
@@ -246,8 +271,8 @@ mutation CreateChatChannel($name: String!, $description: String, $channel_type:
|
||||
`;
|
||||
|
||||
const UPDATE_CHANNEL = gql`
|
||||
mutation UpdateChatChannel($channel_id: Int!, $name: String, $description: String, $archived: Boolean, $locked: Boolean, $chat_model: String, $ai_metadata: jsonb) {
|
||||
chatUpdateChannel(channel_id: $channel_id, name: $name, description: $description, archived: $archived, locked: $locked, chat_model: $chat_model, ai_metadata: $ai_metadata) {
|
||||
mutation UpdateChatChannel($channel_id: Int!, $name: String, $description: String, $archived: Boolean, $locked: Boolean, $chat_model: String, $ai_metadata: jsonb, $apitokens_id: Int) {
|
||||
chatUpdateChannel(channel_id: $channel_id, name: $name, description: $description, archived: $archived, locked: $locked, chat_model: $chat_model, ai_metadata: $ai_metadata, apitokens_id: $apitokens_id) {
|
||||
status
|
||||
error
|
||||
channel_id
|
||||
@@ -255,6 +280,39 @@ mutation UpdateChatChannel($channel_id: Int!, $name: String, $description: Strin
|
||||
}
|
||||
`;
|
||||
|
||||
const CHAT_API_TOKENS_QUERY = gql`
|
||||
query ChatAPITokens($operator_ids: [Int!]!) {
|
||||
apitokens(where: {operator_id: {_in: $operator_ids}, token_type: {_eq: "api"}, deleted: {_eq: false}, active: {_eq: true}}, order_by: [{operator_id: asc}, {id: desc}]) {
|
||||
id
|
||||
name
|
||||
scopes
|
||||
token_type
|
||||
active
|
||||
deleted
|
||||
operator_id
|
||||
created_by
|
||||
creation_time
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
const CREATE_API_TOKEN = gql`
|
||||
mutation CreateChatAPIToken($operator_id: Int, $name: String, $scopes: [String!]) {
|
||||
createAPIToken(operator_id: $operator_id, name: $name, scopes: $scopes) {
|
||||
id
|
||||
token_value
|
||||
scopes
|
||||
token_type
|
||||
status
|
||||
error
|
||||
operator_id
|
||||
name
|
||||
created_by
|
||||
creation_time
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
const CREATE_MESSAGE = gql`
|
||||
mutation CreateChatMessage($channel_id: Int!, $message: String!, $system_message: Boolean = false, $all_operations: Boolean = false) {
|
||||
chatCreateMessage(channel_id: $channel_id, message: $message, system_message: $system_message, all_operations: $all_operations) {
|
||||
@@ -353,6 +411,14 @@ query ChatSearch($query: String!, $channel_id: Int, $limit: Int, $offset: Int) {
|
||||
|
||||
const isGeneralChatChannel = (channel) => channel?.channel_type === "standard" && channel?.slug === "general";
|
||||
|
||||
const getStoredChatSplitSizes = () => {
|
||||
try {
|
||||
return JSON.parse(localStorage.getItem(CHAT_SPLIT_STORAGE_KEY));
|
||||
} catch(error) {
|
||||
return CHAT_SPLIT_DEFAULT_SIZES;
|
||||
}
|
||||
};
|
||||
|
||||
const CHAT_SEARCH_SNIPPET_LENGTH = 260;
|
||||
const CHAT_SEARCH_SNIPPET_CONTEXT = 90;
|
||||
const timestampHasTimeZone = (timestampText) => /(?:[zZ]|[+-]\d{2}:?\d{2})$/.test(timestampText);
|
||||
@@ -551,7 +617,7 @@ const MarkdownHeading = ({level, children}) => (
|
||||
</Typography>
|
||||
);
|
||||
|
||||
const markdownComponents = {
|
||||
export const markdownComponents = {
|
||||
p: ({children}) => <Typography component="p" className="mythic-chat-paragraph">{children}</Typography>,
|
||||
h1: ({children}) => <MarkdownHeading level={1}>{children}</MarkdownHeading>,
|
||||
h2: ({children}) => <MarkdownHeading level={2}>{children}</MarkdownHeading>,
|
||||
@@ -892,6 +958,34 @@ const configHasMissingRequiredValues = (values, options) => options.some((option
|
||||
option.required && `${values[option.name] ?? ""}`.trim() === ""
|
||||
));
|
||||
|
||||
const AI_CHAT_REQUIRED_TOKEN_SCOPES = ["apitoken.write", "chat-ai.write"];
|
||||
|
||||
const tokenHasScope = (token, scope) => {
|
||||
const scopes = token?.scopes || [];
|
||||
if(scopes.includes("*") || scopes.includes(scope)){
|
||||
return true;
|
||||
}
|
||||
const resource = scope.split(".")[0];
|
||||
return scopes.includes(`${resource}.*`);
|
||||
};
|
||||
|
||||
const tokenMeetsAIChatRequirements = (token) => AI_CHAT_REQUIRED_TOKEN_SCOPES.every((scope) => tokenHasScope(token, scope));
|
||||
|
||||
const formatTokenLabel = (token) => {
|
||||
if(!token){
|
||||
return "";
|
||||
}
|
||||
const status = token.active && !token.deleted ? "" : " inactive";
|
||||
return `${token.name || `Token ${token.id}`} (#${token.id})${status}`;
|
||||
};
|
||||
|
||||
const tokenOwnerLabel = (owner) => {
|
||||
if(!owner){
|
||||
return "";
|
||||
}
|
||||
return owner.accountType === "bot" ? `Operation bot (${owner.username})` : `Current user (${owner.username})`;
|
||||
};
|
||||
|
||||
const applyConfigToMetadata = (metadata, config) => ({
|
||||
...parseJSONLikeObject(metadata),
|
||||
config,
|
||||
@@ -953,6 +1047,192 @@ const ChatConfigurationFields = ({options, values, setValues}) => {
|
||||
);
|
||||
};
|
||||
|
||||
const ChatAPITokenSelector = ({value, setValue, currentToken, currentUser, operationBot}) => {
|
||||
const [openCreateToken, setOpenCreateToken] = React.useState(false);
|
||||
const [localTokens, setLocalTokens] = React.useState([]);
|
||||
const ownerOptions = React.useMemo(() => {
|
||||
const owners = [];
|
||||
if(currentUser?.id){
|
||||
owners.push({
|
||||
id: currentUser.id,
|
||||
username: currentUser.username || `Operator ${currentUser.id}`,
|
||||
accountType: "user",
|
||||
});
|
||||
}
|
||||
if(operationBot?.id && !owners.some((owner) => owner.id === operationBot.id)){
|
||||
owners.push({
|
||||
id: operationBot.id,
|
||||
username: operationBot.username || `Operator ${operationBot.id}`,
|
||||
accountType: "bot",
|
||||
});
|
||||
}
|
||||
return owners;
|
||||
}, [currentUser?.id, currentUser?.username, operationBot?.id, operationBot?.username]);
|
||||
const ownerIDs = React.useMemo(() => ownerOptions.map((owner) => owner.id), [ownerOptions]);
|
||||
const ownerIDKey = React.useMemo(() => ownerIDs.join(","), [ownerIDs]);
|
||||
const [tokenOwnerID, setTokenOwnerID] = React.useState("");
|
||||
const {data, loading} = useQuery(CHAT_API_TOKENS_QUERY, {
|
||||
variables: {operator_ids: ownerIDs.length > 0 ? ownerIDs : [0]},
|
||||
skip: ownerIDs.length === 0,
|
||||
fetchPolicy: "no-cache",
|
||||
});
|
||||
React.useEffect(() => {
|
||||
setLocalTokens([]);
|
||||
}, [ownerIDKey]);
|
||||
React.useEffect(() => {
|
||||
if(data?.apitokens){
|
||||
setLocalTokens(data.apitokens);
|
||||
}
|
||||
}, [data]);
|
||||
React.useEffect(() => {
|
||||
if(currentToken?.operator_id && ownerOptions.some((owner) => owner.id === currentToken.operator_id)){
|
||||
setTokenOwnerID(currentToken.operator_id);
|
||||
return;
|
||||
}
|
||||
setTokenOwnerID((previousOwnerID) => (
|
||||
ownerOptions.length > 0 && !ownerOptions.some((owner) => `${owner.id}` === `${previousOwnerID}`) ?
|
||||
ownerOptions[0].id :
|
||||
previousOwnerID
|
||||
));
|
||||
}, [currentToken?.id, currentToken?.operator_id, ownerOptions]);
|
||||
const selectedOwner = ownerOptions.find((owner) => `${owner.id}` === `${tokenOwnerID}`) || null;
|
||||
const tokens = React.useMemo(() => {
|
||||
const combined = localTokens.filter((token) => `${token.operator_id}` === `${tokenOwnerID}`);
|
||||
if(currentToken?.id && `${currentToken.operator_id}` === `${tokenOwnerID}` && !combined.some((token) => token.id === currentToken.id)){
|
||||
combined.unshift(currentToken);
|
||||
}
|
||||
if(value && !combined.some((token) => `${token.id}` === `${value}`)){
|
||||
combined.unshift({id: value, name: `Token ${value}`, scopes: [], active: true, deleted: false, operator_id: tokenOwnerID});
|
||||
}
|
||||
return combined;
|
||||
}, [localTokens, currentToken, value, tokenOwnerID]);
|
||||
const selectedToken = tokens.find((token) => `${token.id}` === `${value}`) || null;
|
||||
const createTokenInitialScopes = React.useMemo(() => selectedToken?.scopes || [], [selectedToken]);
|
||||
const [createAPIToken] = useMutation(CREATE_API_TOKEN, {
|
||||
onCompleted: (result) => {
|
||||
if(result.createAPIToken.status === "success"){
|
||||
const {token_value, ...createdToken} = result.createAPIToken;
|
||||
setLocalTokens((prev) => [{...createdToken, active: true, deleted: false}, ...prev]);
|
||||
setTokenOwnerID(createdToken.operator_id);
|
||||
setValue(createdToken.id);
|
||||
snackActions.success("Created and selected API token");
|
||||
setOpenCreateToken(false);
|
||||
}else{
|
||||
snackActions.error(result.createAPIToken.error);
|
||||
}
|
||||
},
|
||||
onError: (error) => snackActions.error(error.message),
|
||||
});
|
||||
const createToken = (name, scopes) => {
|
||||
createAPIToken({variables: {operator_id: selectedOwner?.id, name, scopes}});
|
||||
};
|
||||
const changeTokenOwner = (event) => {
|
||||
setTokenOwnerID(Number(event.target.value));
|
||||
setValue("");
|
||||
};
|
||||
const tokenSelectorDisabled = !selectedOwner;
|
||||
return (
|
||||
<Box sx={{display: "flex", flexDirection: "column", gap: 1}}>
|
||||
<Box sx={{border: `1px solid ${alpha("#ffffff", 0.12)}`, borderRadius: 1, p: 1.25}}>
|
||||
<Typography variant="subtitle2">AI Chat API Token</Typography>
|
||||
<Typography variant="caption" color="text.secondary" sx={{display: "block", mt: 0.5}}>
|
||||
AI chat can use a token for your operator account or the operation bot{operationBot?.username ? ` (${operationBot.username})` : ""}. The token must include apitoken.write to generate scoped Mythic API tokens and chat-ai.write to stream responses back to this channel.
|
||||
</Typography>
|
||||
<Box sx={{display: "flex", gap: 0.75, flexWrap: "wrap", mt: 1}}>
|
||||
{AI_CHAT_REQUIRED_TOKEN_SCOPES.map((scope) => (
|
||||
<Chip key={scope} size="small" label={scope} color={selectedToken && tokenHasScope(selectedToken, scope) ? "success" : "warning"} />
|
||||
))}
|
||||
</Box>
|
||||
</Box>
|
||||
<Box sx={{display: "grid", gridTemplateColumns: {xs: "1fr", md: "minmax(160px, 0.55fr) minmax(220px, 1fr) auto"}, gap: 1, alignItems: "stretch"}}>
|
||||
<FormControl size="small" fullWidth required>
|
||||
<InputLabel>Owner</InputLabel>
|
||||
<Select
|
||||
label="Owner"
|
||||
value={tokenOwnerID ? `${tokenOwnerID}` : ""}
|
||||
disabled={ownerOptions.length === 0}
|
||||
onChange={changeTokenOwner}
|
||||
>
|
||||
{ownerOptions.length === 0 &&
|
||||
<MenuItem value="" disabled>No token owners available</MenuItem>
|
||||
}
|
||||
{ownerOptions.map((owner) => (
|
||||
<MenuItem value={`${owner.id}`} key={`chat-token-owner-${owner.id}`}>
|
||||
{tokenOwnerLabel(owner)}
|
||||
</MenuItem>
|
||||
))}
|
||||
</Select>
|
||||
</FormControl>
|
||||
<FormControl size="small" fullWidth required>
|
||||
<InputLabel>API Token</InputLabel>
|
||||
<Select
|
||||
label="API Token"
|
||||
value={value ? `${value}` : ""}
|
||||
disabled={tokenSelectorDisabled}
|
||||
onChange={(event) => setValue(Number(event.target.value))}
|
||||
>
|
||||
{!selectedOwner &&
|
||||
<MenuItem value="" disabled>Select a token owner first</MenuItem>
|
||||
}
|
||||
{tokens.length === 0 &&
|
||||
<MenuItem value="" disabled>{loading ? "Loading API tokens..." : "No active API tokens available"}</MenuItem>
|
||||
}
|
||||
{tokens.map((token) => (
|
||||
<MenuItem value={`${token.id}`} key={`chat-token-${token.id}`}>
|
||||
<Box sx={{display: "flex", flexDirection: "column", py: 0.25}}>
|
||||
<Typography variant="body2">{formatTokenLabel(token)}</Typography>
|
||||
<Typography variant="caption" color={tokenMeetsAIChatRequirements(token) ? "text.secondary" : "warning.main"} sx={{whiteSpace: "normal"}}>
|
||||
{(token.scopes || []).join(", ") || "No scopes"}
|
||||
</Typography>
|
||||
</Box>
|
||||
</MenuItem>
|
||||
))}
|
||||
</Select>
|
||||
</FormControl>
|
||||
<Button
|
||||
variant="outlined"
|
||||
size="small"
|
||||
disabled={tokenSelectorDisabled}
|
||||
onClick={() => setOpenCreateToken(true)}
|
||||
sx={{height: 40, whiteSpace: "nowrap", px: 2}}
|
||||
>
|
||||
Create
|
||||
</Button>
|
||||
</Box>
|
||||
{selectedToken &&
|
||||
<Box sx={{display: "flex", flexWrap: "wrap", gap: 0.5}}>
|
||||
{selectedOwner &&
|
||||
<Chip size="small" variant="outlined" label={tokenOwnerLabel(selectedOwner)} />
|
||||
}
|
||||
{(selectedToken.scopes || []).map((scope) => <Chip size="small" key={`${selectedToken.id}-${scope}`} label={scope} />)}
|
||||
</Box>
|
||||
}
|
||||
{openCreateToken &&
|
||||
<MythicDialog
|
||||
open={openCreateToken}
|
||||
fullWidth={true}
|
||||
maxWidth="md"
|
||||
onClose={() => setOpenCreateToken(false)}
|
||||
innerDialog={
|
||||
<SettingsAPITokenDialog
|
||||
title={`New AI Chat API Token${selectedOwner ? ` for ${selectedOwner.username}` : ""}`}
|
||||
name={`${selectedOwner?.accountType === "bot" ? "Operation bot" : "Operator"} AI chat token`}
|
||||
initialScopes={createTokenInitialScopes}
|
||||
requiredScopes={AI_CHAT_REQUIRED_TOKEN_SCOPES}
|
||||
requiredScopeDescriptions={{
|
||||
"apitoken.write": "Required so the chat container can request scoped Mythic API tokens for tools.",
|
||||
"chat-ai.write": "Required so the chat container can stream AI responses back into this channel.",
|
||||
}}
|
||||
onAccept={createToken}
|
||||
handleClose={() => setOpenCreateToken(false)}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
}
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
const ChatEmptyState = ({icon, title, detail}) => (
|
||||
<Box className="mythic-chat-empty-state">
|
||||
{icon}
|
||||
@@ -1108,7 +1388,7 @@ const MessageBubble = ({message, request, me, onEdit, onDelete, onCancel, onRetr
|
||||
);
|
||||
};
|
||||
|
||||
const ChatCreateDialog = ({open, onClose, onCreate, chatContainers}) => {
|
||||
const ChatCreateDialog = ({open, onClose, onCreate, chatContainers, currentUser, operationBot}) => {
|
||||
const theme = useTheme();
|
||||
const [name, setName] = React.useState("");
|
||||
const [description, setDescription] = React.useState("");
|
||||
@@ -1117,6 +1397,7 @@ const ChatCreateDialog = ({open, onClose, onCreate, chatContainers}) => {
|
||||
const [model, setModel] = React.useState("");
|
||||
const [configValues, setConfigValues] = React.useState({});
|
||||
const [locked, setLocked] = React.useState(true);
|
||||
const [apiTokenID, setAPITokenID] = React.useState("");
|
||||
React.useEffect(() => {
|
||||
if(open){
|
||||
setName("");
|
||||
@@ -1126,6 +1407,7 @@ const ChatCreateDialog = ({open, onClose, onCreate, chatContainers}) => {
|
||||
setModel("");
|
||||
setConfigValues({});
|
||||
setLocked(true);
|
||||
setAPITokenID("");
|
||||
}
|
||||
}, [open]);
|
||||
const selectedContainer = React.useMemo(() => (
|
||||
@@ -1164,6 +1446,7 @@ const ChatCreateDialog = ({open, onClose, onCreate, chatContainers}) => {
|
||||
setContainerID("");
|
||||
setModel("");
|
||||
setConfigValues({});
|
||||
setAPITokenID("");
|
||||
}
|
||||
};
|
||||
const changeContainer = (event) => {
|
||||
@@ -1175,7 +1458,7 @@ const ChatCreateDialog = ({open, onClose, onCreate, chatContainers}) => {
|
||||
setConfigValues({});
|
||||
};
|
||||
const createDisabled = name.trim() === "" ||
|
||||
(channelType === "ai" && (!containerID || selectedContainerModels.length === 0 || model === "" || configHasMissingRequiredValues(configValues, configOptions)));
|
||||
(channelType === "ai" && (!containerID || selectedContainerModels.length === 0 || model === "" || !apiTokenID || configHasMissingRequiredValues(configValues, configOptions)));
|
||||
const submit = () => {
|
||||
const aiConfig = channelType === "ai" ? normalizeConfigForSubmit(configValues, configOptions) : {};
|
||||
onCreate({
|
||||
@@ -1186,6 +1469,7 @@ const ChatCreateDialog = ({open, onClose, onCreate, chatContainers}) => {
|
||||
chat_model: channelType === "ai" ? model : "",
|
||||
locked: channelType === "ai" ? locked : false,
|
||||
ai_metadata: channelType === "ai" ? applyConfigToMetadata({}, aiConfig) : {},
|
||||
apitokens_id: channelType === "ai" ? Number(apiTokenID) : null,
|
||||
});
|
||||
};
|
||||
return (
|
||||
@@ -1254,6 +1538,12 @@ const ChatCreateDialog = ({open, onClose, onCreate, chatContainers}) => {
|
||||
{configOptions.length > 0 &&
|
||||
<ChatConfigurationFields options={configOptions} values={configValues} setValues={setConfigValues} />
|
||||
}
|
||||
<ChatAPITokenSelector
|
||||
value={apiTokenID}
|
||||
setValue={setAPITokenID}
|
||||
currentUser={currentUser}
|
||||
operationBot={operationBot}
|
||||
/>
|
||||
<Box sx={{border: `1px solid ${alpha(theme.palette.info.main, 0.22)}`, borderRadius: 1, p: 1.25, backgroundColor: alpha(theme.palette.info.main, theme.palette.mode === "dark" ? 0.12 : 0.07)}}>
|
||||
<FormControlLabel
|
||||
control={<Switch checked={locked} onChange={(e) => setLocked(e.target.checked)} />}
|
||||
@@ -1331,11 +1621,12 @@ const ChatSearchDialog = ({open, onClose, onSearch, searchText, setSearchText, s
|
||||
);
|
||||
};
|
||||
|
||||
const ChatEditChannelDialog = ({open, channel, onClose, onSave, chatContainers = []}) => {
|
||||
const ChatEditChannelDialog = ({open, channel, onClose, onSave, chatContainers = [], currentUser, operationBot}) => {
|
||||
const [name, setName] = React.useState("");
|
||||
const [description, setDescription] = React.useState("");
|
||||
const [chatModel, setChatModel] = React.useState("");
|
||||
const [configValues, setConfigValues] = React.useState({});
|
||||
const [apiTokenID, setAPITokenID] = React.useState("");
|
||||
const isGeneralChannel = isGeneralChatChannel(channel);
|
||||
const isAIChannel = channel?.channel_type === "ai";
|
||||
const containerModels = React.useMemo(() => {
|
||||
@@ -1354,6 +1645,7 @@ const ChatEditChannelDialog = ({open, channel, onClose, onSave, chatContainers =
|
||||
setName(channel.name || "");
|
||||
setDescription(channel.description || "");
|
||||
setChatModel(channel.chat_model || "");
|
||||
setAPITokenID(channel.apitokens_id || "");
|
||||
const initialModel = modelForChannel(channel, chatContainers);
|
||||
setConfigValues(buildDefaultConfigValues(getModelConfigOptions(initialModel), getChannelAIConfig(channel)));
|
||||
}
|
||||
@@ -1382,11 +1674,12 @@ const ChatEditChannelDialog = ({open, channel, onClose, onSave, chatContainers =
|
||||
getChannelAIMetadata(channel),
|
||||
normalizeConfigForSubmit(configValues, configOptions),
|
||||
);
|
||||
update.apitokens_id = Number(apiTokenID);
|
||||
}
|
||||
onSave(update);
|
||||
};
|
||||
const saveDisabled = (!isGeneralChannel && name.trim() === "") ||
|
||||
(isAIChannel && configHasMissingRequiredValues(configValues, configOptions));
|
||||
(isAIChannel && (!apiTokenID || configHasMissingRequiredValues(configValues, configOptions)));
|
||||
return (
|
||||
<Dialog open={open} onClose={onClose} maxWidth="sm" fullWidth>
|
||||
<DialogTitle>{isAIChannel ? "Edit AI Chat" : "Edit Channel"}</DialogTitle>
|
||||
@@ -1438,6 +1731,13 @@ const ChatEditChannelDialog = ({open, channel, onClose, onSave, chatContainers =
|
||||
</Select>
|
||||
</FormControl>
|
||||
<ChatConfigurationFields options={configOptions} values={configValues} setValues={setConfigValues} />
|
||||
<ChatAPITokenSelector
|
||||
value={apiTokenID}
|
||||
setValue={setAPITokenID}
|
||||
currentToken={channel?.apitoken}
|
||||
currentUser={currentUser}
|
||||
operationBot={operationBot}
|
||||
/>
|
||||
</>
|
||||
}
|
||||
</DialogContent>
|
||||
@@ -1529,6 +1829,7 @@ export function Chat({me}) {
|
||||
const [editText, setEditText] = React.useState("");
|
||||
const [reviewMessage, setReviewMessage] = React.useState(null);
|
||||
const [refreshingSpecialMessageID, setRefreshingSpecialMessageID] = React.useState(null);
|
||||
const [chatSplitSizes, setChatSplitSizes] = React.useState(getStoredChatSplitSizes);
|
||||
const messagesContainerRef = React.useRef(null);
|
||||
const messagesEndRef = React.useRef(null);
|
||||
const messagesNearBottomRef = React.useRef(true);
|
||||
@@ -1540,6 +1841,10 @@ export function Chat({me}) {
|
||||
lastMessageStatus: null,
|
||||
lastMessageLength: 0,
|
||||
});
|
||||
const readStateRef = React.useRef({});
|
||||
const submittedReadStateRef = React.useRef({});
|
||||
const pendingReadStateRef = React.useRef({});
|
||||
const markReadTimersRef = React.useRef({});
|
||||
const streamStart = React.useRef(getSkewedNow().toISOString());
|
||||
const [baseChannels, setBaseChannels] = React.useState([]);
|
||||
const [allChatContainers, setAllChatContainers] = React.useState([]);
|
||||
@@ -1549,6 +1854,7 @@ export function Chat({me}) {
|
||||
const selectedChannelIDRef = React.useRef(selectedChannelID);
|
||||
const selectedChannelStreamStart = React.useMemo(() => getSkewedNow().toISOString(), [selectedChannelID]);
|
||||
selectedChannelIDRef.current = selectedChannelID;
|
||||
readStateRef.current = readState;
|
||||
|
||||
const {data: channelData} = useQuery(CHAT_CHANNELS_QUERY, {fetchPolicy: "no-cache"});
|
||||
const {data: readStateData} = useQuery(CHAT_READ_STATE_QUERY, {fetchPolicy: "no-cache"});
|
||||
@@ -1635,10 +1941,19 @@ export function Chat({me}) {
|
||||
const generalChannel = React.useMemo(() => {
|
||||
return channels.find((channel) => isGeneralChatChannel(channel)) || null;
|
||||
}, [channels]);
|
||||
const currentUser = React.useMemo(() => ({
|
||||
id: currentOperatorData?.operator_by_pk?.id || currentMe?.user?.user_id || 0,
|
||||
username: currentOperatorData?.operator_by_pk?.username || currentMe?.user?.username || "Current user",
|
||||
}), [currentOperatorData?.operator_by_pk?.id, currentOperatorData?.operator_by_pk?.username, currentMe?.user?.user_id, currentMe?.user?.username]);
|
||||
const currentOperatorViewMode = currentOperatorData?.operatoroperation?.[0]?.view_mode || currentMe?.user?.view_mode || "";
|
||||
const operationBot = currentOperatorData?.operation_bot?.[0] || null;
|
||||
const isMythicAdmin = Boolean(currentMe?.user?.admin || currentOperatorData?.operator_by_pk?.admin);
|
||||
const canCreateSystemMessage = isMythicAdmin || currentOperatorViewMode === "lead";
|
||||
const selectedChannelIsGeneral = isGeneralChatChannel(selectedChannel);
|
||||
const updateChatSplitSizes = React.useCallback((sizes) => {
|
||||
setChatSplitSizes(sizes);
|
||||
localStorage.setItem(CHAT_SPLIT_STORAGE_KEY, JSON.stringify(sizes));
|
||||
}, []);
|
||||
|
||||
React.useEffect(() => {
|
||||
if(!selectedChannelID && channels.length > 0){
|
||||
@@ -1772,6 +2087,57 @@ export function Chat({me}) {
|
||||
onError: (error) => snackActions.error(error.message),
|
||||
});
|
||||
const [markRead] = useMutation(MARK_READ);
|
||||
const queueMarkRead = React.useCallback((channelID, messageID) => {
|
||||
if(!channelID || !messageID){
|
||||
return;
|
||||
}
|
||||
const currentReadID = readStateRef.current[channelID] || 0;
|
||||
const submittedReadID = submittedReadStateRef.current[channelID] || 0;
|
||||
const pendingReadID = pendingReadStateRef.current[channelID] || 0;
|
||||
if(messageID <= Math.max(currentReadID, submittedReadID, pendingReadID)){
|
||||
return;
|
||||
}
|
||||
pendingReadStateRef.current[channelID] = messageID;
|
||||
if(markReadTimersRef.current[channelID]){
|
||||
clearTimeout(markReadTimersRef.current[channelID]);
|
||||
}
|
||||
markReadTimersRef.current[channelID] = setTimeout(() => {
|
||||
const lastReadMessageID = pendingReadStateRef.current[channelID] || 0;
|
||||
delete pendingReadStateRef.current[channelID];
|
||||
delete markReadTimersRef.current[channelID];
|
||||
const latestKnownReadID = Math.max(
|
||||
readStateRef.current[channelID] || 0,
|
||||
submittedReadStateRef.current[channelID] || 0
|
||||
);
|
||||
if(lastReadMessageID <= latestKnownReadID){
|
||||
return;
|
||||
}
|
||||
submittedReadStateRef.current[channelID] = lastReadMessageID;
|
||||
markRead({variables: {channel_id: channelID, last_read_message_id: lastReadMessageID}}).catch(() => {
|
||||
if((submittedReadStateRef.current[channelID] || 0) <= lastReadMessageID){
|
||||
submittedReadStateRef.current[channelID] = readStateRef.current[channelID] || 0;
|
||||
}
|
||||
});
|
||||
}, 750);
|
||||
}, [markRead]);
|
||||
React.useEffect(() => {
|
||||
Object.entries(readState).forEach(([channelID, lastReadMessageID]) => {
|
||||
submittedReadStateRef.current[channelID] = Math.max(submittedReadStateRef.current[channelID] || 0, lastReadMessageID || 0);
|
||||
if((pendingReadStateRef.current[channelID] || 0) <= (lastReadMessageID || 0)){
|
||||
delete pendingReadStateRef.current[channelID];
|
||||
if(markReadTimersRef.current[channelID]){
|
||||
clearTimeout(markReadTimersRef.current[channelID]);
|
||||
delete markReadTimersRef.current[channelID];
|
||||
}
|
||||
}
|
||||
});
|
||||
}, [readState]);
|
||||
React.useEffect(() => {
|
||||
return () => {
|
||||
Object.values(markReadTimersRef.current).forEach((timer) => clearTimeout(timer));
|
||||
markReadTimersRef.current = {};
|
||||
};
|
||||
}, []);
|
||||
const [refreshSpecialMessage] = useMutation(REFRESH_SPECIAL_MESSAGE, {
|
||||
onCompleted: (data) => {
|
||||
if(data.chatRefreshSpecialMessage.status !== "success"){
|
||||
@@ -1838,9 +2204,9 @@ export function Chat({me}) {
|
||||
}
|
||||
|
||||
if(selectedChannelID && lastMessageMatchesChannel){
|
||||
markRead({variables: {channel_id: selectedChannelID, last_read_message_id: lastMessage.id}}).catch(() => {});
|
||||
queueMarkRead(selectedChannelID, lastMessage.id);
|
||||
}
|
||||
}, [messages, selectedChannelID, markRead]);
|
||||
}, [messages, selectedChannelID, queueMarkRead]);
|
||||
|
||||
const standardChannels = channels.filter((channel) => channel.channel_type === "standard" && (showArchived || !channel.archived));
|
||||
const aiChannels = channels.filter((channel) => channel.channel_type === "ai" && (showArchived || !channel.archived));
|
||||
@@ -1983,11 +2349,16 @@ export function Chat({me}) {
|
||||
</Box>
|
||||
}
|
||||
/>
|
||||
<Box
|
||||
className="mythic-chat-layout"
|
||||
sx={{
|
||||
<Split
|
||||
className={`mythic-chat-layout`}
|
||||
direction="horizontal"
|
||||
sizes={chatSplitSizes}
|
||||
minSize={[0, 0]}
|
||||
onDragEnd={updateChatSplitSizes}
|
||||
style={{
|
||||
backgroundColor: "transparent",
|
||||
boxShadow: "none",
|
||||
width: "100%",
|
||||
}}
|
||||
>
|
||||
<Box className="mythic-chat-sidebar">
|
||||
@@ -2175,12 +2546,14 @@ export function Chat({me}) {
|
||||
</IconButton>
|
||||
</Box>
|
||||
</Box>
|
||||
</Box>
|
||||
</Split>
|
||||
<ChatCreateDialog
|
||||
open={createOpen}
|
||||
onClose={() => setCreateOpen(false)}
|
||||
onCreate={onCreateChannel}
|
||||
chatContainers={chatContainers}
|
||||
currentUser={currentUser}
|
||||
operationBot={operationBot}
|
||||
/>
|
||||
<ChatEditChannelDialog
|
||||
open={editChannelOpen}
|
||||
@@ -2188,6 +2561,8 @@ export function Chat({me}) {
|
||||
chatContainers={chatContainers}
|
||||
onClose={() => setEditChannelOpen(false)}
|
||||
onSave={saveChannelDetails}
|
||||
currentUser={currentUser}
|
||||
operationBot={operationBot}
|
||||
/>
|
||||
<ChatSystemMessageDialog
|
||||
open={systemMessageOpen}
|
||||
|
||||
@@ -37,6 +37,26 @@ query apiTokenScopeDefinitionsQuery {
|
||||
}
|
||||
`;
|
||||
|
||||
const normalizeAPITokenScopeSelection = (scopes) => {
|
||||
const uniqueScopes = Array.from(new Set(scopes.filter((scope) => Boolean(scope)))).sort();
|
||||
if(uniqueScopes.includes("*")){
|
||||
return ["*"];
|
||||
}
|
||||
return uniqueScopes.reduce((prev, scope) => {
|
||||
const resource = scope.endsWith(".*") ? scope.slice(0, -2) : scope.split(".")[0];
|
||||
if(scope.endsWith(".*")){
|
||||
return [
|
||||
...prev.filter((selectedScope) => !selectedScope.startsWith(`${resource}.`)),
|
||||
scope,
|
||||
].sort();
|
||||
}
|
||||
if(prev.includes(`${resource}.*`)){
|
||||
return prev;
|
||||
}
|
||||
return [...prev, scope].sort();
|
||||
}, []);
|
||||
};
|
||||
|
||||
|
||||
export function SettingsOperatorDialog(props) {
|
||||
const [username, setUsername] = React.useState(props.username ? props.username : "");
|
||||
@@ -208,7 +228,13 @@ export function SettingsBotDialog(props) {
|
||||
}
|
||||
export function SettingsAPITokenDialog(props) {
|
||||
const [name, setName] = React.useState(props.name ? props.name : "");
|
||||
const [selectedScopes, setSelectedScopes] = React.useState([]);
|
||||
const requiredScopes = React.useMemo(() => props.requiredScopes || [], [props.requiredScopes]);
|
||||
const requiredScopeDescriptions = props.requiredScopeDescriptions || {};
|
||||
const initialScopes = React.useMemo(() => props.initialScopes || [], [props.initialScopes]);
|
||||
const initialSelectedScopes = React.useMemo(() => (
|
||||
normalizeAPITokenScopeSelection([...initialScopes, ...requiredScopes])
|
||||
), [initialScopes, requiredScopes]);
|
||||
const [selectedScopes, setSelectedScopes] = React.useState(initialSelectedScopes);
|
||||
const [filter, setFilter] = React.useState("");
|
||||
const {data: scopeData, loading: scopeLoading, error: scopeQueryError} = useQuery(apiTokenScopeDefinitionsQuery, {
|
||||
fetchPolicy: "no-cache",
|
||||
@@ -254,6 +280,11 @@ export function SettingsAPITokenDialog(props) {
|
||||
const scopesUnavailable = scopeLoading || scopeLoadFailed;
|
||||
const selectedScopesLabel = selectedScopes.includes("*") ? "Full access selected" : `${selectedScopes.length} selected`;
|
||||
const fullAccessDisabled = !grantableWildcards.includes("*");
|
||||
React.useEffect(() => {
|
||||
if(initialSelectedScopes.length > 0){
|
||||
setSelectedScopes((prev) => Array.from(new Set([...initialSelectedScopes, ...prev])).sort());
|
||||
}
|
||||
}, [initialSelectedScopes]);
|
||||
|
||||
const onUsernameChange = (name, value, error) => {
|
||||
setName(value);
|
||||
@@ -344,6 +375,18 @@ export function SettingsAPITokenDialog(props) {
|
||||
</>
|
||||
}
|
||||
>
|
||||
{requiredScopes.length > 0 &&
|
||||
<Box className="mythic-api-token-scope-card mythic-api-token-scope-card-selected" sx={{mb: 1.25}}>
|
||||
<Box className="mythic-api-token-scope-card-copy">
|
||||
<Typography className="mythic-api-token-scope-card-title">Needed for this use</Typography>
|
||||
{requiredScopes.map((scope) => (
|
||||
<Typography key={`required-${scope}`} variant="caption" color="text.secondary" sx={{display: "block"}}>
|
||||
{scope}: {requiredScopeDescriptions[scope] || "Required for this workflow."}
|
||||
</Typography>
|
||||
))}
|
||||
</Box>
|
||||
</Box>
|
||||
}
|
||||
<TextField
|
||||
className="mythic-api-token-scope-search"
|
||||
size="small"
|
||||
|
||||
@@ -11550,24 +11550,45 @@ tspan {
|
||||
font-size: 1rem;
|
||||
}
|
||||
.mythic-chat-layout {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(240px, 300px) minmax(0, 1fr);
|
||||
gap: 8px;
|
||||
display: flex;
|
||||
min-height: 0;
|
||||
flex: 1 1 auto;
|
||||
border-radius: 8px;
|
||||
overflow: visible;
|
||||
}
|
||||
.mythic-chat-layout > .gutter.gutter-horizontal {
|
||||
cursor: col-resize;
|
||||
margin: 0;
|
||||
position: relative;
|
||||
margin-top: 10px;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
.mythic-chat-layout > .gutter.gutter-horizontal::after {
|
||||
background-color: var(--mythic-global-411);
|
||||
border-radius: 999px;
|
||||
content: "";
|
||||
position: absolute;
|
||||
transition: background-color 120ms ease;
|
||||
}
|
||||
.mythic-chat-layout > .gutter.gutter-horizontal:hover::after {
|
||||
background-color: var(--mythic-global-070);
|
||||
}
|
||||
.mythic-chat-sidebar {
|
||||
background-color: var(--mythic-global-410);
|
||||
border: 1px solid var(--mythic-global-411);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-height: 0;
|
||||
min-width: 0;
|
||||
border-radius: 8px;
|
||||
box-shadow: inset 0 1px 0 var(--mythic-global-096);
|
||||
overflow: hidden;
|
||||
}
|
||||
.mythic-chat-layout-channels-collapsed .mythic-chat-sidebar {
|
||||
border-left-width: 0;
|
||||
border-right-width: 0;
|
||||
opacity: 0;
|
||||
pointer-events: none;
|
||||
}
|
||||
.mythic-chat-sidebar-toolbar {
|
||||
background-color: var(--mythic-global-412);
|
||||
border-bottom: 1px solid var(--mythic-global-411);
|
||||
@@ -12257,9 +12278,6 @@ tspan {
|
||||
text-align: center;
|
||||
}
|
||||
@media (max-width: 900px) {
|
||||
.mythic-chat-layout {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
.mythic-chat-sidebar {
|
||||
max-height: 220px;
|
||||
}
|
||||
|
||||
@@ -19,10 +19,12 @@ func TestMythicConnection() {
|
||||
webAddress := "127.0.0.1"
|
||||
mythicEnv := config.GetMythicEnv()
|
||||
queryBodyString := `
|
||||
query meHook {
|
||||
status
|
||||
error
|
||||
}
|
||||
query whoami {
|
||||
whoami {
|
||||
status
|
||||
error
|
||||
}
|
||||
}
|
||||
`
|
||||
|
||||
if mythicEnv.GetString("NGINX_HOST") == "mythic_nginx" {
|
||||
@@ -54,9 +56,9 @@ func TestMythicConnection() {
|
||||
resp.Body.Close()
|
||||
if resp.StatusCode == 200 || resp.StatusCode == 403 || resp.StatusCode == 404 {
|
||||
if resp.StatusCode == 403 {
|
||||
log.Printf("[+] Successfully connected to Mythic at " + loginAddress + ", but blocked by ALLOWED_IP_BLOCKS setting\n\n")
|
||||
log.Printf("[+] Successfully connected to Mythic at %s, but blocked by ALLOWED_IP_BLOCKS setting\n\n", loginAddress)
|
||||
} else {
|
||||
log.Printf("[+] Successfully connected to Mythic at " + loginAddress + "\n\n")
|
||||
log.Printf("[+] Successfully connected to Mythic at %s\n\n", loginAddress)
|
||||
}
|
||||
for j := range count {
|
||||
log.Printf("[*] Attempting to connect to Mythic's GraphQL, attempt %d/%d\n", j+1, maxCount)
|
||||
|
||||
@@ -66,6 +66,7 @@ type Mutation {
|
||||
chat_model: String
|
||||
locked: Boolean
|
||||
ai_metadata: jsonb
|
||||
apitokens_id: Int
|
||||
): ChatActionOutput
|
||||
}
|
||||
|
||||
@@ -128,6 +129,7 @@ type Mutation {
|
||||
locked: Boolean
|
||||
chat_model: String
|
||||
ai_metadata: jsonb
|
||||
apitokens_id: Int
|
||||
): ChatActionOutput
|
||||
}
|
||||
|
||||
@@ -526,7 +528,7 @@ type Mutation {
|
||||
}
|
||||
|
||||
type Query {
|
||||
meHook: meHookOutput
|
||||
whoami: whoamiOutput
|
||||
}
|
||||
|
||||
type Mutation {
|
||||
@@ -1026,11 +1028,29 @@ type previewFileOutput {
|
||||
contents: String
|
||||
}
|
||||
|
||||
type meHookOutput {
|
||||
type whoamiOutput {
|
||||
status: String!
|
||||
error: String
|
||||
user_id: Int
|
||||
username: String
|
||||
email: String
|
||||
account_type: String
|
||||
admin: Boolean
|
||||
active: Boolean
|
||||
deleted: Boolean
|
||||
current_operation_id: Int
|
||||
current_operation: String
|
||||
view_mode: String
|
||||
view_utc_time: Boolean
|
||||
last_login: String
|
||||
current_utc_time: String
|
||||
auth_method: String
|
||||
apitoken: jsonb
|
||||
eventstepinstance_id: Int
|
||||
scopes: [String!]
|
||||
effective_scopes: [String!]
|
||||
operations: jsonb
|
||||
scope_info: jsonb
|
||||
}
|
||||
|
||||
type deleteTasksAndCallbacksOutput {
|
||||
|
||||
@@ -765,10 +765,10 @@ actions:
|
||||
- role: operation_admin
|
||||
- role: mythic_admin
|
||||
- role: developer
|
||||
- name: meHook
|
||||
- name: whoami
|
||||
definition:
|
||||
kind: ""
|
||||
handler: '{{MYTHIC_ACTIONS_URL_BASE}}/me_webhook'
|
||||
handler: '{{MYTHIC_ACTIONS_URL_BASE}}/whoami_webhook'
|
||||
forward_client_headers: true
|
||||
permissions:
|
||||
- role: spectator
|
||||
@@ -1088,7 +1088,7 @@ custom_types:
|
||||
type: object
|
||||
- name: generateReportOutput
|
||||
- name: previewFileOutput
|
||||
- name: meHookOutput
|
||||
- name: whoamiOutput
|
||||
- name: deleteTasksAndCallbacksOutput
|
||||
- name: sendExternalWebhookOutput
|
||||
- name: deleteTagtypeOutput
|
||||
|
||||
@@ -5,6 +5,9 @@ object_relationships:
|
||||
- name: callback
|
||||
using:
|
||||
foreign_key_constraint_on: callback_id
|
||||
- name: chat_channel
|
||||
using:
|
||||
foreign_key_constraint_on: chat_channel_id
|
||||
- name: created_by_operator
|
||||
using:
|
||||
foreign_key_constraint_on: created_by
|
||||
@@ -21,6 +24,13 @@ object_relationships:
|
||||
using:
|
||||
foreign_key_constraint_on: task_id
|
||||
array_relationships:
|
||||
- name: chat_channels
|
||||
using:
|
||||
foreign_key_constraint_on:
|
||||
column: apitokens_id
|
||||
table:
|
||||
name: chat_channel
|
||||
schema: public
|
||||
- name: callbackgraphedges
|
||||
using:
|
||||
foreign_key_constraint_on:
|
||||
@@ -140,6 +150,7 @@ select_permissions:
|
||||
- active
|
||||
- deleted
|
||||
- callback_id
|
||||
- chat_channel_id
|
||||
- created_by
|
||||
- eventstepinstance_id
|
||||
- id
|
||||
@@ -169,6 +180,7 @@ select_permissions:
|
||||
- active
|
||||
- deleted
|
||||
- callback_id
|
||||
- chat_channel_id
|
||||
- created_by
|
||||
- eventstepinstance_id
|
||||
- id
|
||||
@@ -187,6 +199,12 @@ select_permissions:
|
||||
_eq: X-Hasura-User-Id
|
||||
- operator_id:
|
||||
_eq: X-Hasura-User-Id
|
||||
- operator:
|
||||
_and:
|
||||
- account_type:
|
||||
_eq: bot
|
||||
- current_operation_id:
|
||||
_eq: X-Hasura-current-operation-id
|
||||
- operator_id:
|
||||
_lte: X-Hasura-Scope-apitoken-read-operator
|
||||
- role: operator
|
||||
@@ -195,6 +213,7 @@ select_permissions:
|
||||
- active
|
||||
- deleted
|
||||
- callback_id
|
||||
- chat_channel_id
|
||||
- created_by
|
||||
- eventstepinstance_id
|
||||
- id
|
||||
@@ -213,6 +232,12 @@ select_permissions:
|
||||
_eq: X-Hasura-User-Id
|
||||
- operator_id:
|
||||
_eq: X-Hasura-User-Id
|
||||
- operator:
|
||||
_and:
|
||||
- account_type:
|
||||
_eq: bot
|
||||
- current_operation_id:
|
||||
_eq: X-Hasura-current-operation-id
|
||||
- operator_id:
|
||||
_lte: X-Hasura-Scope-apitoken-read-operator
|
||||
- role: spectator
|
||||
@@ -221,6 +246,7 @@ select_permissions:
|
||||
- active
|
||||
- deleted
|
||||
- callback_id
|
||||
- chat_channel_id
|
||||
- created_by
|
||||
- eventstepinstance_id
|
||||
- id
|
||||
|
||||
@@ -5,6 +5,9 @@ object_relationships:
|
||||
- name: archived_operator
|
||||
using:
|
||||
foreign_key_constraint_on: archived_by
|
||||
- name: apitoken
|
||||
using:
|
||||
foreign_key_constraint_on: apitokens_id
|
||||
- name: chat_container
|
||||
using:
|
||||
foreign_key_constraint_on: chat_container_id
|
||||
@@ -44,6 +47,7 @@ select_permissions:
|
||||
permission:
|
||||
columns:
|
||||
- ai_metadata
|
||||
- apitokens_id
|
||||
- archived
|
||||
- archived_at
|
||||
- archived_by
|
||||
@@ -82,6 +86,7 @@ select_permissions:
|
||||
permission:
|
||||
columns:
|
||||
- ai_metadata
|
||||
- apitokens_id
|
||||
- archived
|
||||
- archived_at
|
||||
- archived_by
|
||||
@@ -120,6 +125,7 @@ select_permissions:
|
||||
permission:
|
||||
columns:
|
||||
- ai_metadata
|
||||
- apitokens_id
|
||||
- archived
|
||||
- archived_at
|
||||
- archived_by
|
||||
@@ -158,6 +164,7 @@ select_permissions:
|
||||
permission:
|
||||
columns:
|
||||
- ai_metadata
|
||||
- apitokens_id
|
||||
- archived
|
||||
- archived_at
|
||||
- archived_by
|
||||
|
||||
@@ -126,6 +126,7 @@ func GetClaims(c *gin.Context) (*mythicjwt.CustomClaims, error) {
|
||||
}
|
||||
tokenString, err := ExtractToken(c)
|
||||
if err != nil {
|
||||
logging.LogError(err, "failed to extract token")
|
||||
return nil, err
|
||||
}
|
||||
if looksLikeOpaqueAPIToken(tokenString) {
|
||||
@@ -139,6 +140,7 @@ func GetClaims(c *gin.Context) (*mythicjwt.CustomClaims, error) {
|
||||
return typedClaims, nil
|
||||
}
|
||||
}
|
||||
logging.LogError(errors.New("failed to get claims from validated apitoken"), "failed to get claims from validated apitoken")
|
||||
return nil, errors.New("failed to get claims from validated apitoken")
|
||||
}
|
||||
claims := mythicjwt.CustomClaims{}
|
||||
@@ -166,8 +168,10 @@ func TokenValid(c *gin.Context) error {
|
||||
operator, err := database.GetUserFromID(claims.UserID)
|
||||
if err == nil {
|
||||
c.Set(ContextKeyUsername, operator.Username)
|
||||
} else {
|
||||
logging.LogError(err, "failed to get user from id")
|
||||
}
|
||||
return nil
|
||||
return err
|
||||
}
|
||||
|
||||
type HasuraRequest struct {
|
||||
|
||||
@@ -37,6 +37,7 @@ var (
|
||||
AUTH_METHOD_ON_START = "on_start"
|
||||
AUTH_METHOD_PAYLOAD = "payload"
|
||||
AUTH_METHOD_CALLBACK = "callback"
|
||||
AUTH_METHOD_CHAT = "chat"
|
||||
ErrFailedToFindRefreshToken = errors.New("Failed to find refresh token for specified access token")
|
||||
ErrRefreshTokenMissmatch = errors.New("Refresh token doesn't match for the given access token")
|
||||
ErrUnexpectedSigningMethod = errors.New("Unexpected signing method")
|
||||
|
||||
@@ -137,7 +137,13 @@ create table if not exists "public"."chat_message" (
|
||||
message text not null default '',
|
||||
deleted boolean not null default false,
|
||||
search_vector tsvector generated always as (
|
||||
to_tsvector('simple', case when deleted then '' else coalesce(message, '') end)
|
||||
to_tsvector(
|
||||
'simple',
|
||||
case
|
||||
when deleted or status <> 'complete' then ''
|
||||
else coalesce(message, '')
|
||||
end
|
||||
)
|
||||
) stored,
|
||||
edited boolean not null default false,
|
||||
edited_at timestamp without time zone,
|
||||
@@ -228,6 +234,10 @@ create table if not exists "public"."chat_request" (
|
||||
create index if not exists chat_request_operation_channel_idx
|
||||
on "public"."chat_request" using btree (operation_id, channel_id, id desc);
|
||||
|
||||
create index if not exists chat_request_active_operation_channel_idx
|
||||
on "public"."chat_request" using btree (operation_id, channel_id, id desc)
|
||||
where status in ('pending', 'streaming');
|
||||
|
||||
create index if not exists chat_request_response_message_id_idx
|
||||
on "public"."chat_request" using btree (response_message_id);
|
||||
|
||||
@@ -447,6 +457,7 @@ drop index if exists "public"."mythictree_operation_tree_host_full_callback_idx"
|
||||
drop index if exists "public"."mythictree_operation_tree_host_parent_callback_idx";
|
||||
drop index if exists "public"."operationeventlog_unresolved_warning_source_operation_level_idx";
|
||||
drop index if exists "public"."chat_channel_operation_unread_idx";
|
||||
drop index if exists "public"."chat_request_active_operation_channel_idx";
|
||||
drop trigger if exists update_chat_channel_last_message_id_trigger on "public"."chat_message";
|
||||
drop function if exists public.update_chat_channel_last_message_id();
|
||||
drop trigger if exists create_default_chat_channel_for_operation_trigger on "public"."operation";
|
||||
|
||||
@@ -15,9 +15,46 @@ alter table "public"."eventstepinstance" add constraint "eventstepinstance_user_
|
||||
foreign key ("user_interaction_resolved_by") references "public"."operator"("id") on update restrict on delete restrict not valid;
|
||||
alter table "public"."eventstepinstance" validate constraint "eventstepinstance_user_interaction_resolved_by_fkey";
|
||||
|
||||
alter table "public"."chat_channel"
|
||||
add column if not exists "apitokens_id" integer;
|
||||
|
||||
alter table "public"."chat_channel" drop constraint if exists "chat_channel_apitokens_id_fkey";
|
||||
alter table "public"."chat_channel" add constraint "chat_channel_apitokens_id_fkey"
|
||||
foreign key ("apitokens_id") references "public"."apitokens"("id") on update restrict on delete restrict not valid;
|
||||
alter table "public"."chat_channel" validate constraint "chat_channel_apitokens_id_fkey";
|
||||
|
||||
alter table "public"."chat_channel" drop constraint if exists "chat_channel_ai_apitoken_check";
|
||||
alter table "public"."chat_channel" add constraint "chat_channel_ai_apitoken_check"
|
||||
check (channel_type <> 'ai' or apitokens_id is not null) not valid;
|
||||
|
||||
create index if not exists chat_channel_apitokens_id_idx
|
||||
on "public"."chat_channel" using btree (apitokens_id);
|
||||
|
||||
alter table "public"."apitokens"
|
||||
add column if not exists "chat_channel_id" integer;
|
||||
|
||||
alter table "public"."apitokens" drop constraint if exists "apitokens_chat_channel_id_fkey";
|
||||
alter table "public"."apitokens" add constraint "apitokens_chat_channel_id_fkey"
|
||||
foreign key ("chat_channel_id") references "public"."chat_channel"("id") on update restrict on delete restrict not valid;
|
||||
alter table "public"."apitokens" validate constraint "apitokens_chat_channel_id_fkey";
|
||||
|
||||
create index if not exists apitokens_chat_channel_id_idx
|
||||
on "public"."apitokens" using btree (chat_channel_id);
|
||||
|
||||
-- +migrate Down
|
||||
-- SQL section 'Down' is executed when this migration is rolled back
|
||||
|
||||
drop index if exists "public"."apitokens_chat_channel_id_idx";
|
||||
alter table "public"."apitokens" drop constraint if exists "apitokens_chat_channel_id_fkey";
|
||||
alter table "public"."apitokens"
|
||||
drop column if exists "chat_channel_id";
|
||||
|
||||
drop index if exists "public"."chat_channel_apitokens_id_idx";
|
||||
alter table "public"."chat_channel" drop constraint if exists "chat_channel_ai_apitoken_check";
|
||||
alter table "public"."chat_channel" drop constraint if exists "chat_channel_apitokens_id_fkey";
|
||||
alter table "public"."chat_channel"
|
||||
drop column if exists "apitokens_id";
|
||||
|
||||
alter table "public"."eventstepinstance" drop constraint if exists "eventstepinstance_user_interaction_resolved_by_fkey";
|
||||
|
||||
alter table "public"."eventstepinstance"
|
||||
|
||||
@@ -22,4 +22,5 @@ type Apitokens struct {
|
||||
TaskID structs.NullInt64 `db:"task_id" json:"task_id" mapstructure:"task_id"`
|
||||
CallbackID structs.NullInt64 `db:"callback_id" json:"callback_id" mapstructure:"callback_id"`
|
||||
PayloadID structs.NullInt64 `db:"payload_id" json:"payload_id" mapstructure:"payload_id"`
|
||||
ChatChannelID structs.NullInt64 `db:"chat_channel_id" json:"chat_channel_id" mapstructure:"chat_channel_id"`
|
||||
}
|
||||
|
||||
@@ -41,6 +41,8 @@ type ChatChannel struct {
|
||||
ChatContainer ConsumingContainer `db:"chat_container" json:"chat_container,omitempty"`
|
||||
ChatModel string `db:"chat_model" json:"chat_model"`
|
||||
AIMetadata MythicJSONText `db:"ai_metadata" json:"ai_metadata"`
|
||||
APITokensID structs.NullInt64 `db:"apitokens_id" json:"api_tokens_id" mapstructure:"apitokens_id"`
|
||||
APIToken Apitokens `db:"apitoken" json:"apitoken,omitempty"`
|
||||
CreatedAt time.Time `db:"created_at" json:"created_at"`
|
||||
UpdatedAt time.Time `db:"updated_at" json:"updated_at"`
|
||||
}
|
||||
|
||||
@@ -15,7 +15,7 @@ import (
|
||||
amqp "github.com/rabbitmq/amqp091-go"
|
||||
)
|
||||
|
||||
const chatStreamFlushInterval = 500 * time.Millisecond
|
||||
const chatStreamFlushInterval = 750 * time.Millisecond
|
||||
|
||||
type ChatContainerResponseMessage struct {
|
||||
OperationID int `json:"operation_id" mapstructure:"operation_id"`
|
||||
|
||||
@@ -15,6 +15,7 @@ type MythicRPCAPITokenCreateMessage struct {
|
||||
AgentTaskID *string `json:"agent_task_id"`
|
||||
AgentCallbackID *string `json:"agent_callback_id"`
|
||||
PayloadUUID *string `json:"payload_uuid"`
|
||||
ChatChannelID *int `json:"chat_channel_id"`
|
||||
Scopes []string `json:"scopes"`
|
||||
}
|
||||
|
||||
@@ -44,6 +45,13 @@ func MythicRPCAPITokenCreate(input MythicRPCAPITokenCreateMessage, authContext R
|
||||
response.Error = err.Error()
|
||||
return response
|
||||
}
|
||||
if len(normalizedScopes) == 0 {
|
||||
normalizedScopes, err = mythicjwt.NormalizeAPITokenScopes(authContext.SourceScopes)
|
||||
if err != nil {
|
||||
response.Error = err.Error()
|
||||
return response
|
||||
}
|
||||
}
|
||||
// save off the access_token as an API token and then return it
|
||||
apiToken := databaseStructs.Apitokens{
|
||||
TokenValue: "",
|
||||
@@ -106,8 +114,26 @@ func MythicRPCAPITokenCreate(input MythicRPCAPITokenCreateMessage, authContext R
|
||||
apiToken.TokenType = mythicjwt.AUTH_METHOD_CALLBACK
|
||||
apiToken.CallbackID.Valid = true
|
||||
apiToken.CallbackID.Int64 = int64(callback.ID)
|
||||
} else if input.ChatChannelID != nil {
|
||||
channel := databaseStructs.ChatChannel{}
|
||||
err = database.DB.Get(&channel, `SELECT id, operation_id, apitokens_id, "name"
|
||||
FROM chat_channel
|
||||
WHERE id=$1 AND operation_id=$2 AND channel_type=$3`,
|
||||
*input.ChatChannelID, authContext.OperationID, databaseStructs.ChatChannelTypeAI)
|
||||
if err != nil {
|
||||
response.Error = err.Error()
|
||||
return response
|
||||
}
|
||||
if !channel.APITokensID.Valid || int(channel.APITokensID.Int64) != authContext.APITokensID {
|
||||
response.Error = "ChatChannelID token delegation requires the RabbitMQ auth context to use the API token associated with that chat channel"
|
||||
return response
|
||||
}
|
||||
apiToken.TokenType = mythicjwt.AUTH_METHOD_CHAT
|
||||
apiToken.ChatChannelID.Valid = true
|
||||
apiToken.ChatChannelID.Int64 = int64(channel.ID)
|
||||
apiToken.Name = fmt.Sprintf("Generated Chat API Token via MythicRPC for Chat Channel \"%s\"", channel.Name)
|
||||
} else {
|
||||
response.Error = "No task or callback information provided, can't generate apitoken"
|
||||
response.Error = "No task, callback, payload, or chat channel information provided, can't generate apitoken"
|
||||
return response
|
||||
}
|
||||
defaultScopes := authContext.SourceScopes
|
||||
@@ -121,9 +147,9 @@ func MythicRPCAPITokenCreate(input MythicRPCAPITokenCreateMessage, authContext R
|
||||
return response
|
||||
}
|
||||
statement, err := database.DB.PrepareNamed(`INSERT INTO apitokens
|
||||
(token_value, operator_id, token_type, active, "name", created_by, task_id, callback_id, payload_id, scopes, eventstepinstance_id)
|
||||
(token_value, operator_id, token_type, active, "name", created_by, task_id, callback_id, payload_id, scopes, eventstepinstance_id, chat_channel_id)
|
||||
VALUES
|
||||
(:token_value, :operator_id, :token_type, :active, :name, :created_by, :task_id, :callback_id, :payload_id, :scopes, :eventstepinstance_id)
|
||||
(:token_value, :operator_id, :token_type, :active, :name, :created_by, :task_id, :callback_id, :payload_id, :scopes, :eventstepinstance_id, :chat_channel_id)
|
||||
RETURNING id`)
|
||||
if err != nil {
|
||||
response.Error = err.Error()
|
||||
@@ -149,7 +175,8 @@ func MythicRPCAPITokenCreate(input MythicRPCAPITokenCreateMessage, authContext R
|
||||
}
|
||||
response.Success = true
|
||||
response.APIToken = accessToken
|
||||
if apiToken.TokenType == mythicjwt.AUTH_METHOD_CALLBACK || apiToken.TokenType == mythicjwt.AUTH_METHOD_PAYLOAD {
|
||||
if apiToken.TokenType == mythicjwt.AUTH_METHOD_CALLBACK ||
|
||||
apiToken.TokenType == mythicjwt.AUTH_METHOD_PAYLOAD {
|
||||
// deactivate the token after 5 min (should be a short-lived use)
|
||||
go expireAPITokenAfterShortLivedTTL(apiToken.ID)
|
||||
}
|
||||
|
||||
@@ -18,6 +18,7 @@ type ChatContainerRequestMessage struct {
|
||||
ContainerName string `json:"container_name" mapstructure:"container_name"`
|
||||
OperationID int `json:"operation_id" mapstructure:"operation_id"`
|
||||
ChannelID int `json:"channel_id" mapstructure:"channel_id"`
|
||||
APITokenID int `json:"apitokens_id" mapstructure:"apitokens_id"`
|
||||
ChannelName string `json:"channel_name" mapstructure:"channel_name"`
|
||||
ChannelSlug string `json:"channel_slug" mapstructure:"channel_slug"`
|
||||
RequestID int `json:"request_id" mapstructure:"request_id"`
|
||||
@@ -37,14 +38,15 @@ func (r *rabbitMQConnection) SendChatContainerRequest(containerName string, chat
|
||||
logging.LogError(err, "Failed to generate auth context for chat request")
|
||||
return err
|
||||
}
|
||||
if err = r.SendStructMessage(
|
||||
err = r.SendStructMessage(
|
||||
MYTHIC_EXCHANGE,
|
||||
GetChatContainerRequestRoutingKey(containerName),
|
||||
"",
|
||||
chatMessage,
|
||||
false,
|
||||
headers,
|
||||
); err != nil {
|
||||
)
|
||||
if err != nil {
|
||||
logging.LogError(err, "Failed to send chat request", "container", containerName, "request_id", chatMessage.RequestID)
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -2,10 +2,7 @@ package webcontroller
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"regexp"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
@@ -20,39 +17,6 @@ import (
|
||||
"github.com/lib/pq"
|
||||
)
|
||||
|
||||
const (
|
||||
chatContextMessageLimit = 40
|
||||
chatGeneralChannelName = "general"
|
||||
)
|
||||
|
||||
var chatSlugInvalidCharacters = regexp.MustCompile(`[^a-z0-9]+`)
|
||||
|
||||
type ChatActionResponse struct {
|
||||
Status string `json:"status"`
|
||||
Error string `json:"error,omitempty"`
|
||||
ID int `json:"id,omitempty"`
|
||||
ChannelID int `json:"channel_id,omitempty"`
|
||||
MessageID int `json:"message_id,omitempty"`
|
||||
RequestID int `json:"request_id,omitempty"`
|
||||
ResponseMessageID int `json:"response_message_id,omitempty"`
|
||||
Results []ChatSearchResult `json:"results,omitempty"`
|
||||
}
|
||||
|
||||
type CreateChatChannelInput struct {
|
||||
Input CreateChatChannel `json:"input" binding:"required"`
|
||||
}
|
||||
|
||||
type CreateChatChannel struct {
|
||||
Name string `json:"name" binding:"required"`
|
||||
Description string `json:"description"`
|
||||
ChannelType string `json:"channel_type"`
|
||||
ChatContainerID *int `json:"chat_container_id"`
|
||||
ChatModel string `json:"chat_model"`
|
||||
Locked bool `json:"locked"`
|
||||
AIMetadata interface{} `json:"ai_metadata"`
|
||||
APITokenID *int `json:"apitokens_id"`
|
||||
}
|
||||
|
||||
type UpdateChatChannelInput struct {
|
||||
Input UpdateChatChannel `json:"input" binding:"required"`
|
||||
}
|
||||
@@ -67,18 +31,6 @@ type UpdateChatChannel struct {
|
||||
AIMetadata interface{} `json:"ai_metadata"`
|
||||
APITokenID *int `json:"apitokens_id"`
|
||||
}
|
||||
|
||||
type CreateChatMessageInput struct {
|
||||
Input CreateChatMessage `json:"input" binding:"required"`
|
||||
}
|
||||
|
||||
type CreateChatMessage struct {
|
||||
ChannelID int `json:"channel_id" binding:"required"`
|
||||
Message string `json:"message" binding:"required"`
|
||||
SystemMessage bool `json:"system_message"`
|
||||
AllOperations bool `json:"all_operations"`
|
||||
}
|
||||
|
||||
type EditChatMessageInput struct {
|
||||
Input EditChatMessage `json:"input" binding:"required"`
|
||||
}
|
||||
@@ -147,99 +99,6 @@ type ChatRequestAction struct {
|
||||
RequestID int `json:"request_id" binding:"required"`
|
||||
}
|
||||
|
||||
func CreateChatChannelWebhook(c *gin.Context) {
|
||||
var input CreateChatChannelInput
|
||||
if !bindChatInput(c, &input) {
|
||||
return
|
||||
}
|
||||
operatorOperation, ok := chatOperatorOperation(c)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
channelType := strings.ToLower(strings.TrimSpace(input.Input.ChannelType))
|
||||
if channelType == "" {
|
||||
channelType = databaseStructs.ChatChannelTypeStandard
|
||||
}
|
||||
if channelType != databaseStructs.ChatChannelTypeStandard && channelType != databaseStructs.ChatChannelTypeAI {
|
||||
chatRespondError(c, "unknown chat channel type")
|
||||
return
|
||||
}
|
||||
if !chatRequireScope(c, chatScopeForChannelType(channelType, true)) {
|
||||
return
|
||||
}
|
||||
name := strings.TrimSpace(input.Input.Name)
|
||||
if name == "" {
|
||||
chatRespondError(c, "channel name is required")
|
||||
return
|
||||
}
|
||||
slug, err := uniqueChatSlug(operatorOperation.CurrentOperation.ID, slugifyChatChannelName(name), 0)
|
||||
if err != nil {
|
||||
logging.LogError(err, "Failed to generate unique chat slug")
|
||||
chatRespondError(c, err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
var chatContainerID interface{}
|
||||
var apiTokenID interface{}
|
||||
if channelType == databaseStructs.ChatChannelTypeAI {
|
||||
if input.Input.ChatContainerID == nil || *input.Input.ChatContainerID <= 0 {
|
||||
chatRespondError(c, "AI chat channels require a chat_container_id")
|
||||
return
|
||||
}
|
||||
if input.Input.APITokenID == nil || *input.Input.APITokenID <= 0 {
|
||||
chatRespondError(c, "AI chat channels require an apitokens_id")
|
||||
return
|
||||
}
|
||||
if _, err = getChatContainer(*input.Input.ChatContainerID); err != nil {
|
||||
logging.LogError(err, "Failed to find chat container")
|
||||
chatRespondError(c, "failed to find a chat container with that id")
|
||||
return
|
||||
}
|
||||
if _, err = validateAIChatChannelAPIToken(*input.Input.APITokenID, operatorOperation.CurrentOperation.ID); err != nil {
|
||||
chatRespondError(c, err.Error())
|
||||
return
|
||||
}
|
||||
chatContainerID = *input.Input.ChatContainerID
|
||||
apiTokenID = *input.Input.APITokenID
|
||||
} else if input.Input.APITokenID != nil && *input.Input.APITokenID > 0 {
|
||||
chatRespondError(c, "apitokens_id can only be set for AI chat channels")
|
||||
return
|
||||
}
|
||||
var lockedBy interface{}
|
||||
var lockedAt interface{}
|
||||
if channelType == databaseStructs.ChatChannelTypeAI && input.Input.Locked {
|
||||
lockedBy = operatorOperation.CurrentOperator.ID
|
||||
lockedAt = time.Now().UTC()
|
||||
}
|
||||
metadata := chatJSONText(input.Input.AIMetadata)
|
||||
var channelID int
|
||||
err = database.DB.Get(&channelID, `INSERT INTO chat_channel
|
||||
(operation_id, name, slug, description, channel_type, created_by, locked, locked_by, locked_at,
|
||||
chat_container_id, chat_model, ai_metadata, apitokens_id)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12::jsonb, $13)
|
||||
RETURNING id`,
|
||||
operatorOperation.CurrentOperation.ID,
|
||||
name,
|
||||
slug,
|
||||
input.Input.Description,
|
||||
channelType,
|
||||
operatorOperation.CurrentOperator.ID,
|
||||
channelType == databaseStructs.ChatChannelTypeAI && input.Input.Locked,
|
||||
lockedBy,
|
||||
lockedAt,
|
||||
chatContainerID,
|
||||
input.Input.ChatModel,
|
||||
metadata.String(),
|
||||
apiTokenID,
|
||||
)
|
||||
if err != nil {
|
||||
logging.LogError(err, "Failed to create chat channel")
|
||||
chatRespondError(c, err.Error())
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, ChatActionResponse{Status: "success", ID: channelID, ChannelID: channelID})
|
||||
}
|
||||
|
||||
func UpdateChatChannelWebhook(c *gin.Context) {
|
||||
var input UpdateChatChannelInput
|
||||
if !bindChatInput(c, &input) {
|
||||
@@ -258,7 +117,7 @@ func UpdateChatChannelWebhook(c *gin.Context) {
|
||||
if !chatRequireScope(c, chatScopeForChannel(channel, true)) {
|
||||
return
|
||||
}
|
||||
if !chatCanManageChannel(operatorOperation, channel) {
|
||||
if !chatIsModerator(operatorOperation) && channel.CreatedBy != operatorOperation.CurrentOperator.ID {
|
||||
chatRespondError(c, "only the channel creator or an operation admin can update this channel")
|
||||
return
|
||||
}
|
||||
@@ -399,85 +258,12 @@ func UpdateChatChannelWebhook(c *gin.Context) {
|
||||
chatRespondError(c, err.Error())
|
||||
return
|
||||
}
|
||||
if archived {
|
||||
expireGeneratedChatAPITokensForChannel(channel.ID)
|
||||
}
|
||||
c.JSON(http.StatusOK, ChatActionResponse{Status: "success", ID: channel.ID, ChannelID: channel.ID})
|
||||
}
|
||||
|
||||
func CreateChatMessageWebhook(c *gin.Context) {
|
||||
var input CreateChatMessageInput
|
||||
if !bindChatInput(c, &input) {
|
||||
return
|
||||
}
|
||||
operatorOperation, ok := chatOperatorOperation(c)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
message := strings.TrimSpace(input.Input.Message)
|
||||
if message == "" {
|
||||
chatRespondError(c, "message is required")
|
||||
return
|
||||
}
|
||||
channel, err := getChatChannel(input.Input.ChannelID, operatorOperation.CurrentOperation.ID)
|
||||
if err != nil {
|
||||
logging.LogError(err, "Failed to get chat channel for message")
|
||||
chatRespondError(c, "failed to find chat channel")
|
||||
return
|
||||
}
|
||||
if !chatRequireScope(c, chatScopeForChannel(channel, true)) {
|
||||
return
|
||||
}
|
||||
if input.Input.AllOperations {
|
||||
if !input.Input.SystemMessage {
|
||||
chatRespondError(c, "sending to all operations requires a system message")
|
||||
return
|
||||
}
|
||||
if !operatorOperation.CurrentOperator.Admin {
|
||||
chatRespondError(c, "only a Mythic admin can create system messages in all operations")
|
||||
return
|
||||
}
|
||||
messageID, _, err := insertSystemChatMessageAllOperations(operatorOperation, message, authentication.RabbitMQAuthContextFromGin(c))
|
||||
if err != nil {
|
||||
logging.LogError(err, "Failed to create system chat messages in all operations")
|
||||
chatRespondError(c, err.Error())
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, ChatActionResponse{Status: "success", ID: messageID, MessageID: messageID, ChannelID: channel.ID})
|
||||
return
|
||||
}
|
||||
if channel.Archived {
|
||||
chatRespondError(c, "cannot post to an archived chat channel")
|
||||
return
|
||||
}
|
||||
if input.Input.SystemMessage {
|
||||
if !chatCanCreateSystemMessage(operatorOperation) {
|
||||
chatRespondError(c, "only an admin can create system messages")
|
||||
return
|
||||
}
|
||||
messageID, err := insertSystemChatMessage(operatorOperation, channel, message, authentication.RabbitMQAuthContextFromGin(c))
|
||||
if err != nil {
|
||||
logging.LogError(err, "Failed to create system chat message")
|
||||
chatRespondError(c, err.Error())
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, ChatActionResponse{Status: "success", ID: messageID, MessageID: messageID, ChannelID: channel.ID})
|
||||
return
|
||||
}
|
||||
if channel.ChannelType == databaseStructs.ChatChannelTypeAI {
|
||||
if !chatCanPostToAIChannel(operatorOperation, channel) {
|
||||
chatRespondError(c, "this AI chat is locked to another operator")
|
||||
return
|
||||
}
|
||||
createAIChatMessage(c, operatorOperation, channel, message, nil)
|
||||
return
|
||||
}
|
||||
messageID, err := insertOperatorChatMessage(operatorOperation, channel, message, authentication.RabbitMQAuthContextFromGin(c))
|
||||
if err != nil {
|
||||
logging.LogError(err, "Failed to create chat message")
|
||||
chatRespondError(c, err.Error())
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, ChatActionResponse{Status: "success", ID: messageID, MessageID: messageID, ChannelID: channel.ID})
|
||||
}
|
||||
|
||||
func EditChatMessageWebhook(c *gin.Context) {
|
||||
var input EditChatMessageInput
|
||||
if !bindChatInput(c, &input) {
|
||||
@@ -597,11 +383,17 @@ func MarkChatReadWebhook(c *gin.Context) {
|
||||
lastReadID = maxID.Int64
|
||||
}
|
||||
}
|
||||
if lastReadID == nil {
|
||||
c.JSON(http.StatusOK, ChatActionResponse{Status: "success", ChannelID: channel.ID})
|
||||
return
|
||||
}
|
||||
_, err = database.DB.Exec(`INSERT INTO chat_read_state
|
||||
(operation_id, channel_id, operator_id, last_read_message_id)
|
||||
VALUES ($1, $2, $3, $4)
|
||||
ON CONFLICT (operator_id, channel_id) DO UPDATE
|
||||
SET last_read_message_id=excluded.last_read_message_id`,
|
||||
SET last_read_message_id=excluded.last_read_message_id
|
||||
WHERE chat_read_state.last_read_message_id IS NULL
|
||||
OR chat_read_state.last_read_message_id < excluded.last_read_message_id`,
|
||||
operatorOperation.CurrentOperation.ID, channel.ID, operatorOperation.CurrentOperator.ID, lastReadID)
|
||||
if err != nil {
|
||||
logging.LogError(err, "Failed to mark chat channel read")
|
||||
@@ -811,169 +603,6 @@ func RetryChatRequestWebhook(c *gin.Context) {
|
||||
createAIChatMessage(c, operatorOperation, channel, prompt, &request.ID)
|
||||
}
|
||||
|
||||
func createAIChatMessage(c *gin.Context, operatorOperation *databaseStructs.Operatoroperation, channel databaseStructs.ChatChannel, message string, retryOfID *int) {
|
||||
container, err := getChatContainer(int(channel.ChatContainerID.Int64))
|
||||
if err != nil {
|
||||
logging.LogError(err, "Failed to find AI chat container")
|
||||
chatRespondError(c, "failed to find AI chat container")
|
||||
return
|
||||
}
|
||||
if !container.ContainerRunning {
|
||||
chatRespondError(c, fmt.Sprintf("chat container %s is not running", container.Name))
|
||||
return
|
||||
}
|
||||
activeRequestID, err := getActiveAIChatRequest(operatorOperation.CurrentOperation.ID, channel.ID)
|
||||
if err != nil {
|
||||
logging.LogError(err, "Failed to check active AI chat request")
|
||||
chatRespondError(c, err.Error())
|
||||
return
|
||||
}
|
||||
if activeRequestID > 0 {
|
||||
chatRespondError(c, fmt.Sprintf("AI chat request %d is still in progress; wait for it to finish or cancel it before sending another prompt", activeRequestID))
|
||||
return
|
||||
}
|
||||
chatAuthContext, err := chatChannelAuthContext(channel)
|
||||
if err != nil {
|
||||
chatRespondError(c, err.Error())
|
||||
return
|
||||
}
|
||||
operatorAuthContext := authentication.RabbitMQAuthContextFromGin(c)
|
||||
requestMessageID, err := insertOperatorChatMessage(operatorOperation, channel, message, operatorAuthContext)
|
||||
if err != nil {
|
||||
logging.LogError(err, "Failed to create AI prompt message")
|
||||
chatRespondError(c, err.Error())
|
||||
return
|
||||
}
|
||||
var responseMessageID int
|
||||
if err = database.DB.Get(&responseMessageID, `INSERT INTO chat_message
|
||||
(operation_id, channel_id, author_type, chat_container_id, sender_display_name, status, metadata)
|
||||
VALUES ($1, $2, 'ai', $3, $4, 'pending', $5::jsonb)
|
||||
RETURNING id`,
|
||||
operatorOperation.CurrentOperation.ID,
|
||||
channel.ID,
|
||||
container.ID,
|
||||
container.Name,
|
||||
rabbitmq.GetMythicJSONTextFromStruct(map[string]interface{}{"model": channel.ChatModel}).String(),
|
||||
); err != nil {
|
||||
logging.LogError(err, "Failed to create AI response placeholder")
|
||||
chatRespondError(c, err.Error())
|
||||
return
|
||||
}
|
||||
var retryOf interface{}
|
||||
if retryOfID != nil {
|
||||
retryOf = *retryOfID
|
||||
}
|
||||
var requestID int
|
||||
if err = database.DB.Get(&requestID, `INSERT INTO chat_request
|
||||
(operation_id, channel_id, request_message_id, response_message_id, chat_container_id,
|
||||
model, status, context_snapshot, retry_of_id, created_by)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, 'pending', $7::jsonb, $8, $9)
|
||||
RETURNING id`,
|
||||
operatorOperation.CurrentOperation.ID,
|
||||
channel.ID,
|
||||
requestMessageID,
|
||||
responseMessageID,
|
||||
container.ID,
|
||||
channel.ChatModel,
|
||||
rabbitmq.GetMythicJSONTextFromStruct(map[string]interface{}{}).String(),
|
||||
retryOf,
|
||||
operatorOperation.CurrentOperator.ID,
|
||||
); err != nil {
|
||||
logging.LogError(err, "Failed to create AI chat request")
|
||||
chatRespondError(c, err.Error())
|
||||
return
|
||||
}
|
||||
contextMessages, err := getChatContextMessages(operatorOperation.CurrentOperation.ID, channel.ID, requestMessageID)
|
||||
if err != nil {
|
||||
logging.LogError(err, "Failed to fetch AI chat context")
|
||||
markChatRequestFailed(requestID, responseMessageID, operatorOperation.CurrentOperation.ID, err.Error())
|
||||
chatRespondError(c, err.Error())
|
||||
return
|
||||
}
|
||||
chatConfig := getChatChannelConfig(channel)
|
||||
contextIDs := make([]int, len(contextMessages))
|
||||
for i := range contextMessages {
|
||||
contextIDs[i] = contextMessages[i].ID
|
||||
}
|
||||
contextSnapshot := rabbitmq.GetMythicJSONTextFromStruct(map[string]interface{}{
|
||||
"context_message_ids": contextIDs,
|
||||
"request_message_id": requestMessageID,
|
||||
"response_message_id": responseMessageID,
|
||||
"retry_of_id": retryOf,
|
||||
"context_message_limit": chatContextMessageLimit,
|
||||
"config": chatConfig,
|
||||
})
|
||||
_, _ = database.DB.Exec(`UPDATE chat_request
|
||||
SET context_snapshot=$3::jsonb
|
||||
WHERE id=$1 AND operation_id=$2`, requestID, operatorOperation.CurrentOperation.ID, contextSnapshot.String())
|
||||
|
||||
err = rabbitmq.RabbitMQConnection.SendChatContainerRequest(container.Name, rabbitmq.ChatContainerRequestMessage{
|
||||
OperationID: operatorOperation.CurrentOperation.ID,
|
||||
ChannelID: channel.ID,
|
||||
APITokenID: int(channel.APITokensID.Int64),
|
||||
ChannelName: channel.Name,
|
||||
ChannelSlug: channel.Slug,
|
||||
RequestID: requestID,
|
||||
RequestMessageID: requestMessageID,
|
||||
ResponseMessageID: responseMessageID,
|
||||
Model: channel.ChatModel,
|
||||
Prompt: message,
|
||||
Config: chatConfig,
|
||||
Context: contextMessages,
|
||||
Secrets: rabbitmq.GetSecrets(operatorOperation.CurrentOperator.ID, 0),
|
||||
}, chatAuthContext)
|
||||
if err != nil {
|
||||
markChatRequestFailed(requestID, responseMessageID, operatorOperation.CurrentOperation.ID, err.Error())
|
||||
chatRespondError(c, err.Error())
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, ChatActionResponse{
|
||||
Status: "success",
|
||||
ID: requestMessageID,
|
||||
MessageID: requestMessageID,
|
||||
ChannelID: channel.ID,
|
||||
RequestID: requestID,
|
||||
ResponseMessageID: responseMessageID,
|
||||
})
|
||||
}
|
||||
|
||||
func bindChatInput(c *gin.Context, input interface{}) bool {
|
||||
if err := c.ShouldBindJSON(input); err != nil {
|
||||
logging.LogError(err, "Failed to get JSON parameters for chat webhook")
|
||||
c.JSON(http.StatusOK, ChatActionResponse{Status: "error", Error: err.Error()})
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func chatOperatorOperation(c *gin.Context) (*databaseStructs.Operatoroperation, bool) {
|
||||
ginOperatorOperation, ok := c.Get(authentication.ContextKeyOperatorOperationStruct)
|
||||
if !ok {
|
||||
chatRespondError(c, "failed to get current operation")
|
||||
return nil, false
|
||||
}
|
||||
operatorOperation := ginOperatorOperation.(*databaseStructs.Operatoroperation)
|
||||
return operatorOperation, true
|
||||
}
|
||||
|
||||
func chatRespondError(c *gin.Context, err string) {
|
||||
c.JSON(http.StatusOK, ChatActionResponse{Status: "error", Error: err})
|
||||
}
|
||||
|
||||
func chatRequireScope(c *gin.Context, requiredScope string) bool {
|
||||
claims, err := authentication.GetClaims(c)
|
||||
if err != nil {
|
||||
logging.LogError(err, "Failed to get claims for chat scope check")
|
||||
chatRespondError(c, "failed to get authentication claims")
|
||||
return false
|
||||
}
|
||||
if !mythicjwt.AllowsScope(claims.Scopes, requiredScope) {
|
||||
chatRespondError(c, fmt.Sprintf("missing required scope: %s", requiredScope))
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func chatSearchAllowedChannelTypes(c *gin.Context) []string {
|
||||
claims, err := authentication.GetClaims(c)
|
||||
if err != nil {
|
||||
@@ -990,165 +619,6 @@ func chatSearchAllowedChannelTypes(c *gin.Context) []string {
|
||||
return channelTypes
|
||||
}
|
||||
|
||||
func chatScopeForChannelType(channelType string, write bool) string {
|
||||
if channelType == databaseStructs.ChatChannelTypeAI {
|
||||
if write {
|
||||
return mythicjwt.SCOPE_CHAT_AI_WRITE
|
||||
}
|
||||
return mythicjwt.SCOPE_CHAT_AI_READ
|
||||
}
|
||||
if write {
|
||||
return mythicjwt.SCOPE_CHAT_WRITE
|
||||
}
|
||||
return mythicjwt.SCOPE_CHAT_READ
|
||||
}
|
||||
|
||||
func chatScopeForChannel(channel databaseStructs.ChatChannel, write bool) string {
|
||||
return chatScopeForChannelType(channel.ChannelType, write)
|
||||
}
|
||||
|
||||
func chatIsModerator(operatorOperation *databaseStructs.Operatoroperation) bool {
|
||||
return operatorOperation.CurrentOperator.Admin ||
|
||||
operatorOperation.ViewMode == database.OPERATOR_OPERATION_VIEW_MODE_LEAD
|
||||
}
|
||||
|
||||
func chatCanCreateSystemMessage(operatorOperation *databaseStructs.Operatoroperation) bool {
|
||||
return chatIsModerator(operatorOperation)
|
||||
}
|
||||
|
||||
func chatIsGeneralChannel(channel databaseStructs.ChatChannel) bool {
|
||||
return channel.ChannelType == databaseStructs.ChatChannelTypeStandard &&
|
||||
strings.EqualFold(channel.Slug, chatGeneralChannelName)
|
||||
}
|
||||
|
||||
func chatCanManageChannel(operatorOperation *databaseStructs.Operatoroperation, channel databaseStructs.ChatChannel) bool {
|
||||
return chatIsModerator(operatorOperation) || channel.CreatedBy == operatorOperation.CurrentOperator.ID
|
||||
}
|
||||
|
||||
func chatCanPostToAIChannel(operatorOperation *databaseStructs.Operatoroperation, channel databaseStructs.ChatChannel) bool {
|
||||
if channel.ChannelType != databaseStructs.ChatChannelTypeAI {
|
||||
return true
|
||||
}
|
||||
if !channel.Locked {
|
||||
return true
|
||||
}
|
||||
return chatIsModerator(operatorOperation) ||
|
||||
(channel.LockedBy.Valid && int(channel.LockedBy.Int64) == operatorOperation.CurrentOperator.ID)
|
||||
}
|
||||
|
||||
func slugifyChatChannelName(name string) string {
|
||||
slug := strings.ToLower(strings.TrimSpace(name))
|
||||
slug = chatSlugInvalidCharacters.ReplaceAllString(slug, "-")
|
||||
slug = strings.Trim(slug, "-")
|
||||
if slug == "" {
|
||||
return "channel"
|
||||
}
|
||||
return slug
|
||||
}
|
||||
|
||||
func uniqueChatSlug(operationID int, baseSlug string, excludeChannelID int) (string, error) {
|
||||
for i := 0; i < 1000; i++ {
|
||||
candidate := baseSlug
|
||||
if i > 0 {
|
||||
candidate = fmt.Sprintf("%s-%d", baseSlug, i+1)
|
||||
}
|
||||
var existingID int
|
||||
err := database.DB.Get(&existingID, `SELECT id FROM chat_channel
|
||||
WHERE operation_id=$1 AND lower(slug)=lower($2) AND id <> $3`,
|
||||
operationID, candidate, excludeChannelID)
|
||||
if err == sql.ErrNoRows {
|
||||
return candidate, nil
|
||||
}
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
}
|
||||
return "", fmt.Errorf("failed to find an available slug for %s", baseSlug)
|
||||
}
|
||||
|
||||
func chatJSONText(input interface{}) databaseStructs.MythicJSONText {
|
||||
if input == nil {
|
||||
return rabbitmq.GetMythicJSONTextFromStruct(map[string]interface{}{})
|
||||
}
|
||||
if inputString, ok := input.(string); ok {
|
||||
var raw json.RawMessage
|
||||
if err := json.Unmarshal([]byte(inputString), &raw); err == nil {
|
||||
return rabbitmq.GetMythicJSONTextFromStruct(raw)
|
||||
}
|
||||
}
|
||||
return rabbitmq.GetMythicJSONTextFromStruct(input)
|
||||
}
|
||||
|
||||
func chatMetadataInt(metadata map[string]interface{}, key string) (int, bool) {
|
||||
value, ok := metadata[key]
|
||||
if !ok {
|
||||
return 0, false
|
||||
}
|
||||
switch typedValue := value.(type) {
|
||||
case int:
|
||||
return typedValue, true
|
||||
case int64:
|
||||
return int(typedValue), true
|
||||
case float64:
|
||||
return int(typedValue), true
|
||||
default:
|
||||
return 0, false
|
||||
}
|
||||
}
|
||||
|
||||
func getChatChannelConfig(channel databaseStructs.ChatChannel) map[string]interface{} {
|
||||
metadata := channel.AIMetadata.StructValue()
|
||||
for _, key := range []string{"config", "configuration"} {
|
||||
if config, ok := metadata[key].(map[string]interface{}); ok {
|
||||
return config
|
||||
}
|
||||
}
|
||||
return map[string]interface{}{}
|
||||
}
|
||||
|
||||
func validateAIChatChannelAPIToken(apiTokenID int, operationID int) (databaseStructs.Apitokens, error) {
|
||||
apiToken := databaseStructs.Apitokens{}
|
||||
if apiTokenID <= 0 {
|
||||
return apiToken, fmt.Errorf("AI chat channels require an apitokens_id")
|
||||
}
|
||||
err := database.DB.Get(&apiToken, `SELECT apitokens.*
|
||||
FROM apitokens
|
||||
JOIN operatoroperation ON operatoroperation.operator_id = apitokens.operator_id
|
||||
WHERE apitokens.id=$1 AND operatoroperation.operation_id=$2`,
|
||||
apiTokenID, operationID)
|
||||
if err != nil {
|
||||
return apiToken, fmt.Errorf("failed to find an active API token for this operation")
|
||||
}
|
||||
if apiToken.Deleted || !apiToken.Active {
|
||||
return apiToken, fmt.Errorf("AI chat channel API token must be active and not deleted")
|
||||
}
|
||||
if apiToken.TokenType != mythicjwt.AUTH_METHOD_API {
|
||||
return apiToken, fmt.Errorf("AI chat channel API token must be a normal API token")
|
||||
}
|
||||
if !mythicjwt.AllowsScope(apiToken.Scopes, mythicjwt.SCOPE_APITOKEN_WRITE) ||
|
||||
!mythicjwt.AllowsScope(apiToken.Scopes, mythicjwt.SCOPE_CHAT_AI_WRITE) {
|
||||
return apiToken, fmt.Errorf("AI chat channel API token must include %s and %s",
|
||||
mythicjwt.SCOPE_APITOKEN_WRITE, mythicjwt.SCOPE_CHAT_AI_WRITE)
|
||||
}
|
||||
return apiToken, nil
|
||||
}
|
||||
|
||||
func chatChannelAuthContext(channel databaseStructs.ChatChannel) (rabbitmq.RabbitMQAuthContext, error) {
|
||||
if !channel.APITokensID.Valid || channel.APITokensID.Int64 <= 0 {
|
||||
return rabbitmq.RabbitMQAuthContext{}, fmt.Errorf("AI chat channel is missing an API token")
|
||||
}
|
||||
apiToken, err := validateAIChatChannelAPIToken(int(channel.APITokensID.Int64), channel.OperationID)
|
||||
if err != nil {
|
||||
return rabbitmq.RabbitMQAuthContext{}, err
|
||||
}
|
||||
return rabbitmq.RabbitMQAuthContext{
|
||||
OperatorID: apiToken.OperatorID,
|
||||
OperationID: channel.OperationID,
|
||||
APITokensID: apiToken.ID,
|
||||
SourceScopes: mythicjwt.EffectiveScopes(apiToken.Scopes),
|
||||
}, nil
|
||||
}
|
||||
|
||||
func expireGeneratedChatAPITokensForChannel(channelID int) {
|
||||
apitokenIDs := []int{}
|
||||
if err := database.DB.Select(&apitokenIDs, `SELECT id FROM apitokens
|
||||
@@ -1167,14 +637,6 @@ func expireGeneratedChatAPITokensForChannel(channelID int) {
|
||||
}
|
||||
}
|
||||
|
||||
func getChatContainer(containerID int) (databaseStructs.ConsumingContainer, error) {
|
||||
container := databaseStructs.ConsumingContainer{}
|
||||
err := database.DB.Get(&container, `SELECT *
|
||||
FROM consuming_container
|
||||
WHERE id=$1 AND type=$2 AND deleted=false`, containerID, string(rabbitmq.CONSUMING_SERVICES_TYPE_CHAT))
|
||||
return container, err
|
||||
}
|
||||
|
||||
func getChatChannel(channelID int, operationID int) (databaseStructs.ChatChannel, error) {
|
||||
channel := databaseStructs.ChatChannel{}
|
||||
err := database.DB.Get(&channel, `SELECT *
|
||||
@@ -1182,202 +644,3 @@ func getChatChannel(channelID int, operationID int) (databaseStructs.ChatChannel
|
||||
WHERE id=$1 AND operation_id=$2`, channelID, operationID)
|
||||
return channel, err
|
||||
}
|
||||
|
||||
func getChatMessageAndChannel(messageID int, operationID int) (databaseStructs.ChatMessage, databaseStructs.ChatChannel, error) {
|
||||
chatMessage := databaseStructs.ChatMessage{}
|
||||
if err := database.DB.Get(&chatMessage, `SELECT
|
||||
id,
|
||||
operation_id,
|
||||
channel_id,
|
||||
operator_id,
|
||||
apitokens_id,
|
||||
author_type,
|
||||
chat_container_id,
|
||||
sender_display_name,
|
||||
message,
|
||||
edited,
|
||||
edited_at,
|
||||
deleted,
|
||||
deleted_by,
|
||||
deleted_at,
|
||||
status,
|
||||
metadata,
|
||||
created_at,
|
||||
updated_at
|
||||
FROM chat_message
|
||||
WHERE id=$1 AND operation_id=$2`, messageID, operationID); err != nil {
|
||||
return chatMessage, databaseStructs.ChatChannel{}, err
|
||||
}
|
||||
channel, err := getChatChannel(chatMessage.ChannelID, operationID)
|
||||
return chatMessage, channel, err
|
||||
}
|
||||
|
||||
func getChatRequestAndChannel(requestID int, operationID int) (databaseStructs.ChatRequest, databaseStructs.ChatChannel, error) {
|
||||
request := databaseStructs.ChatRequest{}
|
||||
if err := database.DB.Get(&request, `SELECT *
|
||||
FROM chat_request
|
||||
WHERE id=$1 AND operation_id=$2`, requestID, operationID); err != nil {
|
||||
return request, databaseStructs.ChatChannel{}, err
|
||||
}
|
||||
channel, err := getChatChannel(request.ChannelID, operationID)
|
||||
return request, channel, err
|
||||
}
|
||||
|
||||
func insertOperatorChatMessage(operatorOperation *databaseStructs.Operatoroperation, channel databaseStructs.ChatChannel, message string, authContext rabbitmq.RabbitMQAuthContext) (int, error) {
|
||||
var apiTokenID interface{}
|
||||
if authContext.APITokensID > 0 {
|
||||
apiTokenID = authContext.APITokensID
|
||||
}
|
||||
var messageID int
|
||||
err := database.DB.Get(&messageID, `INSERT INTO chat_message
|
||||
(operation_id, channel_id, operator_id, apitokens_id, author_type, sender_display_name, message, status)
|
||||
VALUES ($1, $2, $3, $4, 'operator', $5, $6, 'complete')
|
||||
RETURNING id`,
|
||||
operatorOperation.CurrentOperation.ID,
|
||||
channel.ID,
|
||||
operatorOperation.CurrentOperator.ID,
|
||||
apiTokenID,
|
||||
operatorOperation.CurrentOperator.Username,
|
||||
message,
|
||||
)
|
||||
return messageID, err
|
||||
}
|
||||
|
||||
func insertSystemChatMessage(operatorOperation *databaseStructs.Operatoroperation, channel databaseStructs.ChatChannel, message string, authContext rabbitmq.RabbitMQAuthContext) (int, error) {
|
||||
var apiTokenID interface{}
|
||||
if authContext.APITokensID > 0 {
|
||||
apiTokenID = authContext.APITokensID
|
||||
}
|
||||
metadata := rabbitmq.GetMythicJSONTextFromStruct(map[string]interface{}{
|
||||
"submitted_by_operator_id": operatorOperation.CurrentOperator.ID,
|
||||
"submitted_by": operatorOperation.CurrentOperator.Username,
|
||||
})
|
||||
var messageID int
|
||||
err := database.DB.Get(&messageID, `INSERT INTO chat_message
|
||||
(operation_id, channel_id, apitokens_id, author_type, sender_display_name, message, status, metadata)
|
||||
VALUES ($1, $2, $3, 'system', 'System', $4, 'complete', $5::jsonb)
|
||||
RETURNING id`,
|
||||
operatorOperation.CurrentOperation.ID,
|
||||
channel.ID,
|
||||
apiTokenID,
|
||||
message,
|
||||
metadata.String(),
|
||||
)
|
||||
return messageID, err
|
||||
}
|
||||
|
||||
func insertSystemChatMessageAllOperations(operatorOperation *databaseStructs.Operatoroperation, message string, authContext rabbitmq.RabbitMQAuthContext) (int, int, error) {
|
||||
if err := ensureGeneralChatChannels(); err != nil {
|
||||
return 0, 0, err
|
||||
}
|
||||
var apiTokenID interface{}
|
||||
if authContext.APITokensID > 0 {
|
||||
apiTokenID = authContext.APITokensID
|
||||
}
|
||||
metadata := rabbitmq.GetMythicJSONTextFromStruct(map[string]interface{}{
|
||||
"submitted_by_operator_id": operatorOperation.CurrentOperator.ID,
|
||||
"submitted_by": operatorOperation.CurrentOperator.Username,
|
||||
"all_operations": true,
|
||||
})
|
||||
messageIDs := []int{}
|
||||
err := database.DB.Select(&messageIDs, `INSERT INTO chat_message
|
||||
(operation_id, channel_id, apitokens_id, author_type, sender_display_name, message, status, metadata)
|
||||
SELECT chat_channel.operation_id, chat_channel.id, $1, 'system', 'System', $2, 'complete', $3::jsonb
|
||||
FROM chat_channel
|
||||
JOIN operation ON operation.id=chat_channel.operation_id
|
||||
WHERE operation.deleted=false
|
||||
AND chat_channel.channel_type=$4
|
||||
AND lower(chat_channel.slug)=$5
|
||||
RETURNING id`,
|
||||
apiTokenID,
|
||||
message,
|
||||
metadata.String(),
|
||||
databaseStructs.ChatChannelTypeStandard,
|
||||
chatGeneralChannelName,
|
||||
)
|
||||
if err != nil {
|
||||
return 0, 0, err
|
||||
}
|
||||
if len(messageIDs) == 0 {
|
||||
return 0, 0, fmt.Errorf("failed to find general channels for any operations")
|
||||
}
|
||||
return messageIDs[0], len(messageIDs), nil
|
||||
}
|
||||
|
||||
func ensureGeneralChatChannels() error {
|
||||
_, err := database.DB.Exec(`INSERT INTO chat_channel
|
||||
(operation_id, name, slug, description, channel_type, created_by)
|
||||
SELECT id, $1, $1, 'Default operation chat channel', $2, admin_id
|
||||
FROM operation
|
||||
WHERE deleted=false
|
||||
ON CONFLICT DO NOTHING`,
|
||||
chatGeneralChannelName,
|
||||
databaseStructs.ChatChannelTypeStandard,
|
||||
)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
_, err = database.DB.Exec(`UPDATE chat_channel
|
||||
SET name=$1,
|
||||
archived=false,
|
||||
archived_by=NULL,
|
||||
archived_at=NULL
|
||||
WHERE channel_type=$2
|
||||
AND lower(slug)=$1
|
||||
AND (name <> $1 OR archived=true OR archived_by IS NOT NULL OR archived_at IS NOT NULL)`,
|
||||
chatGeneralChannelName,
|
||||
databaseStructs.ChatChannelTypeStandard,
|
||||
)
|
||||
return err
|
||||
}
|
||||
|
||||
func getActiveAIChatRequest(operationID int, channelID int) (int, error) {
|
||||
var requestID int
|
||||
err := database.DB.Get(&requestID, `SELECT id
|
||||
FROM chat_request
|
||||
WHERE operation_id=$1
|
||||
AND channel_id=$2
|
||||
AND status IN ('pending', 'streaming')
|
||||
ORDER BY id DESC
|
||||
LIMIT 1`, operationID, channelID)
|
||||
if err == sql.ErrNoRows {
|
||||
return 0, nil
|
||||
}
|
||||
return requestID, err
|
||||
}
|
||||
|
||||
func getChatContextMessages(operationID int, channelID int, lastMessageID int) ([]rabbitmq.ChatContainerContextMessage, error) {
|
||||
contextMessages := []rabbitmq.ChatContainerContextMessage{}
|
||||
if err := database.DB.Select(&contextMessages, `SELECT
|
||||
id,
|
||||
author_type,
|
||||
sender_display_name,
|
||||
message,
|
||||
created_at
|
||||
FROM chat_message
|
||||
WHERE operation_id=$1
|
||||
AND channel_id=$2
|
||||
AND id <= $3
|
||||
AND deleted=false
|
||||
AND (author_type <> 'ai' OR status='complete')
|
||||
ORDER BY id DESC
|
||||
LIMIT $4`, operationID, channelID, lastMessageID, chatContextMessageLimit); err != nil {
|
||||
return contextMessages, err
|
||||
}
|
||||
for i, j := 0, len(contextMessages)-1; i < j; i, j = i+1, j-1 {
|
||||
contextMessages[i], contextMessages[j] = contextMessages[j], contextMessages[i]
|
||||
}
|
||||
return contextMessages, nil
|
||||
}
|
||||
|
||||
func markChatRequestFailed(requestID int, responseMessageID int, operationID int, errorMessage string) {
|
||||
metadata := rabbitmq.GetMythicJSONTextFromStruct(map[string]interface{}{
|
||||
"error": errorMessage,
|
||||
})
|
||||
_, _ = database.DB.Exec(`UPDATE chat_message
|
||||
SET status='error', metadata=metadata || $3::jsonb
|
||||
WHERE id=$1 AND operation_id=$2`, responseMessageID, operationID, metadata.String())
|
||||
_, _ = database.DB.Exec(`UPDATE chat_request
|
||||
SET status='error', error=$3
|
||||
WHERE id=$1 AND operation_id=$2`, requestID, operationID, errorMessage)
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@ package webcontroller
|
||||
import (
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"regexp"
|
||||
@@ -14,10 +15,8 @@ import (
|
||||
"github.com/its-a-feature/Mythic/authentication/mythicjwt"
|
||||
"github.com/its-a-feature/Mythic/database"
|
||||
databaseStructs "github.com/its-a-feature/Mythic/database/structs"
|
||||
"github.com/its-a-feature/Mythic/eventing"
|
||||
"github.com/its-a-feature/Mythic/logging"
|
||||
"github.com/its-a-feature/Mythic/rabbitmq"
|
||||
"github.com/lib/pq"
|
||||
)
|
||||
|
||||
const (
|
||||
@@ -50,20 +49,7 @@ type CreateChatChannel struct {
|
||||
ChatModel string `json:"chat_model"`
|
||||
Locked bool `json:"locked"`
|
||||
AIMetadata interface{} `json:"ai_metadata"`
|
||||
}
|
||||
|
||||
type UpdateChatChannelInput struct {
|
||||
Input UpdateChatChannel `json:"input" binding:"required"`
|
||||
}
|
||||
|
||||
type UpdateChatChannel struct {
|
||||
ChannelID int `json:"channel_id" binding:"required"`
|
||||
Name *string `json:"name"`
|
||||
Description *string `json:"description"`
|
||||
Archived *bool `json:"archived"`
|
||||
Locked *bool `json:"locked"`
|
||||
ChatModel *string `json:"chat_model"`
|
||||
AIMetadata interface{} `json:"ai_metadata"`
|
||||
APITokenID *int `json:"apitokens_id"`
|
||||
}
|
||||
|
||||
type CreateChatMessageInput struct {
|
||||
@@ -77,74 +63,6 @@ type CreateChatMessage struct {
|
||||
AllOperations bool `json:"all_operations"`
|
||||
}
|
||||
|
||||
type EditChatMessageInput struct {
|
||||
Input EditChatMessage `json:"input" binding:"required"`
|
||||
}
|
||||
|
||||
type EditChatMessage struct {
|
||||
MessageID int `json:"message_id" binding:"required"`
|
||||
Message string `json:"message" binding:"required"`
|
||||
}
|
||||
|
||||
type DeleteChatMessageInput struct {
|
||||
Input DeleteChatMessage `json:"input" binding:"required"`
|
||||
}
|
||||
|
||||
type DeleteChatMessage struct {
|
||||
MessageID int `json:"message_id" binding:"required"`
|
||||
}
|
||||
|
||||
type MarkChatReadInput struct {
|
||||
Input MarkChatRead `json:"input" binding:"required"`
|
||||
}
|
||||
|
||||
type MarkChatRead struct {
|
||||
ChannelID int `json:"channel_id" binding:"required"`
|
||||
LastReadMessageID *int `json:"last_read_message_id"`
|
||||
}
|
||||
|
||||
type RefreshChatSpecialMessageInput struct {
|
||||
Input RefreshChatSpecialMessage `json:"input" binding:"required"`
|
||||
}
|
||||
|
||||
type RefreshChatSpecialMessage struct {
|
||||
MessageID int `json:"message_id" binding:"required"`
|
||||
}
|
||||
|
||||
type ChatSearchInput struct {
|
||||
Input ChatSearch `json:"input" binding:"required"`
|
||||
}
|
||||
|
||||
type ChatSearch struct {
|
||||
Query string `json:"query" binding:"required"`
|
||||
ChannelID *int `json:"channel_id"`
|
||||
Limit int `json:"limit"`
|
||||
Offset int `json:"offset"`
|
||||
}
|
||||
|
||||
type ChatSearchResult struct {
|
||||
ID int `db:"id" json:"id"`
|
||||
ChannelID int `db:"channel_id" json:"channel_id"`
|
||||
ChannelName string `db:"channel_name" json:"channel_name"`
|
||||
ChannelSlug string `db:"channel_slug" json:"channel_slug"`
|
||||
ChannelType string `db:"channel_type" json:"channel_type"`
|
||||
AuthorType string `db:"author_type" json:"author_type"`
|
||||
SenderDisplayName string `db:"sender_display_name" json:"sender_display_name"`
|
||||
Message string `db:"message" json:"message"`
|
||||
Edited bool `db:"edited" json:"edited"`
|
||||
Status string `db:"status" json:"status"`
|
||||
CreatedAt time.Time `db:"created_at" json:"created_at"`
|
||||
Rank float64 `db:"rank" json:"rank"`
|
||||
}
|
||||
|
||||
type ChatRequestActionInput struct {
|
||||
Input ChatRequestAction `json:"input" binding:"required"`
|
||||
}
|
||||
|
||||
type ChatRequestAction struct {
|
||||
RequestID int `json:"request_id" binding:"required"`
|
||||
}
|
||||
|
||||
func CreateChatChannelWebhook(c *gin.Context) {
|
||||
var input CreateChatChannelInput
|
||||
if !bindChatInput(c, &input) {
|
||||
@@ -162,7 +80,7 @@ func CreateChatChannelWebhook(c *gin.Context) {
|
||||
chatRespondError(c, "unknown chat channel type")
|
||||
return
|
||||
}
|
||||
if !chatRequireScope(c, chatScopeForChannelType(channelType, true)) {
|
||||
if !chatRequireScope(c, chatScopeForChannel(databaseStructs.ChatChannel{ChannelType: channelType}, true)) {
|
||||
return
|
||||
}
|
||||
name := strings.TrimSpace(input.Input.Name)
|
||||
@@ -178,17 +96,30 @@ func CreateChatChannelWebhook(c *gin.Context) {
|
||||
}
|
||||
|
||||
var chatContainerID interface{}
|
||||
var apiTokenID interface{}
|
||||
if channelType == databaseStructs.ChatChannelTypeAI {
|
||||
if input.Input.ChatContainerID == nil || *input.Input.ChatContainerID <= 0 {
|
||||
chatRespondError(c, "AI chat channels require a chat_container_id")
|
||||
return
|
||||
}
|
||||
if input.Input.APITokenID == nil || *input.Input.APITokenID <= 0 {
|
||||
chatRespondError(c, "AI chat channels require an apitokens_id")
|
||||
return
|
||||
}
|
||||
if _, err = getChatContainer(*input.Input.ChatContainerID); err != nil {
|
||||
logging.LogError(err, "Failed to find chat container")
|
||||
chatRespondError(c, "failed to find a chat container with that id")
|
||||
return
|
||||
}
|
||||
if _, err = validateAIChatChannelAPIToken(*input.Input.APITokenID, operatorOperation.CurrentOperation.ID); err != nil {
|
||||
chatRespondError(c, err.Error())
|
||||
return
|
||||
}
|
||||
chatContainerID = *input.Input.ChatContainerID
|
||||
apiTokenID = *input.Input.APITokenID
|
||||
} else if input.Input.APITokenID != nil && *input.Input.APITokenID > 0 {
|
||||
chatRespondError(c, "apitokens_id can only be set for AI chat channels")
|
||||
return
|
||||
}
|
||||
var lockedBy interface{}
|
||||
var lockedAt interface{}
|
||||
@@ -200,8 +131,8 @@ func CreateChatChannelWebhook(c *gin.Context) {
|
||||
var channelID int
|
||||
err = database.DB.Get(&channelID, `INSERT INTO chat_channel
|
||||
(operation_id, name, slug, description, channel_type, created_by, locked, locked_by, locked_at,
|
||||
chat_container_id, chat_model, ai_metadata)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12::jsonb)
|
||||
chat_container_id, chat_model, ai_metadata, apitokens_id)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12::jsonb, $13)
|
||||
RETURNING id`,
|
||||
operatorOperation.CurrentOperation.ID,
|
||||
name,
|
||||
@@ -215,6 +146,7 @@ func CreateChatChannelWebhook(c *gin.Context) {
|
||||
chatContainerID,
|
||||
input.Input.ChatModel,
|
||||
metadata.String(),
|
||||
apiTokenID,
|
||||
)
|
||||
if err != nil {
|
||||
logging.LogError(err, "Failed to create chat channel")
|
||||
@@ -224,140 +156,6 @@ func CreateChatChannelWebhook(c *gin.Context) {
|
||||
c.JSON(http.StatusOK, ChatActionResponse{Status: "success", ID: channelID, ChannelID: channelID})
|
||||
}
|
||||
|
||||
func UpdateChatChannelWebhook(c *gin.Context) {
|
||||
var input UpdateChatChannelInput
|
||||
if !bindChatInput(c, &input) {
|
||||
return
|
||||
}
|
||||
operatorOperation, ok := chatOperatorOperation(c)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
channel, err := getChatChannel(input.Input.ChannelID, operatorOperation.CurrentOperation.ID)
|
||||
if err != nil {
|
||||
logging.LogError(err, "Failed to get chat channel")
|
||||
chatRespondError(c, "failed to find chat channel")
|
||||
return
|
||||
}
|
||||
if !chatRequireScope(c, chatScopeForChannel(channel, true)) {
|
||||
return
|
||||
}
|
||||
if !chatCanManageChannel(operatorOperation, channel) {
|
||||
chatRespondError(c, "only the channel creator or an operation admin can update this channel")
|
||||
return
|
||||
}
|
||||
|
||||
name := channel.Name
|
||||
slug := channel.Slug
|
||||
if input.Input.Name != nil {
|
||||
name = strings.TrimSpace(*input.Input.Name)
|
||||
if name == "" {
|
||||
chatRespondError(c, "channel name is required")
|
||||
return
|
||||
}
|
||||
if chatIsGeneralChannel(channel) {
|
||||
if name != channel.Name {
|
||||
chatRespondError(c, "the general channel name cannot be changed")
|
||||
return
|
||||
}
|
||||
} else {
|
||||
slug, err = uniqueChatSlug(operatorOperation.CurrentOperation.ID, slugifyChatChannelName(name), channel.ID)
|
||||
if err != nil {
|
||||
logging.LogError(err, "Failed to generate unique chat slug")
|
||||
chatRespondError(c, err.Error())
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
description := channel.Description
|
||||
if input.Input.Description != nil {
|
||||
description = *input.Input.Description
|
||||
}
|
||||
archived := channel.Archived
|
||||
var archivedBy interface{}
|
||||
var archivedAt interface{}
|
||||
if channel.ArchivedBy.Valid {
|
||||
archivedBy = channel.ArchivedBy.Int64
|
||||
}
|
||||
if channel.ArchivedAt.Valid {
|
||||
archivedAt = channel.ArchivedAt.Time
|
||||
}
|
||||
if input.Input.Archived != nil {
|
||||
archived = *input.Input.Archived
|
||||
if chatIsGeneralChannel(channel) && archived {
|
||||
chatRespondError(c, "the general channel cannot be archived")
|
||||
return
|
||||
}
|
||||
if archived {
|
||||
archivedBy = operatorOperation.CurrentOperator.ID
|
||||
archivedAt = time.Now().UTC()
|
||||
} else {
|
||||
archivedBy = nil
|
||||
archivedAt = nil
|
||||
}
|
||||
}
|
||||
locked := channel.Locked
|
||||
var lockedBy interface{}
|
||||
var lockedAt interface{}
|
||||
if channel.LockedBy.Valid {
|
||||
lockedBy = channel.LockedBy.Int64
|
||||
}
|
||||
if channel.LockedAt.Valid {
|
||||
lockedAt = channel.LockedAt.Time
|
||||
}
|
||||
if input.Input.Locked != nil {
|
||||
if channel.ChannelType != databaseStructs.ChatChannelTypeAI {
|
||||
chatRespondError(c, "only AI chat channels can be locked")
|
||||
return
|
||||
}
|
||||
locked = *input.Input.Locked
|
||||
if locked {
|
||||
lockedBy = operatorOperation.CurrentOperator.ID
|
||||
lockedAt = time.Now().UTC()
|
||||
} else {
|
||||
if !chatIsModerator(operatorOperation) &&
|
||||
(!channel.LockedBy.Valid || int(channel.LockedBy.Int64) != operatorOperation.CurrentOperator.ID) {
|
||||
chatRespondError(c, "only the lock owner or an operation admin can unlock this AI chat")
|
||||
return
|
||||
}
|
||||
lockedBy = nil
|
||||
lockedAt = nil
|
||||
}
|
||||
}
|
||||
chatModel := channel.ChatModel
|
||||
if input.Input.ChatModel != nil {
|
||||
chatModel = *input.Input.ChatModel
|
||||
}
|
||||
metadata := channel.AIMetadata
|
||||
if input.Input.AIMetadata != nil {
|
||||
metadata = chatJSONText(input.Input.AIMetadata)
|
||||
}
|
||||
_, err = database.DB.Exec(`UPDATE chat_channel
|
||||
SET name=$3, slug=$4, description=$5, archived=$6, archived_by=$7, archived_at=$8,
|
||||
locked=$9, locked_by=$10, locked_at=$11, chat_model=$12, ai_metadata=$13::jsonb
|
||||
WHERE id=$1 AND operation_id=$2`,
|
||||
channel.ID,
|
||||
operatorOperation.CurrentOperation.ID,
|
||||
name,
|
||||
slug,
|
||||
description,
|
||||
archived,
|
||||
archivedBy,
|
||||
archivedAt,
|
||||
locked,
|
||||
lockedBy,
|
||||
lockedAt,
|
||||
chatModel,
|
||||
metadata.String(),
|
||||
)
|
||||
if err != nil {
|
||||
logging.LogError(err, "Failed to update chat channel")
|
||||
chatRespondError(c, err.Error())
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, ChatActionResponse{Status: "success", ID: channel.ID, ChannelID: channel.ID})
|
||||
}
|
||||
|
||||
func CreateChatMessageWebhook(c *gin.Context) {
|
||||
var input CreateChatMessageInput
|
||||
if !bindChatInput(c, &input) {
|
||||
@@ -404,7 +202,7 @@ func CreateChatMessageWebhook(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
if input.Input.SystemMessage {
|
||||
if !chatCanCreateSystemMessage(operatorOperation) {
|
||||
if !chatIsModerator(operatorOperation) {
|
||||
chatRespondError(c, "only an admin can create system messages")
|
||||
return
|
||||
}
|
||||
@@ -434,339 +232,6 @@ func CreateChatMessageWebhook(c *gin.Context) {
|
||||
c.JSON(http.StatusOK, ChatActionResponse{Status: "success", ID: messageID, MessageID: messageID, ChannelID: channel.ID})
|
||||
}
|
||||
|
||||
func EditChatMessageWebhook(c *gin.Context) {
|
||||
var input EditChatMessageInput
|
||||
if !bindChatInput(c, &input) {
|
||||
return
|
||||
}
|
||||
operatorOperation, ok := chatOperatorOperation(c)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
message := strings.TrimSpace(input.Input.Message)
|
||||
if message == "" {
|
||||
chatRespondError(c, "message is required")
|
||||
return
|
||||
}
|
||||
chatMessage, channel, err := getChatMessageAndChannel(input.Input.MessageID, operatorOperation.CurrentOperation.ID)
|
||||
if err != nil {
|
||||
logging.LogError(err, "Failed to get chat message for edit")
|
||||
chatRespondError(c, "failed to find chat message")
|
||||
return
|
||||
}
|
||||
if !chatRequireScope(c, chatScopeForChannel(channel, true)) {
|
||||
return
|
||||
}
|
||||
if chatMessage.Deleted {
|
||||
chatRespondError(c, "cannot edit a deleted chat message")
|
||||
return
|
||||
}
|
||||
if chatMessage.AuthorType != databaseStructs.ChatMessageAuthorOperator ||
|
||||
!chatMessage.OperatorID.Valid ||
|
||||
int(chatMessage.OperatorID.Int64) != operatorOperation.CurrentOperator.ID {
|
||||
chatRespondError(c, "only the message author can edit this message")
|
||||
return
|
||||
}
|
||||
_, err = database.DB.Exec(`UPDATE chat_message
|
||||
SET message=$3, edited=true, edited_at=now()
|
||||
WHERE id=$1 AND operation_id=$2`, chatMessage.ID, operatorOperation.CurrentOperation.ID, message)
|
||||
if err != nil {
|
||||
logging.LogError(err, "Failed to edit chat message")
|
||||
chatRespondError(c, err.Error())
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, ChatActionResponse{Status: "success", ID: chatMessage.ID, MessageID: chatMessage.ID, ChannelID: channel.ID})
|
||||
}
|
||||
|
||||
func DeleteChatMessageWebhook(c *gin.Context) {
|
||||
var input DeleteChatMessageInput
|
||||
if !bindChatInput(c, &input) {
|
||||
return
|
||||
}
|
||||
operatorOperation, ok := chatOperatorOperation(c)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
chatMessage, channel, err := getChatMessageAndChannel(input.Input.MessageID, operatorOperation.CurrentOperation.ID)
|
||||
if err != nil {
|
||||
logging.LogError(err, "Failed to get chat message for delete")
|
||||
chatRespondError(c, "failed to find chat message")
|
||||
return
|
||||
}
|
||||
if !chatRequireScope(c, chatScopeForChannel(channel, true)) {
|
||||
return
|
||||
}
|
||||
if !chatIsModerator(operatorOperation) &&
|
||||
(!chatMessage.OperatorID.Valid || int(chatMessage.OperatorID.Int64) != operatorOperation.CurrentOperator.ID) {
|
||||
chatRespondError(c, "only the message author or an operation admin can delete this message")
|
||||
return
|
||||
}
|
||||
_, err = database.DB.Exec(`UPDATE chat_message
|
||||
SET message='Message Deleted',
|
||||
deleted=true,
|
||||
deleted_by=$3,
|
||||
deleted_at=now(),
|
||||
status='complete'
|
||||
WHERE id=$1 AND operation_id=$2`, chatMessage.ID, operatorOperation.CurrentOperation.ID, operatorOperation.CurrentOperator.ID)
|
||||
if err != nil {
|
||||
logging.LogError(err, "Failed to delete chat message")
|
||||
chatRespondError(c, err.Error())
|
||||
return
|
||||
}
|
||||
_, _ = database.DB.Exec(`UPDATE chat_request
|
||||
SET status='cancelled', cancelled_at=now(), error='Chat message was deleted'
|
||||
WHERE operation_id=$1 AND (request_message_id=$2 OR response_message_id=$2) AND status IN ('pending', 'streaming')`,
|
||||
operatorOperation.CurrentOperation.ID, chatMessage.ID)
|
||||
c.JSON(http.StatusOK, ChatActionResponse{Status: "success", ID: chatMessage.ID, MessageID: chatMessage.ID, ChannelID: channel.ID})
|
||||
}
|
||||
|
||||
func MarkChatReadWebhook(c *gin.Context) {
|
||||
var input MarkChatReadInput
|
||||
if !bindChatInput(c, &input) {
|
||||
return
|
||||
}
|
||||
operatorOperation, ok := chatOperatorOperation(c)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
channel, err := getChatChannel(input.Input.ChannelID, operatorOperation.CurrentOperation.ID)
|
||||
if err != nil {
|
||||
logging.LogError(err, "Failed to get chat channel for read marker")
|
||||
chatRespondError(c, "failed to find chat channel")
|
||||
return
|
||||
}
|
||||
if !chatRequireScope(c, chatScopeForChannel(channel, false)) {
|
||||
return
|
||||
}
|
||||
var lastReadID interface{}
|
||||
if input.Input.LastReadMessageID != nil {
|
||||
lastReadID = *input.Input.LastReadMessageID
|
||||
} else {
|
||||
var maxID sql.NullInt64
|
||||
if err = database.DB.Get(&maxID, `SELECT max(id) FROM chat_message
|
||||
WHERE operation_id=$1 AND channel_id=$2`, operatorOperation.CurrentOperation.ID, channel.ID); err != nil {
|
||||
logging.LogError(err, "Failed to get max chat message id")
|
||||
chatRespondError(c, err.Error())
|
||||
return
|
||||
}
|
||||
if maxID.Valid {
|
||||
lastReadID = maxID.Int64
|
||||
}
|
||||
}
|
||||
_, err = database.DB.Exec(`INSERT INTO chat_read_state
|
||||
(operation_id, channel_id, operator_id, last_read_message_id)
|
||||
VALUES ($1, $2, $3, $4)
|
||||
ON CONFLICT (operator_id, channel_id) DO UPDATE
|
||||
SET last_read_message_id=excluded.last_read_message_id`,
|
||||
operatorOperation.CurrentOperation.ID, channel.ID, operatorOperation.CurrentOperator.ID, lastReadID)
|
||||
if err != nil {
|
||||
logging.LogError(err, "Failed to mark chat channel read")
|
||||
chatRespondError(c, err.Error())
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, ChatActionResponse{Status: "success", ChannelID: channel.ID})
|
||||
}
|
||||
|
||||
func RefreshChatSpecialMessageWebhook(c *gin.Context) {
|
||||
var input RefreshChatSpecialMessageInput
|
||||
if !bindChatInput(c, &input) {
|
||||
return
|
||||
}
|
||||
operatorOperation, ok := chatOperatorOperation(c)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
chatMessage, channel, err := getChatMessageAndChannel(input.Input.MessageID, operatorOperation.CurrentOperation.ID)
|
||||
if err != nil {
|
||||
logging.LogError(err, "Failed to get chat message for refresh")
|
||||
chatRespondError(c, "failed to find chat message")
|
||||
return
|
||||
}
|
||||
if !chatRequireScope(c, chatScopeForChannel(channel, false)) {
|
||||
return
|
||||
}
|
||||
if chatMessage.Deleted {
|
||||
chatRespondError(c, "cannot refresh a deleted chat message")
|
||||
return
|
||||
}
|
||||
metadata := chatMessage.Metadata.StructValue()
|
||||
if metadata["special_type"] != eventing.ChatSpecialTypeEventingUserInteraction {
|
||||
chatRespondError(c, "chat message is not refreshable")
|
||||
return
|
||||
}
|
||||
eventStepInstanceID, ok := chatMetadataInt(metadata, "eventstepinstance_id")
|
||||
if !ok || eventStepInstanceID <= 0 {
|
||||
chatRespondError(c, "chat message is missing its eventstep instance reference")
|
||||
return
|
||||
}
|
||||
message, senderDisplayName, refreshedMetadata, err := eventing.BuildUserInteractionChatMessage(eventStepInstanceID, operatorOperation.CurrentOperation.ID)
|
||||
if err != nil {
|
||||
logging.LogError(err, "Failed to refresh eventing user interaction chat message")
|
||||
chatRespondError(c, err.Error())
|
||||
return
|
||||
}
|
||||
refreshedMetadataText := eventing.GetMythicJSONTextFromStruct(refreshedMetadata)
|
||||
_, err = database.DB.Exec(`UPDATE chat_message
|
||||
SET sender_display_name=$3, message=$4, metadata=$5::jsonb, status=$6, updated_at=now()
|
||||
WHERE id=$1 AND operation_id=$2`,
|
||||
chatMessage.ID,
|
||||
operatorOperation.CurrentOperation.ID,
|
||||
senderDisplayName,
|
||||
message,
|
||||
refreshedMetadataText.String(),
|
||||
databaseStructs.ChatMessageStatusComplete)
|
||||
if err != nil {
|
||||
logging.LogError(err, "Failed to update refreshed chat message")
|
||||
chatRespondError(c, err.Error())
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, ChatActionResponse{Status: "success", ID: chatMessage.ID, MessageID: chatMessage.ID, ChannelID: channel.ID})
|
||||
}
|
||||
|
||||
func ChatSearchWebhook(c *gin.Context) {
|
||||
var input ChatSearchInput
|
||||
if !bindChatInput(c, &input) {
|
||||
return
|
||||
}
|
||||
operatorOperation, ok := chatOperatorOperation(c)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
query := strings.TrimSpace(input.Input.Query)
|
||||
if query == "" {
|
||||
chatRespondError(c, "query is required")
|
||||
return
|
||||
}
|
||||
allowedTypes := chatSearchAllowedChannelTypes(c)
|
||||
if len(allowedTypes) == 0 {
|
||||
chatRespondError(c, "missing required chat read scope")
|
||||
return
|
||||
}
|
||||
limit := input.Input.Limit
|
||||
if limit <= 0 || limit > 100 {
|
||||
limit = 50
|
||||
}
|
||||
offset := input.Input.Offset
|
||||
if offset < 0 {
|
||||
offset = 0
|
||||
}
|
||||
var channelID interface{}
|
||||
if input.Input.ChannelID != nil {
|
||||
channelID = *input.Input.ChannelID
|
||||
}
|
||||
results := []ChatSearchResult{}
|
||||
err := database.DB.Select(&results, `SELECT
|
||||
chat_message.id,
|
||||
chat_message.channel_id,
|
||||
chat_channel.name "channel_name",
|
||||
chat_channel.slug "channel_slug",
|
||||
chat_channel.channel_type,
|
||||
chat_message.author_type,
|
||||
chat_message.sender_display_name,
|
||||
chat_message.message,
|
||||
chat_message.edited,
|
||||
chat_message.status,
|
||||
chat_message.created_at,
|
||||
ts_rank(chat_message.search_vector, plainto_tsquery('simple', $3)) "rank"
|
||||
FROM chat_message
|
||||
JOIN chat_channel ON chat_message.channel_id = chat_channel.id
|
||||
WHERE chat_message.operation_id=$1
|
||||
AND chat_message.deleted=false
|
||||
AND chat_channel.channel_type = ANY($2)
|
||||
AND chat_message.search_vector @@ plainto_tsquery('simple', $3)
|
||||
AND ($4::integer IS NULL OR chat_message.channel_id=$4)
|
||||
ORDER BY rank DESC, chat_message.id DESC
|
||||
LIMIT $5 OFFSET $6`,
|
||||
operatorOperation.CurrentOperation.ID,
|
||||
pq.Array(allowedTypes),
|
||||
query,
|
||||
channelID,
|
||||
limit,
|
||||
offset,
|
||||
)
|
||||
if err != nil {
|
||||
logging.LogError(err, "Failed to search chat")
|
||||
chatRespondError(c, err.Error())
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, ChatActionResponse{Status: "success", Results: results})
|
||||
}
|
||||
|
||||
func CancelChatRequestWebhook(c *gin.Context) {
|
||||
var input ChatRequestActionInput
|
||||
if !bindChatInput(c, &input) {
|
||||
return
|
||||
}
|
||||
operatorOperation, ok := chatOperatorOperation(c)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
request, channel, err := getChatRequestAndChannel(input.Input.RequestID, operatorOperation.CurrentOperation.ID)
|
||||
if err != nil {
|
||||
logging.LogError(err, "Failed to find chat request for cancel")
|
||||
chatRespondError(c, "failed to find chat request")
|
||||
return
|
||||
}
|
||||
if !chatRequireScope(c, chatScopeForChannel(channel, true)) {
|
||||
return
|
||||
}
|
||||
if !chatIsModerator(operatorOperation) && request.CreatedBy != operatorOperation.CurrentOperator.ID {
|
||||
chatRespondError(c, "only the request creator or an operation admin can cancel this request")
|
||||
return
|
||||
}
|
||||
_, err = database.DB.Exec(`UPDATE chat_request
|
||||
SET status='cancelled', cancelled_at=now(), error='Cancelled by operator'
|
||||
WHERE id=$1 AND operation_id=$2 AND status IN ('pending', 'streaming')`,
|
||||
request.ID, operatorOperation.CurrentOperation.ID)
|
||||
if err != nil {
|
||||
logging.LogError(err, "Failed to cancel chat request")
|
||||
chatRespondError(c, err.Error())
|
||||
return
|
||||
}
|
||||
_, _ = database.DB.Exec(`UPDATE chat_message
|
||||
SET status='cancelled'
|
||||
WHERE id=$1 AND operation_id=$2 AND status IN ('pending', 'streaming')`,
|
||||
request.ResponseMessageID, operatorOperation.CurrentOperation.ID)
|
||||
c.JSON(http.StatusOK, ChatActionResponse{Status: "success", RequestID: request.ID, ResponseMessageID: request.ResponseMessageID, ChannelID: channel.ID})
|
||||
}
|
||||
|
||||
func RetryChatRequestWebhook(c *gin.Context) {
|
||||
var input ChatRequestActionInput
|
||||
if !bindChatInput(c, &input) {
|
||||
return
|
||||
}
|
||||
operatorOperation, ok := chatOperatorOperation(c)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
request, channel, err := getChatRequestAndChannel(input.Input.RequestID, operatorOperation.CurrentOperation.ID)
|
||||
if err != nil {
|
||||
logging.LogError(err, "Failed to find chat request for retry")
|
||||
chatRespondError(c, "failed to find chat request")
|
||||
return
|
||||
}
|
||||
if !chatRequireScope(c, chatScopeForChannel(channel, true)) {
|
||||
return
|
||||
}
|
||||
if channel.Archived {
|
||||
chatRespondError(c, "cannot retry in an archived chat channel")
|
||||
return
|
||||
}
|
||||
if !chatCanPostToAIChannel(operatorOperation, channel) {
|
||||
chatRespondError(c, "this AI chat is locked to another operator")
|
||||
return
|
||||
}
|
||||
var prompt string
|
||||
if err = database.DB.Get(&prompt, `SELECT message FROM chat_message
|
||||
WHERE id=$1 AND operation_id=$2 AND deleted=false`,
|
||||
request.RequestMessageID, operatorOperation.CurrentOperation.ID); err != nil {
|
||||
logging.LogError(err, "Failed to get original prompt for retry")
|
||||
chatRespondError(c, "failed to find original prompt")
|
||||
return
|
||||
}
|
||||
createAIChatMessage(c, operatorOperation, channel, prompt, &request.ID)
|
||||
}
|
||||
|
||||
func createAIChatMessage(c *gin.Context, operatorOperation *databaseStructs.Operatoroperation, channel databaseStructs.ChatChannel, message string, retryOfID *int) {
|
||||
container, err := getChatContainer(int(channel.ChatContainerID.Int64))
|
||||
if err != nil {
|
||||
@@ -788,8 +253,13 @@ func createAIChatMessage(c *gin.Context, operatorOperation *databaseStructs.Oper
|
||||
chatRespondError(c, fmt.Sprintf("AI chat request %d is still in progress; wait for it to finish or cancel it before sending another prompt", activeRequestID))
|
||||
return
|
||||
}
|
||||
authContext := authentication.RabbitMQAuthContextFromGin(c)
|
||||
requestMessageID, err := insertOperatorChatMessage(operatorOperation, channel, message, authContext)
|
||||
chatAuthContext, err := chatChannelAuthContext(channel)
|
||||
if err != nil {
|
||||
chatRespondError(c, err.Error())
|
||||
return
|
||||
}
|
||||
operatorAuthContext := authentication.RabbitMQAuthContextFromGin(c)
|
||||
requestMessageID, err := insertOperatorChatMessage(operatorOperation, channel, message, operatorAuthContext)
|
||||
if err != nil {
|
||||
logging.LogError(err, "Failed to create AI prompt message")
|
||||
chatRespondError(c, err.Error())
|
||||
@@ -834,11 +304,46 @@ func createAIChatMessage(c *gin.Context, operatorOperation *databaseStructs.Oper
|
||||
chatRespondError(c, err.Error())
|
||||
return
|
||||
}
|
||||
contextMessages, err := getChatContextMessages(operatorOperation.CurrentOperation.ID, channel.ID, requestMessageID)
|
||||
|
||||
go dispatchAIChatRequest(
|
||||
operatorOperation.CurrentOperation.ID,
|
||||
operatorOperation.CurrentOperator.ID,
|
||||
channel,
|
||||
container,
|
||||
requestID,
|
||||
requestMessageID,
|
||||
responseMessageID,
|
||||
message,
|
||||
retryOf,
|
||||
chatAuthContext,
|
||||
)
|
||||
|
||||
c.JSON(http.StatusOK, ChatActionResponse{
|
||||
Status: "success",
|
||||
ID: requestMessageID,
|
||||
MessageID: requestMessageID,
|
||||
ChannelID: channel.ID,
|
||||
RequestID: requestID,
|
||||
ResponseMessageID: responseMessageID,
|
||||
})
|
||||
}
|
||||
|
||||
func dispatchAIChatRequest(
|
||||
operationID int,
|
||||
operatorID int,
|
||||
channel databaseStructs.ChatChannel,
|
||||
container databaseStructs.ConsumingContainer,
|
||||
requestID int,
|
||||
requestMessageID int,
|
||||
responseMessageID int,
|
||||
prompt string,
|
||||
retryOf interface{},
|
||||
chatAuthContext rabbitmq.RabbitMQAuthContext,
|
||||
) {
|
||||
contextMessages, err := getChatContextMessages(operationID, channel.ID, requestMessageID)
|
||||
if err != nil {
|
||||
logging.LogError(err, "Failed to fetch AI chat context")
|
||||
markChatRequestFailed(requestID, responseMessageID, operatorOperation.CurrentOperation.ID, err.Error())
|
||||
chatRespondError(c, err.Error())
|
||||
markChatRequestFailed(requestID, responseMessageID, operationID, err.Error())
|
||||
return
|
||||
}
|
||||
chatConfig := getChatChannelConfig(channel)
|
||||
@@ -856,35 +361,27 @@ func createAIChatMessage(c *gin.Context, operatorOperation *databaseStructs.Oper
|
||||
})
|
||||
_, _ = database.DB.Exec(`UPDATE chat_request
|
||||
SET context_snapshot=$3::jsonb
|
||||
WHERE id=$1 AND operation_id=$2`, requestID, operatorOperation.CurrentOperation.ID, contextSnapshot.String())
|
||||
WHERE id=$1 AND operation_id=$2`, requestID, operationID, contextSnapshot.String())
|
||||
|
||||
err = rabbitmq.RabbitMQConnection.SendChatContainerRequest(container.Name, rabbitmq.ChatContainerRequestMessage{
|
||||
OperationID: operatorOperation.CurrentOperation.ID,
|
||||
OperationID: operationID,
|
||||
ChannelID: channel.ID,
|
||||
APITokenID: int(channel.APITokensID.Int64),
|
||||
ChannelName: channel.Name,
|
||||
ChannelSlug: channel.Slug,
|
||||
RequestID: requestID,
|
||||
RequestMessageID: requestMessageID,
|
||||
ResponseMessageID: responseMessageID,
|
||||
Model: channel.ChatModel,
|
||||
Prompt: message,
|
||||
Prompt: prompt,
|
||||
Config: chatConfig,
|
||||
Context: contextMessages,
|
||||
Secrets: rabbitmq.GetSecrets(operatorOperation.CurrentOperator.ID, 0),
|
||||
}, authContext)
|
||||
Secrets: rabbitmq.GetSecrets(operatorID, 0),
|
||||
}, chatAuthContext)
|
||||
if err != nil {
|
||||
markChatRequestFailed(requestID, responseMessageID, operatorOperation.CurrentOperation.ID, err.Error())
|
||||
chatRespondError(c, err.Error())
|
||||
return
|
||||
logging.LogError(err, "Failed to send AI chat request", "request_id", requestID, "response_message_id", responseMessageID)
|
||||
markChatRequestFailed(requestID, responseMessageID, operationID, err.Error())
|
||||
}
|
||||
c.JSON(http.StatusOK, ChatActionResponse{
|
||||
Status: "success",
|
||||
ID: requestMessageID,
|
||||
MessageID: requestMessageID,
|
||||
ChannelID: channel.ID,
|
||||
RequestID: requestID,
|
||||
ResponseMessageID: responseMessageID,
|
||||
})
|
||||
}
|
||||
|
||||
func bindChatInput(c *gin.Context, input interface{}) bool {
|
||||
@@ -924,24 +421,8 @@ func chatRequireScope(c *gin.Context, requiredScope string) bool {
|
||||
return true
|
||||
}
|
||||
|
||||
func chatSearchAllowedChannelTypes(c *gin.Context) []string {
|
||||
claims, err := authentication.GetClaims(c)
|
||||
if err != nil {
|
||||
logging.LogError(err, "Failed to get claims for chat search scope check")
|
||||
return []string{}
|
||||
}
|
||||
channelTypes := []string{}
|
||||
if mythicjwt.AllowsScope(claims.Scopes, mythicjwt.SCOPE_CHAT_READ) {
|
||||
channelTypes = append(channelTypes, databaseStructs.ChatChannelTypeStandard)
|
||||
}
|
||||
if mythicjwt.AllowsScope(claims.Scopes, mythicjwt.SCOPE_CHAT_AI_READ) {
|
||||
channelTypes = append(channelTypes, databaseStructs.ChatChannelTypeAI)
|
||||
}
|
||||
return channelTypes
|
||||
}
|
||||
|
||||
func chatScopeForChannelType(channelType string, write bool) string {
|
||||
if channelType == databaseStructs.ChatChannelTypeAI {
|
||||
func chatScopeForChannel(channel databaseStructs.ChatChannel, write bool) string {
|
||||
if channel.ChannelType == databaseStructs.ChatChannelTypeAI {
|
||||
if write {
|
||||
return mythicjwt.SCOPE_CHAT_AI_WRITE
|
||||
}
|
||||
@@ -953,37 +434,21 @@ func chatScopeForChannelType(channelType string, write bool) string {
|
||||
return mythicjwt.SCOPE_CHAT_READ
|
||||
}
|
||||
|
||||
func chatScopeForChannel(channel databaseStructs.ChatChannel, write bool) string {
|
||||
return chatScopeForChannelType(channel.ChannelType, write)
|
||||
}
|
||||
|
||||
func chatIsModerator(operatorOperation *databaseStructs.Operatoroperation) bool {
|
||||
return operatorOperation.CurrentOperator.Admin ||
|
||||
operatorOperation.ViewMode == database.OPERATOR_OPERATION_VIEW_MODE_LEAD
|
||||
}
|
||||
|
||||
func chatCanCreateSystemMessage(operatorOperation *databaseStructs.Operatoroperation) bool {
|
||||
return chatIsModerator(operatorOperation)
|
||||
}
|
||||
|
||||
func chatIsGeneralChannel(channel databaseStructs.ChatChannel) bool {
|
||||
return channel.ChannelType == databaseStructs.ChatChannelTypeStandard &&
|
||||
strings.EqualFold(channel.Slug, chatGeneralChannelName)
|
||||
}
|
||||
|
||||
func chatCanManageChannel(operatorOperation *databaseStructs.Operatoroperation, channel databaseStructs.ChatChannel) bool {
|
||||
return chatIsModerator(operatorOperation) || channel.CreatedBy == operatorOperation.CurrentOperator.ID
|
||||
}
|
||||
|
||||
func chatCanPostToAIChannel(operatorOperation *databaseStructs.Operatoroperation, channel databaseStructs.ChatChannel) bool {
|
||||
if channel.ChannelType != databaseStructs.ChatChannelTypeAI {
|
||||
return true
|
||||
}
|
||||
if !channel.Locked {
|
||||
return true
|
||||
}
|
||||
return chatIsModerator(operatorOperation) ||
|
||||
(channel.LockedBy.Valid && int(channel.LockedBy.Int64) == operatorOperation.CurrentOperator.ID)
|
||||
return channel.LockedBy.Valid && int(channel.LockedBy.Int64) == operatorOperation.CurrentOperator.ID
|
||||
}
|
||||
|
||||
func slugifyChatChannelName(name string) string {
|
||||
@@ -1006,7 +471,7 @@ func uniqueChatSlug(operationID int, baseSlug string, excludeChannelID int) (str
|
||||
err := database.DB.Get(&existingID, `SELECT id FROM chat_channel
|
||||
WHERE operation_id=$1 AND lower(slug)=lower($2) AND id <> $3`,
|
||||
operationID, candidate, excludeChannelID)
|
||||
if err == sql.ErrNoRows {
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return candidate, nil
|
||||
}
|
||||
if err != nil {
|
||||
@@ -1056,6 +521,49 @@ func getChatChannelConfig(channel databaseStructs.ChatChannel) map[string]interf
|
||||
return map[string]interface{}{}
|
||||
}
|
||||
|
||||
func validateAIChatChannelAPIToken(apiTokenID int, operationID int) (databaseStructs.Apitokens, error) {
|
||||
apiToken := databaseStructs.Apitokens{}
|
||||
if apiTokenID <= 0 {
|
||||
return apiToken, fmt.Errorf("AI chat channels require an apitokens_id")
|
||||
}
|
||||
err := database.DB.Get(&apiToken, `SELECT apitokens.*
|
||||
FROM apitokens
|
||||
JOIN operatoroperation ON operatoroperation.operator_id = apitokens.operator_id
|
||||
WHERE apitokens.id=$1 AND operatoroperation.operation_id=$2`,
|
||||
apiTokenID, operationID)
|
||||
if err != nil {
|
||||
return apiToken, fmt.Errorf("failed to find an active API token for this operation")
|
||||
}
|
||||
if apiToken.Deleted || !apiToken.Active {
|
||||
return apiToken, fmt.Errorf("AI chat channel API token must be active and not deleted")
|
||||
}
|
||||
if apiToken.TokenType != mythicjwt.AUTH_METHOD_API {
|
||||
return apiToken, fmt.Errorf("AI chat channel API token must be a normal API token")
|
||||
}
|
||||
if !mythicjwt.AllowsScope(apiToken.Scopes, mythicjwt.SCOPE_APITOKEN_WRITE) ||
|
||||
!mythicjwt.AllowsScope(apiToken.Scopes, mythicjwt.SCOPE_CHAT_AI_WRITE) {
|
||||
return apiToken, fmt.Errorf("AI chat channel API token must include %s and %s",
|
||||
mythicjwt.SCOPE_APITOKEN_WRITE, mythicjwt.SCOPE_CHAT_AI_WRITE)
|
||||
}
|
||||
return apiToken, nil
|
||||
}
|
||||
|
||||
func chatChannelAuthContext(channel databaseStructs.ChatChannel) (rabbitmq.RabbitMQAuthContext, error) {
|
||||
if !channel.APITokensID.Valid || channel.APITokensID.Int64 <= 0 {
|
||||
return rabbitmq.RabbitMQAuthContext{}, fmt.Errorf("AI chat channel is missing an API token")
|
||||
}
|
||||
apiToken, err := validateAIChatChannelAPIToken(int(channel.APITokensID.Int64), channel.OperationID)
|
||||
if err != nil {
|
||||
return rabbitmq.RabbitMQAuthContext{}, err
|
||||
}
|
||||
return rabbitmq.RabbitMQAuthContext{
|
||||
OperatorID: apiToken.OperatorID,
|
||||
OperationID: channel.OperationID,
|
||||
APITokensID: apiToken.ID,
|
||||
SourceScopes: mythicjwt.EffectiveScopes(apiToken.Scopes),
|
||||
}, nil
|
||||
}
|
||||
|
||||
func getChatContainer(containerID int) (databaseStructs.ConsumingContainer, error) {
|
||||
container := databaseStructs.ConsumingContainer{}
|
||||
err := database.DB.Get(&container, `SELECT *
|
||||
@@ -1064,14 +572,6 @@ func getChatContainer(containerID int) (databaseStructs.ConsumingContainer, erro
|
||||
return container, err
|
||||
}
|
||||
|
||||
func getChatChannel(channelID int, operationID int) (databaseStructs.ChatChannel, error) {
|
||||
channel := databaseStructs.ChatChannel{}
|
||||
err := database.DB.Get(&channel, `SELECT *
|
||||
FROM chat_channel
|
||||
WHERE id=$1 AND operation_id=$2`, channelID, operationID)
|
||||
return channel, err
|
||||
}
|
||||
|
||||
func getChatMessageAndChannel(messageID int, operationID int) (databaseStructs.ChatMessage, databaseStructs.ChatChannel, error) {
|
||||
chatMessage := databaseStructs.ChatMessage{}
|
||||
if err := database.DB.Get(&chatMessage, `SELECT
|
||||
|
||||
@@ -9,6 +9,7 @@ import (
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/its-a-feature/Mythic/authentication"
|
||||
"github.com/its-a-feature/Mythic/database"
|
||||
databaseStructs "github.com/its-a-feature/Mythic/database/structs"
|
||||
"github.com/its-a-feature/Mythic/logging"
|
||||
)
|
||||
|
||||
@@ -56,35 +57,60 @@ func Login(c *gin.Context) {
|
||||
|
||||
}
|
||||
|
||||
func GetMeWebhook(c *gin.Context) {
|
||||
func GetWhoamiWebhook(c *gin.Context) {
|
||||
user, err := GetUserIDFromGin(c)
|
||||
if err != nil {
|
||||
logging.LogError(err, "Failed to get UserID")
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"status": "error", "error": "Failed to get UserID"})
|
||||
return
|
||||
}
|
||||
operator, err := database.GetUserFromID(user)
|
||||
if err != nil {
|
||||
logging.LogError(err, "Failed to get operator")
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"status": "error", "error": "Failed to get operator"})
|
||||
return
|
||||
}
|
||||
currentOperation, err := database.GetUserCurrentOperation(user)
|
||||
if err != nil {
|
||||
logging.LogError(err, "Failed to get current operation")
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"status": "error", "error": "Failed to get current operation"})
|
||||
return
|
||||
}
|
||||
//logging.LogInfo("got mewebhook info", "currentOperation", currentOperation)
|
||||
claims := currentClaimsOrNil(c)
|
||||
apiTokenInfo := buildWhoamiAPITokenInfo(c, claims)
|
||||
operations := buildWhoamiOperations(user)
|
||||
scopes := []string{}
|
||||
effectiveScopes := []string{}
|
||||
authMethod := ""
|
||||
eventStepInstanceID := 0
|
||||
if claims != nil {
|
||||
authMethod = claims.AuthMethod
|
||||
scopes = claims.Scopes
|
||||
effectiveScopes = mythicjwt.EffectiveScopes(claims.Scopes)
|
||||
eventStepInstanceID = claims.EventStepInstanceID
|
||||
}
|
||||
//logging.LogInfo("got whoami info", "currentOperation", currentOperation)
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"status": "success",
|
||||
"current_operation": currentOperation.CurrentOperation.Name,
|
||||
"current_operation_banner_text": currentOperation.CurrentOperation.BannerText,
|
||||
"current_operation_banner_color": currentOperation.CurrentOperation.BannerColor,
|
||||
"current_operation_complete": currentOperation.CurrentOperation.Complete,
|
||||
"current_operation_id": currentOperation.CurrentOperation.ID,
|
||||
"username": currentOperation.CurrentOperator.Username,
|
||||
"user_id": currentOperation.CurrentOperator.ID,
|
||||
"id": currentOperation.CurrentOperator.ID,
|
||||
"admin": currentOperation.CurrentOperator.Admin,
|
||||
"view_mode": currentOperation.ViewMode,
|
||||
"view_utc_time": currentOperation.CurrentOperator.ViewUtcTime,
|
||||
"current_utc_time": time.Now().UTC(),
|
||||
"scope_info": buildScopeIntrospectionResponse(c, currentClaimsOrNil(c)),
|
||||
"status": "success",
|
||||
"current_operation": currentOperation.CurrentOperation.Name,
|
||||
"current_operation_id": currentOperation.CurrentOperation.ID,
|
||||
"username": operator.Username,
|
||||
"email": operator.Email.String,
|
||||
"account_type": operator.AccountType,
|
||||
"user_id": operator.ID,
|
||||
"admin": operator.Admin,
|
||||
"active": operator.Active,
|
||||
"deleted": operator.Deleted,
|
||||
"view_mode": currentOperation.ViewMode,
|
||||
"last_login": operator.LastLogin.Time.UTC().Format(time.RFC3339),
|
||||
"current_utc_time": time.Now().UTC().Format(time.RFC3339),
|
||||
"auth_method": authMethod,
|
||||
"apitoken": apiTokenInfo,
|
||||
"eventstepinstance_id": eventStepInstanceID,
|
||||
"scopes": scopes,
|
||||
"effective_scopes": effectiveScopes,
|
||||
"operations": operations,
|
||||
"scope_info": buildScopeIntrospectionResponse(c, claims),
|
||||
})
|
||||
return
|
||||
}
|
||||
@@ -97,16 +123,74 @@ func currentClaimsOrNil(c *gin.Context) *mythicjwt.CustomClaims {
|
||||
return claims
|
||||
}
|
||||
|
||||
func buildWhoamiOperations(userID int) []gin.H {
|
||||
operationSummaries := []gin.H{}
|
||||
operations, err := database.GetOperationsForUser(userID)
|
||||
if err != nil {
|
||||
return operationSummaries
|
||||
}
|
||||
for _, operation := range *operations {
|
||||
operationSummaries = append(operationSummaries, gin.H{
|
||||
"id": operation.CurrentOperation.ID,
|
||||
"name": operation.CurrentOperation.Name,
|
||||
"admin_id": operation.CurrentOperation.AdminID,
|
||||
"view_mode": operation.ViewMode,
|
||||
})
|
||||
}
|
||||
return operationSummaries
|
||||
}
|
||||
|
||||
func buildWhoamiAPITokenInfo(c *gin.Context, claims *mythicjwt.CustomClaims) gin.H {
|
||||
apiTokenID := 0
|
||||
authenticatedWithAPIToken := false
|
||||
if claims != nil {
|
||||
apiTokenID = claims.APITokensID
|
||||
authenticatedWithAPIToken = claims.APITokensID > 0
|
||||
}
|
||||
info := gin.H{
|
||||
"authenticated_with_apitoken": authenticatedWithAPIToken,
|
||||
"apitokens_id": apiTokenID,
|
||||
"apitoken_name": nil,
|
||||
"apitoken_type": nil,
|
||||
"apitoken_created_by": nil,
|
||||
"apitoken_creation_time": nil,
|
||||
"apitoken_active": nil,
|
||||
"apitoken_deleted": nil,
|
||||
"apitoken_scopes": []string{},
|
||||
}
|
||||
if apiTokenID <= 0 {
|
||||
return info
|
||||
}
|
||||
apiToken := databaseStructs.Apitokens{}
|
||||
if cachedAPIToken, ok := c.Get(authentication.ContextKeyAPIToken); ok {
|
||||
if typedAPIToken, ok := cachedAPIToken.(databaseStructs.Apitokens); ok && typedAPIToken.ID == apiTokenID {
|
||||
apiToken = typedAPIToken
|
||||
}
|
||||
}
|
||||
if apiToken.ID == 0 {
|
||||
err := database.DB.Get(&apiToken, `SELECT
|
||||
id, token_type, active, creation_time, operator_id, "name", scopes, created_by, deleted, eventstepinstance_id
|
||||
FROM apitokens
|
||||
WHERE id=$1`, apiTokenID)
|
||||
if err != nil {
|
||||
logging.LogError(err, "Failed to get API token info for whoami", "apitokens_id", apiTokenID)
|
||||
return info
|
||||
}
|
||||
}
|
||||
info["apitoken_name"] = apiToken.Name
|
||||
info["apitoken_type"] = apiToken.TokenType
|
||||
info["apitoken_created_by"] = apiToken.CreatedBy
|
||||
info["apitoken_creation_time"] = apiToken.CreationTime.UTC().Format(time.RFC3339)
|
||||
info["apitoken_active"] = apiToken.Active
|
||||
info["apitoken_deleted"] = apiToken.Deleted
|
||||
info["apitoken_scopes"] = []string(apiToken.Scopes)
|
||||
return info
|
||||
}
|
||||
|
||||
func buildScopeIntrospectionResponse(c *gin.Context, claims *mythicjwt.CustomClaims) gin.H {
|
||||
if claims == nil {
|
||||
return gin.H{}
|
||||
}
|
||||
operationIDs := []int{}
|
||||
if operations, err := database.GetOperationsForUser(claims.UserID); err == nil {
|
||||
for _, operation := range *operations {
|
||||
operationIDs = append(operationIDs, operation.CurrentOperation.ID)
|
||||
}
|
||||
}
|
||||
effectiveScopes := mythicjwt.EffectiveScopes(claims.Scopes)
|
||||
hasAllScopes := mythicjwt.AllowsScope(claims.Scopes, mythicjwt.SCOPE_ALL)
|
||||
scopeDescriptions := []mythicjwt.ScopeDefinition{}
|
||||
@@ -117,14 +201,14 @@ func buildScopeIntrospectionResponse(c *gin.Context, claims *mythicjwt.CustomCla
|
||||
}
|
||||
return gin.H{
|
||||
"auth_method": claims.AuthMethod,
|
||||
"apitoken_id": claims.APITokensID,
|
||||
"apitokens_id": claims.APITokensID,
|
||||
"operator_id": claims.UserID,
|
||||
"current_operation_id": claims.OperationID,
|
||||
"eventstepinstance_id": claims.EventStepInstanceID,
|
||||
"scopes": claims.Scopes,
|
||||
"effective_scopes": effectiveScopes,
|
||||
"scope_descriptions": scopeDescriptions,
|
||||
"available_scopes": mythicjwt.ScopeDefinitions(),
|
||||
"operation_ids": operationIDs,
|
||||
"hasura_scope_claims": mythicjwt.HasuraScopeRequirements(),
|
||||
"limitations": []string{
|
||||
"Scopes apply within the operations available to the authenticated operator.",
|
||||
|
||||
@@ -2,10 +2,12 @@ package webcontroller
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"errors"
|
||||
"fmt"
|
||||
"github.com/its-a-feature/Mythic/rabbitmq"
|
||||
"net/http"
|
||||
|
||||
"github.com/its-a-feature/Mythic/rabbitmq"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/its-a-feature/Mythic/database"
|
||||
databaseStructs "github.com/its-a-feature/Mythic/database/structs"
|
||||
@@ -68,7 +70,7 @@ func UpdateOperatorOperationWebhook(c *gin.Context) {
|
||||
}
|
||||
err = database.DB.Get(&operatorRole, `SELECT * FROM operatoroperation WHERE
|
||||
operator_id=$1 AND operation_id=$2`, operator.ID, input.Input.OperationID)
|
||||
if err != sql.ErrNoRows && err != nil {
|
||||
if !errors.Is(err, sql.ErrNoRows) && err != nil {
|
||||
logging.LogError(err, "Failed to get information about operator's role in operation")
|
||||
c.JSON(http.StatusOK, UpdateOperatorOperationResponse{
|
||||
Status: "error",
|
||||
@@ -93,26 +95,30 @@ func UpdateOperatorOperationWebhook(c *gin.Context) {
|
||||
// add users
|
||||
for _, user := range input.Input.AddUsers {
|
||||
newUser := databaseStructs.Operator{ID: user}
|
||||
if _, err := database.DB.Exec(`INSERT INTO operatoroperation
|
||||
(operator_id, operation_id) VALUES ($1, $2) ON CONFLICT DO NOTHING `, user, currentOperation.ID); err != nil {
|
||||
_, err = database.DB.Exec(`INSERT INTO operatoroperation
|
||||
(operator_id, operation_id) VALUES ($1, $2) ON CONFLICT DO NOTHING `, user, currentOperation.ID)
|
||||
if err != nil {
|
||||
logging.LogError(err, "Failed to add operator to operation")
|
||||
} else if err := database.DB.Get(&newUser, `SELECT username FROM operator WHERE id=$1`, newUser.ID); err != nil {
|
||||
continue
|
||||
}
|
||||
err = database.DB.Get(&newUser, `SELECT username FROM operator WHERE id=$1`, newUser.ID)
|
||||
if err != nil {
|
||||
logging.LogError(err, "Failed to lookup operator username")
|
||||
} else {
|
||||
go rabbitmq.SendAllOperationsMessage(fmt.Sprintf("Adding %s to operation", newUser.Username),
|
||||
currentOperation.ID, "", database.MESSAGE_LEVEL_INFO, false)
|
||||
err = database.DB.Get(&newUser, `SELECT current_operation_id FROM operator WHERE id=$1`, newUser.ID)
|
||||
if err != nil {
|
||||
logging.LogError(err, "Failed to lookup operator username")
|
||||
}
|
||||
if int(newUser.CurrentOperationID.Int64) == 0 {
|
||||
newUser.CurrentOperationID.Valid = true
|
||||
newUser.CurrentOperationID.Int64 = int64(currentOperation.ID)
|
||||
_, err = database.DB.NamedExec(`UPDATE operator SET current_operation_id=:current_operation_id
|
||||
continue
|
||||
}
|
||||
go rabbitmq.SendAllOperationsMessage(fmt.Sprintf("Adding %s to operation", newUser.Username),
|
||||
currentOperation.ID, "", database.MESSAGE_LEVEL_INFO, false)
|
||||
err = database.DB.Get(&newUser, `SELECT current_operation_id FROM operator WHERE id=$1`, newUser.ID)
|
||||
if err != nil {
|
||||
logging.LogError(err, "Failed to lookup operator username")
|
||||
}
|
||||
if int(newUser.CurrentOperationID.Int64) == 0 {
|
||||
newUser.CurrentOperationID.Valid = true
|
||||
newUser.CurrentOperationID.Int64 = int64(currentOperation.ID)
|
||||
_, err = database.DB.NamedExec(`UPDATE operator SET current_operation_id=:current_operation_id
|
||||
WHERE id=:id`, newUser)
|
||||
if err != nil {
|
||||
logging.LogError(err, "Failed to update operator's current operation")
|
||||
}
|
||||
if err != nil {
|
||||
logging.LogError(err, "Failed to update operator's current operation")
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -120,61 +126,81 @@ func UpdateOperatorOperationWebhook(c *gin.Context) {
|
||||
for _, user := range input.Input.RemoveUsers {
|
||||
newUser := databaseStructs.Operator{ID: user}
|
||||
// can't remove the lead this way, you need to use the update first
|
||||
if _, err := database.DB.Exec(`DELETE FROM operatoroperation
|
||||
_, err = database.DB.Exec(`DELETE FROM operatoroperation
|
||||
WHERE operator_id=$1 AND operation_id=$2 AND view_mode!=$3`,
|
||||
user, currentOperation.ID, database.OPERATOR_OPERATION_VIEW_MODE_LEAD); err != nil {
|
||||
user, currentOperation.ID, database.OPERATOR_OPERATION_VIEW_MODE_LEAD)
|
||||
if err != nil {
|
||||
logging.LogError(err, "Failed to remove operator from operation")
|
||||
} else if err := database.DB.Get(&newUser, `SELECT username FROM operator WHERE id=$1`, newUser.ID); err != nil {
|
||||
continue
|
||||
}
|
||||
err = database.DB.Get(&newUser, `SELECT username FROM operator WHERE id=$1`, newUser.ID)
|
||||
if err != nil {
|
||||
logging.LogError(err, "Failed to lookup operator username")
|
||||
} else {
|
||||
go rabbitmq.SendAllOperationsMessage(fmt.Sprintf("Removing %s from operation", newUser.Username),
|
||||
currentOperation.ID, "", database.MESSAGE_LEVEL_INFO, false)
|
||||
// now that a user was removed from the op, see if they have any other's available and update
|
||||
err = database.DB.Get(&newUser, `SELECT current_operation_id FROM operator WHERE id=$1`, newUser.ID)
|
||||
if err != nil {
|
||||
logging.LogError(err, "Failed to lookup operator username")
|
||||
}
|
||||
if int(newUser.CurrentOperationID.Int64) == currentOperation.ID {
|
||||
newUser.CurrentOperationID.Valid = false
|
||||
newUser.CurrentOperationID.Int64 = 0
|
||||
_, err = database.DB.NamedExec(`UPDATE operator SET current_operation_id=:current_operation_id
|
||||
continue
|
||||
}
|
||||
go rabbitmq.SendAllOperationsMessage(fmt.Sprintf("Removing %s from operation", newUser.Username),
|
||||
currentOperation.ID, "", database.MESSAGE_LEVEL_INFO, false)
|
||||
// now that a user was removed from the op, see if they have any other's available and update
|
||||
err = database.DB.Get(&newUser, `SELECT current_operation_id FROM operator WHERE id=$1`, newUser.ID)
|
||||
if err != nil {
|
||||
logging.LogError(err, "Failed to lookup operator username")
|
||||
}
|
||||
err = invalidateAPITokensCreatedByOperatorIDAndOperationID(newUser.ID, newUser.Username, currentOperation.ID)
|
||||
if err != nil {
|
||||
logging.LogError(err, "Failed to invalidate API tokens created by operator")
|
||||
}
|
||||
if int(newUser.CurrentOperationID.Int64) == currentOperation.ID {
|
||||
newUser.CurrentOperationID.Valid = false
|
||||
newUser.CurrentOperationID.Int64 = 0
|
||||
_, err = database.DB.NamedExec(`UPDATE operator SET current_operation_id=:current_operation_id
|
||||
WHERE id=:id`, newUser)
|
||||
if err != nil {
|
||||
logging.LogError(err, "Failed to update operator's current operation")
|
||||
}
|
||||
if err != nil {
|
||||
logging.LogError(err, "Failed to update operator's current operation")
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
// change view_mode to operator for specified users
|
||||
for _, user := range input.Input.ViewModeOperators {
|
||||
newUser := databaseStructs.Operator{ID: user}
|
||||
if _, err := database.DB.Exec(`UPDATE operatoroperation SET
|
||||
_, err = database.DB.Exec(`UPDATE operatoroperation SET
|
||||
view_mode=$1 WHERE operator_id=$2 AND operation_id=$3 AND view_mode!=$4`,
|
||||
database.OPERATOR_OPERATION_VIEW_MODE_OPERATOR, user, currentOperation.ID,
|
||||
database.OPERATOR_OPERATION_VIEW_MODE_LEAD); err != nil {
|
||||
database.OPERATOR_OPERATION_VIEW_MODE_LEAD)
|
||||
if err != nil {
|
||||
logging.LogError(err, "Failed to update view mode to operator")
|
||||
} else if err := database.DB.Get(&newUser, `SELECT username FROM operator WHERE id=$1`, newUser.ID); err != nil {
|
||||
logging.LogError(err, "Failed to lookup operator username")
|
||||
} else {
|
||||
go rabbitmq.SendAllOperationsMessage(fmt.Sprintf("Updated %s to operator", newUser.Username),
|
||||
currentOperation.ID, "", database.MESSAGE_LEVEL_INFO, false)
|
||||
continue
|
||||
}
|
||||
err = database.DB.Get(&newUser, `SELECT username FROM operator WHERE id=$1`, newUser.ID)
|
||||
if err != nil {
|
||||
logging.LogError(err, "Failed to lookup operator username")
|
||||
continue
|
||||
}
|
||||
go rabbitmq.SendAllOperationsMessage(fmt.Sprintf("Updated %s to operator", newUser.Username),
|
||||
currentOperation.ID, "", database.MESSAGE_LEVEL_INFO, false)
|
||||
}
|
||||
// change view_mode to spectator for specified users
|
||||
for _, user := range input.Input.ViewModeSpectators {
|
||||
newUser := databaseStructs.Operator{ID: user}
|
||||
if _, err := database.DB.Exec(`UPDATE operatoroperation SET
|
||||
_, err = database.DB.Exec(`UPDATE operatoroperation SET
|
||||
view_mode=$1 WHERE operator_id=$2 AND operation_id=$3 AND view_mode!=$4`,
|
||||
database.OPERATOR_OPERATION_VIEW_MODE_SPECTATOR, user, currentOperation.ID,
|
||||
database.OPERATOR_OPERATION_VIEW_MODE_LEAD); err != nil {
|
||||
database.OPERATOR_OPERATION_VIEW_MODE_LEAD)
|
||||
if err != nil {
|
||||
logging.LogError(err, "Failed to update view mode to spectator")
|
||||
} else if err := database.DB.Get(&newUser, `SELECT username FROM operator WHERE id=$1`, newUser.ID); err != nil {
|
||||
logging.LogError(err, "Failed to lookup operator username")
|
||||
} else {
|
||||
go rabbitmq.SendAllOperationsMessage(fmt.Sprintf("Updated %s to spectator", newUser.Username),
|
||||
currentOperation.ID, "", database.MESSAGE_LEVEL_INFO, false)
|
||||
continue
|
||||
}
|
||||
|
||||
err = database.DB.Get(&newUser, `SELECT username FROM operator WHERE id=$1`, newUser.ID)
|
||||
if err != nil {
|
||||
logging.LogError(err, "Failed to lookup operator username")
|
||||
continue
|
||||
}
|
||||
err = invalidateAPITokensCreatedByOperatorIDAndOperationID(newUser.ID, newUser.Username, currentOperation.ID)
|
||||
if err != nil {
|
||||
logging.LogError(err, "Failed to invalidate API tokens created by operator")
|
||||
}
|
||||
go rabbitmq.SendAllOperationsMessage(fmt.Sprintf("Updated %s to spectator", newUser.Username),
|
||||
currentOperation.ID, "", database.MESSAGE_LEVEL_INFO, false)
|
||||
}
|
||||
// update disabled command profiles
|
||||
for _, disabledProfile := range input.Input.DisabledCommandProfiles {
|
||||
@@ -183,19 +209,26 @@ func UpdateOperatorOperationWebhook(c *gin.Context) {
|
||||
newProfileID = &disabledProfile.DisabledCommandProfileID
|
||||
}
|
||||
newUser := databaseStructs.Operator{ID: disabledProfile.UserID}
|
||||
if _, err := database.DB.Exec(`UPDATE operatoroperation SET base_disabled_commands_id=$1
|
||||
WHERE operator_id=$2 AND operation_id=$3`, newProfileID, disabledProfile.UserID, currentOperation.ID); err != nil {
|
||||
_, err = database.DB.Exec(`UPDATE operatoroperation SET base_disabled_commands_id=$1
|
||||
WHERE operator_id=$2 AND operation_id=$3`, newProfileID, disabledProfile.UserID, currentOperation.ID)
|
||||
if err != nil {
|
||||
logging.LogError(err, "Failed to update disabled commands profile")
|
||||
} else if err := database.DB.Get(&newUser, `SELECT username FROM operator WHERE id=$1`, newUser.ID); err != nil {
|
||||
continue
|
||||
}
|
||||
err = database.DB.Get(&newUser, `SELECT username FROM operator WHERE id=$1`, newUser.ID)
|
||||
if err != nil {
|
||||
logging.LogError(err, "Failed to lookup operator username")
|
||||
} else if disabledProfile.DisabledCommandProfileID > 0 {
|
||||
continue
|
||||
}
|
||||
if disabledProfile.DisabledCommandProfileID > 0 {
|
||||
var profileName string
|
||||
if err := database.DB.Get(&profileName, `SELECT "name" FROM disabledcommandsprofile WHERE id=$1`, disabledProfile.DisabledCommandProfileID); err != nil {
|
||||
err = database.DB.Get(&profileName, `SELECT "name" FROM disabledcommandsprofile WHERE id=$1`, disabledProfile.DisabledCommandProfileID)
|
||||
if err != nil {
|
||||
logging.LogError(err, "Failed to get disabled commands profile name")
|
||||
} else {
|
||||
go rabbitmq.SendAllOperationsMessage(fmt.Sprintf("Updated %s's disabled commands profile to '%s' ", newUser.Username, profileName),
|
||||
currentOperation.ID, "", database.MESSAGE_LEVEL_INFO, false)
|
||||
continue
|
||||
}
|
||||
go rabbitmq.SendAllOperationsMessage(fmt.Sprintf("Updated %s's disabled commands profile to '%s' ", newUser.Username, profileName),
|
||||
currentOperation.ID, "", database.MESSAGE_LEVEL_INFO, false)
|
||||
} else {
|
||||
go rabbitmq.SendAllOperationsMessage(fmt.Sprintf("Removed %s's disabled commands profile ", newUser.Username),
|
||||
currentOperation.ID, "", database.MESSAGE_LEVEL_INFO, false)
|
||||
|
||||
@@ -6,6 +6,7 @@ import (
|
||||
"time"
|
||||
|
||||
"github.com/its-a-feature/Mythic/authentication/mythicjwt"
|
||||
"github.com/its-a-feature/Mythic/rabbitmq"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/its-a-feature/Mythic/authentication"
|
||||
@@ -86,10 +87,23 @@ func GenerateAPITokenWebhook(c *gin.Context) {
|
||||
})
|
||||
return
|
||||
}
|
||||
if !issuingUser.Admin {
|
||||
issuingUserOperatorOperation := databaseStructs.Operatoroperation{}
|
||||
err = database.DB.Get(&issuingUserOperatorOperation, `SELECT
|
||||
id, view_mode
|
||||
FROM operatoroperation
|
||||
WHERE operatoroperation.operator_id=$1 and operatoroperation.operation_id=$2`,
|
||||
issuingUser.ID, targetOperator.CurrentOperationID.Int64)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusOK, UpdateCurrentOperationResponse{
|
||||
Status: "error",
|
||||
Error: "Cannot generate an apitoken for a bot that's a member of an operation you don't belong to",
|
||||
})
|
||||
return
|
||||
}
|
||||
if issuingUserOperatorOperation.ViewMode == database.OPERATOR_OPERATION_VIEW_MODE_SPECTATOR {
|
||||
c.JSON(http.StatusOK, GenerateAPITokenResponse{
|
||||
Status: "error",
|
||||
Error: "only admins can generate API tokens for bot accounts",
|
||||
Error: "cannot generate an apitoken for a bot as a spectator",
|
||||
})
|
||||
return
|
||||
}
|
||||
@@ -182,7 +196,38 @@ func GenerateAPITokenWebhook(c *gin.Context) {
|
||||
Name: apiToken.Name,
|
||||
CreatedBy: apiToken.CreatedBy,
|
||||
TokenType: apiToken.TokenType,
|
||||
Scopes: []string(apiToken.Scopes),
|
||||
Scopes: apiToken.Scopes,
|
||||
CreationTime: apiToken.CreationTime,
|
||||
})
|
||||
}
|
||||
|
||||
func invalidateAPITokensCreatedByOperatorIDAndOperationID(operatorID int, operatorUsername string, operationID int) error {
|
||||
var err error
|
||||
if operationID > 0 {
|
||||
_, err = database.DB.Exec(`UPDATE apitokens
|
||||
SET active=false, deleted=true
|
||||
FROM operator bot_operator
|
||||
WHERE apitokens.created_by=$1
|
||||
AND apitokens.operator_id = bot_operator.id
|
||||
AND apitokens.operator_id != apitokens.created_by
|
||||
AND bot_operator.account_type='bot'
|
||||
AND bot_operator.current_operation_id=$2
|
||||
RETURNING apitokens.id
|
||||
`,
|
||||
operatorID, operationID)
|
||||
go rabbitmq.SendAllOperationsMessage(fmt.Sprintf("Invalidating APITokens created by %s for bot accounts", operatorUsername),
|
||||
operationID, "", database.MESSAGE_LEVEL_INFO, false)
|
||||
} else {
|
||||
_, err = database.DB.Exec(`UPDATE apitokens
|
||||
SET active=false, deleted=true
|
||||
FROM operator bot_operator
|
||||
WHERE apitokens.created_by=$1
|
||||
AND apitokens.operator_id = bot_operator.id
|
||||
AND apitokens.operator_id != apitokens.created_by
|
||||
AND bot_operator.account_type='bot'
|
||||
RETURNING apitokens.id`,
|
||||
operatorID)
|
||||
}
|
||||
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
package webcontroller
|
||||
|
||||
import (
|
||||
"github.com/its-a-feature/Mythic/logging"
|
||||
"net/http"
|
||||
|
||||
"github.com/its-a-feature/Mythic/logging"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/its-a-feature/Mythic/database"
|
||||
databaseStructs "github.com/its-a-feature/Mythic/database/structs"
|
||||
@@ -42,6 +43,7 @@ func UpdateCurrentOperationWebhook(c *gin.Context) {
|
||||
// get the associated database information
|
||||
operatorOperation := databaseStructs.Operatoroperation{}
|
||||
issuingOperator := databaseStructs.Operator{}
|
||||
targetOperator := databaseStructs.Operator{}
|
||||
userID, err := GetUserIDFromGin(c)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusOK, UpdateCurrentOperationResponse{
|
||||
@@ -50,7 +52,7 @@ func UpdateCurrentOperationWebhook(c *gin.Context) {
|
||||
})
|
||||
return
|
||||
}
|
||||
err = database.DB.Get(&issuingOperator, `SELECT id, admin FROM operator WHERE id=$1`, userID)
|
||||
err = database.DB.Get(&issuingOperator, `SELECT id, admin, account_type FROM operator WHERE id=$1`, userID)
|
||||
if err != nil {
|
||||
logging.LogError(err, "Failed to get information about issuing user")
|
||||
c.JSON(http.StatusOK, UpdateCurrentOperationResponse{
|
||||
@@ -59,10 +61,40 @@ func UpdateCurrentOperationWebhook(c *gin.Context) {
|
||||
})
|
||||
return
|
||||
}
|
||||
if userID != input.Input.UserID && !issuingOperator.Admin {
|
||||
err = database.DB.Get(&targetOperator, `SELECT id, admin, account_type, deleted, active FROM operator WHERE id=$1`, input.Input.UserID)
|
||||
if err != nil {
|
||||
logging.LogError(err, "Failed to get information about issuing user")
|
||||
c.JSON(http.StatusOK, UpdateCurrentOperationResponse{
|
||||
Status: "error",
|
||||
Error: "Cannot set the current operation for another user",
|
||||
Error: err.Error(),
|
||||
})
|
||||
return
|
||||
}
|
||||
if targetOperator.Deleted {
|
||||
c.JSON(http.StatusOK, UpdateCurrentOperationResponse{
|
||||
Status: "error",
|
||||
Error: "Cannot set the current operation for a deleted user",
|
||||
})
|
||||
return
|
||||
}
|
||||
if !targetOperator.Active {
|
||||
c.JSON(http.StatusOK, UpdateCurrentOperationResponse{
|
||||
Status: "error",
|
||||
Error: "Cannot set the current operation for an inactive user",
|
||||
})
|
||||
return
|
||||
}
|
||||
if targetOperator.AccountType == databaseStructs.AccountTypeBot {
|
||||
c.JSON(http.StatusOK, UpdateCurrentOperationResponse{
|
||||
Status: "error",
|
||||
Error: "Cannot change the current operation for a bot account",
|
||||
})
|
||||
return
|
||||
}
|
||||
if issuingOperator.ID != input.Input.UserID && !issuingOperator.Admin {
|
||||
c.JSON(http.StatusOK, UpdateCurrentOperationResponse{
|
||||
Status: "error",
|
||||
Error: "Cannot set the current operation for another user unless you're an admin",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
@@ -174,11 +174,8 @@ func setRoutes(r *gin.Engine) {
|
||||
//protected.GET("/graphql/webhook", webcontroller.GetHasuraClaims)
|
||||
protected.POST("/graphql/webhook", webcontroller.GetHasuraClaims)
|
||||
// user
|
||||
protected.POST("/me_webhook",
|
||||
authentication.TokenScopeMiddleware([]string{
|
||||
mythicjwt.SCOPE_OPERATOR_READ,
|
||||
}),
|
||||
webcontroller.GetMeWebhook) // controller.login
|
||||
protected.POST("/whoami_webhook",
|
||||
webcontroller.GetWhoamiWebhook) // controller.login
|
||||
protected.POST("/generate_apitoken_webhook",
|
||||
authentication.TokenScopeMiddleware([]string{
|
||||
mythicjwt.SCOPE_APITOKEN_WRITE,
|
||||
|
||||
Reference in New Issue
Block a user