add anomaly + fix billing + add emails templates

This commit is contained in:
Emily
2024-09-14 17:07:46 +02:00
parent c253846b86
commit 4c46a36c75
23 changed files with 336 additions and 48 deletions

View File

@@ -20,7 +20,7 @@ const { visible } = usePricingDrawer();
<Transition name="pdrawer"> <Transition name="pdrawer">
<LazyPricingDrawer @onCloseClick="visible = false" <LazyPricingDrawer @onCloseClick="visible = false"
class="bg-black fixed right-0 top-0 w-full xl:w-[60vw] xl:min-w-[65rem] h-full z-[20]" v-if=visible> class="bg-black fixed right-0 top-0 w-full xl:w-[60vw] xl:min-w-[65rem] h-full z-[20]" v-if="visible">
</LazyPricingDrawer> </LazyPricingDrawer>
</Transition> </Transition>

View File

@@ -28,6 +28,7 @@ const route = useRoute();
const props = defineProps<Props>(); const props = defineProps<Props>();
const { isAdmin } = useUserRoles(); const { isAdmin } = useUserRoles();
const loggedUser = useLoggedUser()
const debugMode = process.dev; const debugMode = process.dev;
@@ -101,8 +102,15 @@ function onLogout() {
} }
const { projects } = useProjectsList(); const { projects } = useProjectsList();
const { data: guestProjects } = useGuestProjectsList()
const activeProject = useActiveProject(); const activeProject = useActiveProject();
const selectorProjects = computed(() => {
const result: TProject[] = [];
if (projects.value) result.push(...projects.value);
if (guestProjects.value) result.push(...guestProjects.value);
return result;
});
const { data: maxProjects } = useFetch("/api/user/max_projects", { const { data: maxProjects } = useFetch("/api/user/max_projects", {
headers: computed(() => { headers: computed(() => {
@@ -121,6 +129,11 @@ const isPremium = computed(() => {
return activeProject.value?.premium; return activeProject.value?.premium;
}) })
function isProjectMine(owner?: string) {
if (!owner) return false;
if (!loggedUser.value?.logged) return;
return loggedUser.value.id == owner;
}
const pricingDrawer = usePricingDrawer(); const pricingDrawer = usePricingDrawer();
@@ -152,14 +165,14 @@ const pricingDrawer = usePricingDrawer();
base: 'hover:!bg-lyx-widget-lighter cursor-pointer', base: 'hover:!bg-lyx-widget-lighter cursor-pointer',
active: '!bg-lyx-widget-lighter' active: '!bg-lyx-widget-lighter'
} }
}" class="w-full" v-if="projects" v-model="selected" :options="projects"> }" class="w-full" v-if="selectorProjects" v-model="selected" :options="selectorProjects">
<template #option="{ option, active, selected }"> <template #option="{ option, active, selected }">
<div class="flex items-center gap-2"> <div class="flex items-center gap-2">
<div> <div>
<img class="h-5 bg-black rounded-full" :src="'/logo_32.png'" alt="Litlyx logo"> <img class="h-5 bg-black rounded-full" :src="'/logo_32.png'" alt="Litlyx logo">
</div> </div>
<div> {{ option.name }} </div> <div> {{ option.name }} {{ !isProjectMine(option.owner) ? '(Guest)' : '' }}</div>
</div> </div>
</template> </template>
@@ -168,7 +181,10 @@ const pricingDrawer = usePricingDrawer();
<div> <div>
<img class="h-5 bg-black rounded-full" :src="'/logo_32.png'" alt="Litlyx logo"> <img class="h-5 bg-black rounded-full" :src="'/logo_32.png'" alt="Litlyx logo">
</div> </div>
<div> {{ activeProject?.name || '???' }} </div> <div>
{{ activeProject?.name || '-' }}
{{ !isProjectMine(activeProject?.owner?.toString()) ? '(Guest)' : '' }}
</div>
</div> </div>
</template> </template>
</USelectMenu> </USelectMenu>
@@ -179,6 +195,7 @@ const pricingDrawer = usePricingDrawer();
</div> </div>
<NuxtLink to="/project_creation" v-if="projects && (projects.length < (maxProjects || 1))" <NuxtLink to="/project_creation" v-if="projects && (projects.length < (maxProjects || 1))"
class="flex items-center text-[.8rem] gap-1 justify-end pt-2 pr-2 text-lyx-text-dark hover:text-lyx-text cursor-pointer"> class="flex items-center text-[.8rem] gap-1 justify-end pt-2 pr-2 text-lyx-text-dark hover:text-lyx-text cursor-pointer">
<div><i class="fas fa-plus"></i></div> <div><i class="fas fa-plus"></i></div>

View File

@@ -106,11 +106,11 @@ const externalTooltipElement = ref<null | HTMLDivElement>(null);
function externalTooltipHandler(context: { chart: any, tooltip: TooltipModel<'line' | 'bar'> }) { function externalTooltipHandler(context: { chart: any, tooltip: TooltipModel<'line' | 'bar'> }) {
const { chart, tooltip } = context; const { chart, tooltip } = context;
const tooltipEl = externalTooltipElement.value; const tooltipEl = externalTooltipElement.value;
currentTooltipData.value.visits = (tooltip.dataPoints.find(e=> e.datasetIndex == 0)?.raw) as number; currentTooltipData.value.visits = (tooltip.dataPoints.find(e => e.datasetIndex == 0)?.raw) as number;
currentTooltipData.value.sessions = (tooltip.dataPoints.find(e=> e.datasetIndex == 1)?.raw) as number; currentTooltipData.value.sessions = (tooltip.dataPoints.find(e => e.datasetIndex == 1)?.raw) as number;
currentTooltipData.value.events = ((tooltip.dataPoints.find(e=> e.datasetIndex == 2)?.raw) as any)?.r2 as number; currentTooltipData.value.events = ((tooltip.dataPoints.find(e => e.datasetIndex == 2)?.raw) as any)?.r2 as number;
currentTooltipData.value.date = new Date(allDatesFull.value[tooltip.dataPoints[0].dataIndex]).toLocaleDateString(); currentTooltipData.value.date = new Date(allDatesFull.value[tooltip.dataPoints[0].dataIndex]).toLocaleDateString();
if (!tooltipEl) return; if (!tooltipEl) return;
@@ -118,11 +118,12 @@ function externalTooltipHandler(context: { chart: any, tooltip: TooltipModel<'li
tooltipEl.style.opacity = '0'; tooltipEl.style.opacity = '0';
return; return;
} }
const { offsetLeft: positionX, offsetTop: positionY } = chart.canvas; const { left: positionX, top: positionY } = chart.canvas.getBoundingClientRect();
tooltipEl.style.opacity = '1'; tooltipEl.style.opacity = '1';
tooltipEl.style.left = positionX + tooltip.caretX + 'px'; tooltipEl.style.left = positionX + tooltip.caretX + 'px';
tooltipEl.style.top = positionY + tooltip.caretY + 'px'; tooltipEl.style.top = positionY + tooltip.caretY + 'px';
tooltipEl.style.padding = tooltip.options.padding + 'px ' + tooltip.options.padding + 'px'; tooltipEl.style.padding = tooltip.options.padding + 'px ' + tooltip.options.padding + 'px';
} }

View File

@@ -13,6 +13,19 @@ function copyProjectId() {
navigator.clipboard.writeText((activeProject.value?._id || 0).toString()); navigator.clipboard.writeText((activeProject.value?._id || 0).toString());
createAlert('Success', 'Project id copied successfully.', 'far fa-circle-check', 5000); createAlert('Success', 'Project id copied successfully.', 'far fa-circle-check', 5000);
} }
function showAnomalyInfoAlert() {
createAlert('AI Anomaly Detector info',
`Anomaly detector is running. It helps you detect a spike in visits or events, it could mean an
attack or simply higher traffic due to good performance. Additionally, it can detect if someone is
stealing parts of your website and hosting a duplicate version—an unfortunately common practice.
Litlyx will notify you via email with actionable advices`,
'far fa-bug',
10000
)
}
</script> </script>
@@ -29,8 +42,10 @@ function copyProjectId() {
<div class="flex md:gap-2 items-center md:justify-start flex-col md:flex-row"> <div class="flex md:gap-2 items-center md:justify-start flex-col md:flex-row">
<div class="poppins font-medium text-lyx-text-darker text-[1.2rem]">Project:</div> <div class="poppins font-medium text-lyx-text-darker text-[1.2rem]">Project:</div>
<div class="text-lyx-text poppins font-medium text-[1.2rem]"> {{ activeProject?.name || 'Loading...' }} </div> <div class="text-lyx-text poppins font-medium text-[1.2rem]"> {{ activeProject?.name || 'Loading...' }}
</div>
</div> </div>
<div class="flex flex-col md:flex-row md:gap-2 items-center md:justify-start"> <div class="flex flex-col md:flex-row md:gap-2 items-center md:justify-start">
<div class="poppins font-medium text-lyx-text-darker text-[1.2rem]">Project id:</div> <div class="poppins font-medium text-lyx-text-darker text-[1.2rem]">Project id:</div>
<div class="flex gap-2"> <div class="flex gap-2">
@@ -38,9 +53,20 @@ function copyProjectId() {
{{ activeProject?._id || 'Loading...' }} {{ activeProject?._id || 'Loading...' }}
</div> </div>
<div class="flex items-center ml-3"> <div class="flex items-center ml-3">
<i @click="copyProjectId()" class="far fa-copy text-lyx-text hover:text-lyx-primary cursor-pointer text-[1.2rem]"></i> <i @click="copyProjectId()"
class="far fa-copy text-lyx-text hover:text-lyx-primary cursor-pointer text-[1.2rem]"></i>
</div> </div>
</div> </div>
</div> </div>
<div class="flex gap-2 items-center text-text/90 justify-center md:justify-start">
<div class="animate-pulse w-[1rem] h-[1rem] bg-green-400 rounded-full"> </div>
<div class="poppins font-regular text-[1rem]"> AI Anomaly Detector </div>
<div class="flex items-center">
<i class="far fa-info-circle text-[.9rem] hover:text-lyx-primary cursor-pointer"
@click="showAnomalyInfoAlert"></i>
</div>
</div>
</div> </div>
</template> </template>

View File

@@ -14,7 +14,6 @@ const entries: SettingsTemplateEntry[] = [
const activeProject = useActiveProject(); const activeProject = useActiveProject();
const projectNameInputVal = ref<string>(activeProject.value?.name || ''); const projectNameInputVal = ref<string>(activeProject.value?.name || '');
const apiKeys = ref<TApiSettings[]>([]); const apiKeys = ref<TApiSettings[]>([]);
const newApiKeyName = ref<string>(''); const newApiKeyName = ref<string>('');
@@ -145,13 +144,15 @@ function copyProjectId() {
<template #pname> <template #pname>
<div class="flex items-center gap-4"> <div class="flex items-center gap-4">
<LyxUiInput class="w-full px-4 py-2" v-model="projectNameInputVal"></LyxUiInput> <LyxUiInput class="w-full px-4 py-2" v-model="projectNameInputVal"></LyxUiInput>
<LyxUiButton @click="changeProjectName()" :disabled="!canChange" type="primary"> Change </LyxUiButton> <LyxUiButton v-if="!isGuest" @click="changeProjectName()" :disabled="!canChange" type="primary"> Change
</LyxUiButton>
</div> </div>
</template> </template>
<template #api> <template #api>
<div class="flex items-center gap-4" v-if="apiKeys && apiKeys.length < 5"> <div class="flex items-center gap-4" v-if="apiKeys && apiKeys.length < 5">
<LyxUiInput class="grow px-4 py-2" placeholder="ApiKeyName" v-model="newApiKeyName"></LyxUiInput> <LyxUiInput class="grow px-4 py-2" placeholder="ApiKeyName" v-model="newApiKeyName"></LyxUiInput>
<LyxUiButton @click="createApiKey()" :disabled="newApiKeyName.length < 3" type="primary"> <LyxUiButton v-if="!isGuest" @click="createApiKey()" :disabled="newApiKeyName.length < 3"
type="primary">
<i class="far fa-plus"></i> <i class="far fa-plus"></i>
</LyxUiButton> </LyxUiButton>
</div> </div>
@@ -185,8 +186,8 @@ function copyProjectId() {
<div><i class="far fa-copy" @click="copyScript()"></i></div> <div><i class="far fa-copy" @click="copyScript()"></i></div>
</LyxUiCard> </LyxUiCard>
</template> </template>
<template #pdelete> <template #pdelete >
<div class="flex justify-end"> <div class="flex justify-end" v-if="!isGuest">
<LyxUiButton type="danger" @click="deleteProject()"> <LyxUiButton type="danger" @click="deleteProject()">
Delete project Delete project
</LyxUiButton> </LyxUiButton>

View File

@@ -31,6 +31,7 @@ const sections: Section[] = [
label: 'Slack support', icon: 'fab fa-slack', label: 'Slack support', icon: 'fab fa-slack',
premiumOnly: true, premiumOnly: true,
action() { action() {
if (isGuest) return;
if (isPremium.value === true) { if (isPremium.value === true) {
window.open('https://join.slack.com/t/litlyx/shared_invite/zt-2q3oawn29-hZlu_fBUBlc4052Ooe3FZg', '_blank'); window.open('https://join.slack.com/t/litlyx/shared_invite/zt-2q3oawn29-hZlu_fBUBlc4052Ooe3FZg', '_blank');
} else { } else {

View File

@@ -124,7 +124,7 @@ async function deleteChat(chat_id: string) {
<div class="flex flex-row h-full"> <div class="flex flex-row h-full">
<div class="flex-[5] py-8 flex flex-col items-center relative"> <div class="flex-[5] py-8 flex flex-col items-center relative bg-lyx-background-light">
<div class="flex flex-col items-center mt-[20vh] px-28" v-if="currentChatMessages.length == 0"> <div class="flex flex-col items-center mt-[20vh] px-28" v-if="currentChatMessages.length == 0">
<div class="w-[10rem]"> <div class="w-[10rem]">
@@ -138,7 +138,7 @@ async function deleteChat(chat_id: string) {
</div> </div>
<div class="grid grid-cols-2 gap-4 mt-6" v-if="!isGuest"> <div class="grid grid-cols-2 gap-4 mt-6" v-if="!isGuest">
<div v-for="prompt of defaultPrompts" @click="currentText = prompt" <div v-for="prompt of defaultPrompts" @click="currentText = prompt"
class="bg-[#2f2f2f] hover:bg-[#424242] cursor-pointer p-4 rounded-lg poppins text-center"> class="bg-lyx-widget-light hover:bg-lyx-widget-lighter cursor-pointer p-4 rounded-lg poppins text-center">
{{ prompt }} {{ prompt }}
</div> </div>
</div> </div>
@@ -148,7 +148,7 @@ async function deleteChat(chat_id: string) {
<div class="flex w-full" v-for="message of currentChatMessages"> <div class="flex w-full" v-for="message of currentChatMessages">
<div class="flex justify-end w-full poppins text-[1.1rem]" v-if="message.role === 'user'"> <div class="flex justify-end w-full poppins text-[1.1rem]" v-if="message.role === 'user'">
<div class="bg-[#303030] px-5 py-3 rounded-lg"> <div class="bg-lyx-widget-light px-5 py-3 rounded-lg">
{{ message.content }} {{ message.content }}
</div> </div>
</div> </div>
@@ -177,13 +177,13 @@ async function deleteChat(chat_id: string) {
<div v-if="!isGuest" class="flex gap-2 items-center absolute bottom-8 left-0 w-full px-10 xl:px-28"> <div v-if="!isGuest" class="flex gap-2 items-center absolute bottom-8 left-0 w-full px-10 xl:px-28">
<input @keydown="onKeyDown" v-model="currentText" <input @keydown="onKeyDown" v-model="currentText"
class="bg-[#303030] w-full focus:outline-none px-4 py-2 rounded-lg" type="text"> class="bg-lyx-widget-light w-full focus:outline-none px-4 py-2 rounded-lg" type="text">
<div @click="sendMessage()" <div @click="sendMessage()"
class="bg-[#303030] hover:bg-[#464646] cursor-pointer px-4 py-2 rounded-full"> class="bg-lyx-widget-light hhover:bg-lyx-widget-lighter cursor-pointer px-4 py-2 rounded-full">
<i class="far fa-arrow-up"></i> <i class="far fa-arrow-up"></i>
</div> </div>
<div @click="menuOpen = !menuOpen" <div @click="menuOpen = !menuOpen"
class="bg-[#303030] lg:hidden hover:bg-[#464646] cursor-pointer px-4 py-2 rounded-full"> class="bg-lyx-widget-light lg:hidden hhover:bg-lyx-widget-lighter cursor-pointer px-4 py-2 rounded-full">
<i class="far fa-message"></i> <i class="far fa-message"></i>
</div> </div>
</div> </div>
@@ -194,7 +194,7 @@ async function deleteChat(chat_id: string) {
<div :class="{ <div :class="{
'absolute': menuOpen, 'absolute': menuOpen,
'hidden lg:flex': !menuOpen 'hidden lg:flex': !menuOpen
}" class="flex-[2] bg-[#303030] p-6 flex flex-col gap-4 h-full overflow-hidden"> }" class="flex-[2] bg-lyx-widget-light p-6 flex flex-col gap-4 h-full overflow-hidden">
<div class="gap-2 flex flex-col"> <div class="gap-2 flex flex-col">
<div class="lg:hidden absolute right-4 top-4 text-[1.5rem]"> <div class="lg:hidden absolute right-4 top-4 text-[1.5rem]">
@@ -219,7 +219,7 @@ async function deleteChat(chat_id: string) {
<div class="px-2"> <div class="px-2">
<div @click="openChat()" <div @click="openChat()"
class="bg-menu cursor-pointer hover:bg-menu/80 rounded-lg px-4 py-3 poppins flex gap-2 items-center"> class="bg-lyx-widget-lighter cursor-pointer hover:bg-lyx-widget rounded-lg px-4 py-3 poppins flex gap-4 items-center">
<div> <i class="fas fa-plus"></i> </div> <div> <i class="fas fa-plus"></i> </div>
<div> New chat </div> <div> New chat </div>
</div> </div>
@@ -228,12 +228,11 @@ async function deleteChat(chat_id: string) {
<div class="overflow-y-auto"> <div class="overflow-y-auto">
<div class="flex flex-col gap-2 px-2"> <div class="flex flex-col gap-2 px-2">
<div class="flex items-center gap-4 w-full" v-for="chat of chatsList?.toReversed()"> <div :class="{ '!bg-accent/60': chat._id.toString() === currentChatId }" class="flex rounded-lg items-center gap-4 w-full px-4 bg-lyx-widget-lighter hover:bg-lyx-widget" v-for="chat of chatsList?.toReversed()">
<i @click="deleteChat(chat._id.toString())" <i @click="deleteChat(chat._id.toString())"
class="fas fa-trash hover:text-gray-300 cursor-pointer"></i> class="far fa-trash hover:text-gray-300 cursor-pointer"></i>
<div @click="openChat(chat._id.toString())" <div @click="openChat(chat._id.toString())"
class="bg-menu px-4 py-3 w-full cursor-pointer hover:bg-menu/80 poppins rounded-lg" class="py-3 w-full cursor-pointer poppins rounded-lg">
:class="{ '!bg-accent/60': chat._id.toString() === currentChatId }">
{{ chat.title }} {{ chat.title }}
</div> </div>
</div> </div>

View File

@@ -29,7 +29,7 @@ export default defineEventHandler(async event => {
const project = await ProjectModel.findById(project_id); const project = await ProjectModel.findById(project_id);
if (!project) return setResponseStatus(event, 400, 'Project not found'); if (!project) return setResponseStatus(event, 400, 'Project not found');
if (project.owner.toString() != userData.id) { if (project.owner.toString() != userData.id) {
return setResponseStatus(event, 400, 'You are not the owner'); return setResponseStatus(event, 400, 'You are not the owner');
} }

View File

@@ -1,6 +0,0 @@
export default defineEventHandler(async event => {
console.log('TEST');
return;
});

View File

@@ -9,9 +9,13 @@ export default defineEventHandler(async event => {
if (!project_id) return; if (!project_id) return;
const user = getRequestUser(event); const user = getRequestUser(event);
if (!user?.logged) return setResponseStatus(event, 400, 'User need to be logged');
const project = await getUserProjectFromId(project_id, user); const project = await getUserProjectFromId(project_id, user);
if (!project) return; if (!project) return;
if (project.owner.toString() != user.id) return setResponseStatus(event, 400, 'You cannot upgrade a project as guest');
const body = await readBody(event); const body = await readBody(event);
const { planId } = body; const { planId } = body;

View File

@@ -9,9 +9,13 @@ export default defineEventHandler(async event => {
if (!project_id) return; if (!project_id) return;
const user = getRequestUser(event); const user = getRequestUser(event);
if (!user?.logged) return setResponseStatus(event, 400, 'User need to be logged');
const project = await getUserProjectFromId(project_id, user); const project = await getUserProjectFromId(project_id, user);
if (!project) return; if (!project) return;
if (project.owner.toString() != user.id) return setResponseStatus(event, 400, 'You cannot upgrade a project as guest');
const body = await readBody(event); const body = await readBody(event);
const { planId } = body; const { planId } = body;

View File

@@ -143,6 +143,7 @@ async function onPaymentSuccess(event: Event.InvoicePaidEvent) {
EmailService.sendPurchaseEmail(user.email, project.name); EmailService.sendPurchaseEmail(user.email, project.name);
}, 1); }, 1);
return { ok: true }; return { ok: true };

View File

@@ -18,6 +18,8 @@ export default defineEventHandler(async event => {
const project = await ProjectModel.findById(project_id); const project = await ProjectModel.findById(project_id);
if (!project) return setResponseStatus(event, 400, 'Project not exist'); if (!project) return setResponseStatus(event, 400, 'Project not exist');
if (userData.id != project.owner.toString()) return setResponseStatus(event, 400, 'You cannot delete a project as guest');
const projects = await ProjectModel.countDocuments({ owner: userData.id }); const projects = await ProjectModel.countDocuments({ owner: userData.id });
if (projects == 1) return setResponseStatus(event, 400, 'Cannot delete last project'); if (projects == 1) return setResponseStatus(event, 400, 'Cannot delete last project');

View File

@@ -2,10 +2,17 @@ import mongoose from "mongoose";
import { Redis } from "~/server/services/CacheService"; import { Redis } from "~/server/services/CacheService";
import EmailService from '@services/EmailService'; import EmailService from '@services/EmailService';
import StripeService from '~/server/services/StripeService'; import StripeService from '~/server/services/StripeService';
import { anomalyLoop } from "./services/AnomalyService";
const config = useRuntimeConfig(); const config = useRuntimeConfig();
let connection: mongoose.Mongoose; let connection: mongoose.Mongoose;
let anomalyMinutesCount = 0;
function anomalyCheck() {
}
export default async () => { export default async () => {
console.log('[SERVER] Initializing'); console.log('[SERVER] Initializing');
@@ -37,4 +44,7 @@ export default async () => {
console.log('[SERVER] Completed'); console.log('[SERVER] Completed');
console.log('[ANOMALY LOOP] Started');
anomalyLoop();
}; };

View File

@@ -0,0 +1,148 @@
import mongoose from "mongoose";
import { executeTimelineAggregation } from "./TimelineService";
import { VisitModel } from "@schema/metrics/VisitSchema";
import { AnomalyDomainModel } from '@schema/anomalies/AnomalyDomainSchema';
import { AnomalyVisitModel } from '@schema/anomalies/AnomalyVisitSchema';
import { AnomalyEventsModel } from '@schema/anomalies/AnomalyEventsSchema';
import { EventModel } from "@schema/metrics/EventSchema";
import EmailService from "@services/EmailService";
import * as url from 'url';
import { ProjectModel } from "@schema/ProjectSchema";
import { UserModel } from "@schema/UserSchema";
type TAvgInput = { _id: string, count: number }
const anomalyData = { minutes: 0 }
async function anomalyCheckAll() {
const start = performance.now();
console.log('START ANOMALY CHECK');
const projects = await ProjectModel.find({}, { _id: 1 });
for (const project of projects) {
await findAnomalies(project.id);
}
const end = start - performance.now();
console.log('END ANOMALY CHECK', end, 'ms');
}
export function anomalyLoop() {
if (anomalyData.minutes == 60 * 12) {
anomalyCheckAll();
anomalyData.minutes = 0;
}
setTimeout(() => anomalyLoop(), 1000 * 60);
}
function movingAverageAnomaly(visits: TAvgInput[], windowSize: number, threshold: number): TAvgInput[] {
const anomalies: TAvgInput[] = [];
for (let i = windowSize; i < visits.length; i++) {
const window = visits.slice(i - windowSize, i);
const mean = window.reduce((a, b) => a + b.count, 0) / window.length;
const stdDev = Math.sqrt(window.reduce((sum, visit) => sum + Math.pow(visit.count - mean, 2), 0) / window.length);
const currentVisit = visits[i];
if (Math.abs(currentVisit.count - mean) > threshold * stdDev) {
if (currentVisit.count <= mean) continue;
anomalies.push(currentVisit);
}
}
return anomalies;
}
function getUrlFromString(str: string) {
const res = str.startsWith('http') ? str : 'http://' + str;
return res;
}
export async function findAnomalies(project_id: string) {
const THRESHOLD = 6;
const WINDOW_SIZE = 14;
const pid = new mongoose.Types.ObjectId(project_id) as any;
const from = Date.now() - 1000 * 60 * 60 * 24 * 30;
const to = Date.now() - 1000 * 60 * 60 * 24;
const visitsTimelineData = await executeTimelineAggregation({
projectId: pid,
model: VisitModel,
from, to, slice: 'day'
});
const eventsTimelineData = await executeTimelineAggregation({
projectId: pid,
model: EventModel,
from, to, slice: 'day'
});
const websites: { _id: string, count: number }[] = await VisitModel.aggregate([
{ $match: { project_id: pid, created_at: { $gte: new Date(from), $lte: new Date(to) } }, },
{ $group: { _id: "$website", count: { $sum: 1, } } }
]);
const rootWebsite = websites.reduce((a, e) => {
return a.count > e.count ? a : e;
});
const rootDomain = new url.URL(getUrlFromString(rootWebsite._id)).hostname;
const detectedWebsites: string[] = [];
for (const website of websites) {
const websiteDomain = new url.URL(getUrlFromString(website._id)).hostname;
if (!websiteDomain.includes(rootDomain)) {
detectedWebsites.push(website._id);
}
}
const visitAnomalies = movingAverageAnomaly(visitsTimelineData, WINDOW_SIZE, THRESHOLD);
const eventAnomalies = movingAverageAnomaly(eventsTimelineData, WINDOW_SIZE, THRESHOLD);
const shouldSendMail = {
visitsEvents: false,
domains: false
}
for (const visit of visitAnomalies) {
const anomalyAlreadyExist = await AnomalyVisitModel.findOne({ visitDate: visit._id }, { _id: 1 });
if (anomalyAlreadyExist) continue;
await AnomalyVisitModel.create({ project_id: pid, visitDate: visit._id, created_at: Date.now() });
shouldSendMail.visitsEvents = true;
}
for (const event of eventAnomalies) {
const anomalyAlreadyExist = await AnomalyEventsModel.findOne({ eventDate: event._id }, { _id: 1 });
if (anomalyAlreadyExist) continue;
await AnomalyEventsModel.create({ project_id: pid, eventDate: event._id, created_at: Date.now() });
shouldSendMail.visitsEvents = true;
}
for (const website of detectedWebsites) {
const anomalyAlreadyExist = await AnomalyDomainModel.findOne({ domain: website }, { _id: 1 });
if (anomalyAlreadyExist) continue;
await AnomalyDomainModel.create({ project_id: pid, domain: website, created_at: Date.now() });
shouldSendMail.domains = true;
}
const project = await ProjectModel.findById(pid);
if (!project) return { ok: false, error: 'Cannot find project with id ' + pid.toString() }
const user = await UserModel.findById(project.owner);
if (!user) return { ok: false, error: 'Cannot find user with id ' + project.owner.toString() }
if (shouldSendMail.visitsEvents === true) {
await EmailService.sendAnomalyVisitsEventsEmail(user.email, project.name);
}
if (shouldSendMail.domains === true) {
await EmailService.sendAnomalyDomainEmail(user.email, project.name);
}
return { ok: true };
}

View File

@@ -6,7 +6,7 @@ import { RedisStreamService } from "@services/RedisStreamService";
const router = Router(); const router = Router();
const allowAnyType = () => true; const allowAnyType = () => true;
const jsonOptions = { limit: '5mb', type: allowAnyType } const jsonOptions = { limit: '25kb', type: allowAnyType }
const streamName = requireEnv('STREAM_NAME'); const streamName = requireEnv('STREAM_NAME');

View File

@@ -9,7 +9,7 @@ const app = express();
app.use(cors()); app.use(cors());
const allowAnyType = () => true; const allowAnyType = () => true;
const jsonOptions = { limit: '5mb', type: allowAnyType } const jsonOptions = { limit: '25kb', type: allowAnyType }
const streamName = requireEnv('STREAM_NAME'); const streamName = requireEnv('STREAM_NAME');

View File

@@ -0,0 +1,16 @@
import { model, Schema, Types } from 'mongoose';
export type TAnomalyDomain = {
project_id: Schema.Types.ObjectId
domain: string,
created_at: Date
}
const AnomalyDomainSchema = new Schema<TAnomalyDomain>({
project_id: { type: Types.ObjectId, required: true },
domain: { type: String, required: true },
created_at: { type: Date, required: true },
})
export const AnomalyDomainModel = model<TAnomalyDomain>('anomaly_domains', AnomalyDomainSchema);

View File

@@ -0,0 +1,16 @@
import { model, Schema, Types } from 'mongoose';
export type TAnomalyEvents = {
project_id: Schema.Types.ObjectId
eventDate: string,
created_at: Date
}
const AnomalyEventsSchema = new Schema<TAnomalyEvents>({
project_id: { type: Types.ObjectId, required: true },
eventDate: { type: String, required: true },
created_at: { type: Date, required: true },
})
export const AnomalyEventsModel = model<TAnomalyEvents>('anomaly_events', AnomalyEventsSchema);

View File

@@ -0,0 +1,16 @@
import { model, Schema, Types } from 'mongoose';
export type TAnomalyVisit = {
project_id: Schema.Types.ObjectId
visitDate: string,
created_at: Date
}
const AnomalyVisitSchema = new Schema<TAnomalyVisit>({
project_id: { type: Types.ObjectId, required: true },
visitDate: { type: String, required: true },
created_at: { type: Date, required: true },
})
export const AnomalyVisitModel = model<TAnomalyVisit>('anomaly_visits', AnomalyVisitSchema);

View File

@@ -4,6 +4,8 @@ import { LIMIT_50_EMAIL } from './email_templates/Limit50Email';
import { LIMIT_90_EMAIL } from './email_templates/Limit90Email'; import { LIMIT_90_EMAIL } from './email_templates/Limit90Email';
import { LIMIT_MAX_EMAIL } from './email_templates/LimitMaxEmail'; import { LIMIT_MAX_EMAIL } from './email_templates/LimitMaxEmail';
import { PURCHASE_EMAIL } from './email_templates/PurchaseEmail'; import { PURCHASE_EMAIL } from './email_templates/PurchaseEmail';
import { ANOMALY_VISITS_EVENTS_EMAIL } from './email_templates/AnomalyUsageEmail';
import { ANOMALY_DOMAIN_EMAIL } from './email_templates/AnomalyDomainEmail';
class EmailService { class EmailService {
@@ -99,6 +101,40 @@ class EmailService {
} }
} }
async sendAnomalyVisitsEventsEmail(target: string, projectName: string) {
try {
const sendSmtpEmail = new SendSmtpEmail();
sendSmtpEmail.subject = "🚨 Unexpected Activity Detected by our AI";
sendSmtpEmail.sender = { "name": "Litlyx", "email": "help@litlyx.com" };
sendSmtpEmail.to = [{ "email": target }];
sendSmtpEmail.htmlContent = ANOMALY_VISITS_EVENTS_EMAIL
.replace(/\[Project Name\]/, projectName)
.toString();;
await this.apiInstance.sendTransacEmail(sendSmtpEmail);
return true;
} catch (ex) {
console.error('ERROR SENDING EMAIL', ex);
return false;
}
}
async sendAnomalyDomainEmail(target: string, projectName: string) {
try {
const sendSmtpEmail = new SendSmtpEmail();
sendSmtpEmail.subject = "🚨 Anomaly detected by our AI";
sendSmtpEmail.sender = { "name": "Litlyx", "email": "help@litlyx.com" };
sendSmtpEmail.to = [{ "email": target }];
sendSmtpEmail.htmlContent = ANOMALY_DOMAIN_EMAIL
.replace(/\[Project Name\]/, projectName)
.toString();;
await this.apiInstance.sendTransacEmail(sendSmtpEmail);
return true;
} catch (ex) {
console.error('ERROR SENDING EMAIL', ex);
return false;
}
}
} }
const instance = new EmailService(); const instance = new EmailService();

View File

@@ -1,4 +1,4 @@
<!DOCTYPE html> export const ANOMALY_DOMAIN_EMAIL = `<!DOCTYPE html>
<html lang="en"> <html lang="en">
<head> <head>
<meta charset="UTF-8"> <meta charset="UTF-8">
@@ -9,17 +9,12 @@
<body style="font-family: Arial, sans-serif; line-height: 1.6; color: #333;"> <body style="font-family: Arial, sans-serif; line-height: 1.6; color: #333;">
<!-- Email Content --> <!-- Email Content -->
<h2 style="color: #D32F2F;"> Anomaly detected by our AI</h2>
<p>Dear User,</p> <p>Dear User,</p>
<p>We wanted to let you know that <strong>[Project Name]</strong> on <strong>Litlyx</strong> has an anomaly that our AI agent detected. Here is the report:</p> <p>We wanted to let you know that <strong>[Project Name]</strong> on <strong>Litlyx</strong> has an anomaly that our AI agent detected.</p>
<p><strong>Anomaly:</strong> Suspicious DNS</p> <p>You can analyze a suspicious DNS on your Litlyx dashboard. We put a symbol next to each suspicious DNS to let users know something might be wrong!</p>
<p><strong>Message:</strong> [Suspicious DNS name] is logging data in your project. Is that you?</p>
<p><strong>Date:</strong> Current date!</p>
<p>You can analyze the suspicious DNS on your Litlyx dashboard. We put a symbol next to each suspicious DNS to let users know something might be wrong!</p>
<h3>What can I do?</h3> <h3>What can I do?</h3>
@@ -45,3 +40,4 @@
</body> </body>
</html> </html>
`

View File

@@ -1,4 +1,4 @@
<!DOCTYPE html> export const ANOMALY_VISITS_EVENTS_EMAIL = `<!DOCTYPE html>
<html lang="en"> <html lang="en">
<head> <head>
<meta charset="UTF-8"> <meta charset="UTF-8">
@@ -9,7 +9,6 @@
<body style="font-family: Arial, sans-serif; line-height: 1.6; color: #333;"> <body style="font-family: Arial, sans-serif; line-height: 1.6; color: #333;">
<!-- Email Content --> <!-- Email Content -->
<h2 style="color: #D32F2F;">🚨 Unexpected Activity Detected by our AI</h2>
<p>Dear User,</p> <p>Dear User,</p>
@@ -40,3 +39,4 @@
</body> </body>
</html> </html>
`