mirror of
https://github.com/Litlyx/litlyx
synced 2025-12-10 07:48:37 +01:00
add anomaly + fix billing + add emails templates
This commit is contained in:
@@ -20,7 +20,7 @@ const { visible } = usePricingDrawer();
|
||||
|
||||
<Transition name="pdrawer">
|
||||
<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>
|
||||
</Transition>
|
||||
|
||||
|
||||
@@ -28,6 +28,7 @@ const route = useRoute();
|
||||
const props = defineProps<Props>();
|
||||
|
||||
const { isAdmin } = useUserRoles();
|
||||
const loggedUser = useLoggedUser()
|
||||
|
||||
const debugMode = process.dev;
|
||||
|
||||
@@ -101,8 +102,15 @@ function onLogout() {
|
||||
}
|
||||
|
||||
const { projects } = useProjectsList();
|
||||
const { data: guestProjects } = useGuestProjectsList()
|
||||
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", {
|
||||
headers: computed(() => {
|
||||
@@ -121,6 +129,11 @@ const isPremium = computed(() => {
|
||||
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();
|
||||
|
||||
@@ -152,14 +165,14 @@ const pricingDrawer = usePricingDrawer();
|
||||
base: 'hover:!bg-lyx-widget-lighter cursor-pointer',
|
||||
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 }">
|
||||
<div class="flex items-center gap-2">
|
||||
<div>
|
||||
<img class="h-5 bg-black rounded-full" :src="'/logo_32.png'" alt="Litlyx logo">
|
||||
</div>
|
||||
<div> {{ option.name }} </div>
|
||||
<div> {{ option.name }} {{ !isProjectMine(option.owner) ? '(Guest)' : '' }}</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -168,7 +181,10 @@ const pricingDrawer = usePricingDrawer();
|
||||
<div>
|
||||
<img class="h-5 bg-black rounded-full" :src="'/logo_32.png'" alt="Litlyx logo">
|
||||
</div>
|
||||
<div> {{ activeProject?.name || '???' }} </div>
|
||||
<div>
|
||||
{{ activeProject?.name || '-' }}
|
||||
{{ !isProjectMine(activeProject?.owner?.toString()) ? '(Guest)' : '' }}
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</USelectMenu>
|
||||
@@ -179,6 +195,7 @@ const pricingDrawer = usePricingDrawer();
|
||||
|
||||
</div>
|
||||
|
||||
|
||||
<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">
|
||||
<div><i class="fas fa-plus"></i></div>
|
||||
|
||||
@@ -106,11 +106,11 @@ const externalTooltipElement = ref<null | HTMLDivElement>(null);
|
||||
function externalTooltipHandler(context: { chart: any, tooltip: TooltipModel<'line' | 'bar'> }) {
|
||||
const { chart, tooltip } = context;
|
||||
const tooltipEl = externalTooltipElement.value;
|
||||
|
||||
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.events = ((tooltip.dataPoints.find(e=> e.datasetIndex == 2)?.raw) as any)?.r2 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.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();
|
||||
|
||||
if (!tooltipEl) return;
|
||||
@@ -118,11 +118,12 @@ function externalTooltipHandler(context: { chart: any, tooltip: TooltipModel<'li
|
||||
tooltipEl.style.opacity = '0';
|
||||
return;
|
||||
}
|
||||
const { offsetLeft: positionX, offsetTop: positionY } = chart.canvas;
|
||||
const { left: positionX, top: positionY } = chart.canvas.getBoundingClientRect();
|
||||
tooltipEl.style.opacity = '1';
|
||||
tooltipEl.style.left = positionX + tooltip.caretX + 'px';
|
||||
tooltipEl.style.top = positionY + tooltip.caretY + 'px';
|
||||
tooltipEl.style.padding = tooltip.options.padding + 'px ' + tooltip.options.padding + 'px';
|
||||
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -13,6 +13,19 @@ function copyProjectId() {
|
||||
navigator.clipboard.writeText((activeProject.value?._id || 0).toString());
|
||||
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>
|
||||
|
||||
|
||||
@@ -29,8 +42,10 @@ function copyProjectId() {
|
||||
|
||||
<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="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 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="flex gap-2">
|
||||
@@ -38,9 +53,20 @@ function copyProjectId() {
|
||||
{{ activeProject?._id || 'Loading...' }}
|
||||
</div>
|
||||
<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 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>
|
||||
</template>
|
||||
@@ -14,7 +14,6 @@ const entries: SettingsTemplateEntry[] = [
|
||||
const activeProject = useActiveProject();
|
||||
const projectNameInputVal = ref<string>(activeProject.value?.name || '');
|
||||
|
||||
|
||||
const apiKeys = ref<TApiSettings[]>([]);
|
||||
|
||||
const newApiKeyName = ref<string>('');
|
||||
@@ -145,13 +144,15 @@ function copyProjectId() {
|
||||
<template #pname>
|
||||
<div class="flex items-center gap-4">
|
||||
<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>
|
||||
</template>
|
||||
<template #api>
|
||||
<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>
|
||||
<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>
|
||||
</LyxUiButton>
|
||||
</div>
|
||||
@@ -185,8 +186,8 @@ function copyProjectId() {
|
||||
<div><i class="far fa-copy" @click="copyScript()"></i></div>
|
||||
</LyxUiCard>
|
||||
</template>
|
||||
<template #pdelete>
|
||||
<div class="flex justify-end">
|
||||
<template #pdelete >
|
||||
<div class="flex justify-end" v-if="!isGuest">
|
||||
<LyxUiButton type="danger" @click="deleteProject()">
|
||||
Delete project
|
||||
</LyxUiButton>
|
||||
|
||||
@@ -31,6 +31,7 @@ const sections: Section[] = [
|
||||
label: 'Slack support', icon: 'fab fa-slack',
|
||||
premiumOnly: true,
|
||||
action() {
|
||||
if (isGuest) return;
|
||||
if (isPremium.value === true) {
|
||||
window.open('https://join.slack.com/t/litlyx/shared_invite/zt-2q3oawn29-hZlu_fBUBlc4052Ooe3FZg', '_blank');
|
||||
} else {
|
||||
|
||||
@@ -124,7 +124,7 @@ async function deleteChat(chat_id: string) {
|
||||
|
||||
<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="w-[10rem]">
|
||||
@@ -138,7 +138,7 @@ async function deleteChat(chat_id: string) {
|
||||
</div>
|
||||
<div class="grid grid-cols-2 gap-4 mt-6" v-if="!isGuest">
|
||||
<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 }}
|
||||
</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 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 }}
|
||||
</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">
|
||||
<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()"
|
||||
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>
|
||||
</div>
|
||||
<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>
|
||||
</div>
|
||||
</div>
|
||||
@@ -194,7 +194,7 @@ async function deleteChat(chat_id: string) {
|
||||
<div :class="{
|
||||
'absolute': 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="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 @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> New chat </div>
|
||||
</div>
|
||||
@@ -228,12 +228,11 @@ async function deleteChat(chat_id: string) {
|
||||
|
||||
<div class="overflow-y-auto">
|
||||
<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())"
|
||||
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())"
|
||||
class="bg-menu px-4 py-3 w-full cursor-pointer hover:bg-menu/80 poppins rounded-lg"
|
||||
:class="{ '!bg-accent/60': chat._id.toString() === currentChatId }">
|
||||
class="py-3 w-full cursor-pointer poppins rounded-lg">
|
||||
{{ chat.title }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -29,7 +29,7 @@ export default defineEventHandler(async event => {
|
||||
|
||||
const project = await ProjectModel.findById(project_id);
|
||||
if (!project) return setResponseStatus(event, 400, 'Project not found');
|
||||
|
||||
|
||||
if (project.owner.toString() != userData.id) {
|
||||
return setResponseStatus(event, 400, 'You are not the owner');
|
||||
}
|
||||
|
||||
@@ -1,6 +0,0 @@
|
||||
|
||||
export default defineEventHandler(async event => {
|
||||
|
||||
console.log('TEST');
|
||||
return;
|
||||
});
|
||||
@@ -9,9 +9,13 @@ export default defineEventHandler(async event => {
|
||||
if (!project_id) return;
|
||||
|
||||
const user = getRequestUser(event);
|
||||
if (!user?.logged) return setResponseStatus(event, 400, 'User need to be logged');
|
||||
|
||||
const project = await getUserProjectFromId(project_id, user);
|
||||
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 { planId } = body;
|
||||
|
||||
@@ -9,9 +9,13 @@ export default defineEventHandler(async event => {
|
||||
if (!project_id) return;
|
||||
|
||||
const user = getRequestUser(event);
|
||||
if (!user?.logged) return setResponseStatus(event, 400, 'User need to be logged');
|
||||
|
||||
const project = await getUserProjectFromId(project_id, user);
|
||||
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 { planId } = body;
|
||||
|
||||
@@ -143,6 +143,7 @@ async function onPaymentSuccess(event: Event.InvoicePaidEvent) {
|
||||
EmailService.sendPurchaseEmail(user.email, project.name);
|
||||
}, 1);
|
||||
|
||||
|
||||
return { ok: true };
|
||||
|
||||
|
||||
|
||||
@@ -18,6 +18,8 @@ export default defineEventHandler(async event => {
|
||||
const project = await ProjectModel.findById(project_id);
|
||||
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 });
|
||||
if (projects == 1) return setResponseStatus(event, 400, 'Cannot delete last project');
|
||||
|
||||
|
||||
@@ -2,10 +2,17 @@ import mongoose from "mongoose";
|
||||
import { Redis } from "~/server/services/CacheService";
|
||||
import EmailService from '@services/EmailService';
|
||||
import StripeService from '~/server/services/StripeService';
|
||||
import { anomalyLoop } from "./services/AnomalyService";
|
||||
|
||||
const config = useRuntimeConfig();
|
||||
let connection: mongoose.Mongoose;
|
||||
|
||||
|
||||
let anomalyMinutesCount = 0;
|
||||
function anomalyCheck() {
|
||||
|
||||
}
|
||||
|
||||
export default async () => {
|
||||
|
||||
console.log('[SERVER] Initializing');
|
||||
@@ -37,4 +44,7 @@ export default async () => {
|
||||
|
||||
console.log('[SERVER] Completed');
|
||||
|
||||
console.log('[ANOMALY LOOP] Started');
|
||||
anomalyLoop();
|
||||
|
||||
};
|
||||
148
dashboard/server/services/AnomalyService.ts
Normal file
148
dashboard/server/services/AnomalyService.ts
Normal 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 };
|
||||
|
||||
|
||||
}
|
||||
@@ -6,7 +6,7 @@ import { RedisStreamService } from "@services/RedisStreamService";
|
||||
const router = Router();
|
||||
|
||||
const allowAnyType = () => true;
|
||||
const jsonOptions = { limit: '5mb', type: allowAnyType }
|
||||
const jsonOptions = { limit: '25kb', type: allowAnyType }
|
||||
|
||||
const streamName = requireEnv('STREAM_NAME');
|
||||
|
||||
|
||||
@@ -9,7 +9,7 @@ const app = express();
|
||||
app.use(cors());
|
||||
|
||||
const allowAnyType = () => true;
|
||||
const jsonOptions = { limit: '5mb', type: allowAnyType }
|
||||
const jsonOptions = { limit: '25kb', type: allowAnyType }
|
||||
|
||||
const streamName = requireEnv('STREAM_NAME');
|
||||
|
||||
|
||||
16
shared/schema/anomalies/AnomalyDomainSchema.ts
Normal file
16
shared/schema/anomalies/AnomalyDomainSchema.ts
Normal 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);
|
||||
16
shared/schema/anomalies/AnomalyEventsSchema.ts
Normal file
16
shared/schema/anomalies/AnomalyEventsSchema.ts
Normal 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);
|
||||
16
shared/schema/anomalies/AnomalyVisitSchema.ts
Normal file
16
shared/schema/anomalies/AnomalyVisitSchema.ts
Normal 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);
|
||||
@@ -4,6 +4,8 @@ import { LIMIT_50_EMAIL } from './email_templates/Limit50Email';
|
||||
import { LIMIT_90_EMAIL } from './email_templates/Limit90Email';
|
||||
import { LIMIT_MAX_EMAIL } from './email_templates/LimitMaxEmail';
|
||||
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 {
|
||||
@@ -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();
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
<!DOCTYPE html>
|
||||
export const ANOMALY_DOMAIN_EMAIL = `<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
@@ -9,17 +9,12 @@
|
||||
<body style="font-family: Arial, sans-serif; line-height: 1.6; color: #333;">
|
||||
|
||||
<!-- Email Content -->
|
||||
<h2 style="color: #D32F2F;">❗️ Anomaly detected by our AI</h2>
|
||||
|
||||
<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><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>
|
||||
<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>
|
||||
|
||||
<h3>What can I do?</h3>
|
||||
|
||||
@@ -45,3 +40,4 @@
|
||||
|
||||
</body>
|
||||
</html>
|
||||
`
|
||||
@@ -1,4 +1,4 @@
|
||||
<!DOCTYPE html>
|
||||
export const ANOMALY_VISITS_EVENTS_EMAIL = `<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
@@ -9,7 +9,6 @@
|
||||
<body style="font-family: Arial, sans-serif; line-height: 1.6; color: #333;">
|
||||
|
||||
<!-- Email Content -->
|
||||
<h2 style="color: #D32F2F;">🚨 Unexpected Activity Detected by our AI</h2>
|
||||
|
||||
<p>Dear User,</p>
|
||||
|
||||
@@ -40,3 +39,4 @@
|
||||
|
||||
</body>
|
||||
</html>
|
||||
`
|
||||
Reference in New Issue
Block a user