diff --git a/apps/client/package.json b/apps/client/package.json index b757f932..1a1bce95 100644 --- a/apps/client/package.json +++ b/apps/client/package.json @@ -47,6 +47,7 @@ "react-helmet-async": "^2.0.5", "react-i18next": "^15.0.1", "react-router-dom": "^7.0.1", + "semver": "^7.7.1", "socket.io-client": "^4.8.1", "tippy.js": "^6.3.7", "tiptap-extension-global-drag-handle": "^0.1.18", diff --git a/apps/client/public/locales/en-US/translation.json b/apps/client/public/locales/en-US/translation.json index b69d0b28..7f809a4e 100644 --- a/apps/client/public/locales/en-US/translation.json +++ b/apps/client/public/locales/en-US/translation.json @@ -351,5 +351,7 @@ "Created at: {{time}}": "Created at: {{time}}", "Edited by {{name}} {{time}}": "Edited by {{name}} {{time}}", "Word count: {{wordCount}}": "Word count: {{wordCount}}", - "Character count: {{characterCount}}": "Character count: {{characterCount}}" + "Character count: {{characterCount}}": "Character count: {{characterCount}}", + "New update": "New update", + "{{latestVersion}} is available": "{{latestVersion}} is available" } diff --git a/apps/client/src/components/settings/app-version.tsx b/apps/client/src/components/settings/app-version.tsx new file mode 100644 index 00000000..f5097d51 --- /dev/null +++ b/apps/client/src/components/settings/app-version.tsx @@ -0,0 +1,53 @@ +import { useAppVersion } from "@/features/workspace/queries/workspace-query.ts"; +import { isCloud } from "@/lib/config.ts"; +import classes from "@/components/settings/settings.module.css"; +import { Indicator, Text, Tooltip } from "@mantine/core"; +import React from "react"; +import semverGt from "semver/functions/gt"; +import { useTranslation } from "react-i18next"; + +export default function AppVersion() { + const { t } = useTranslation(); + const { data: appVersion } = useAppVersion(!isCloud()); + let hasUpdate = false; + try { + hasUpdate = + appVersion && + parseFloat(appVersion.latestVersion) > 0 && + semverGt(appVersion.latestVersion, appVersion.currentVersion); + } catch (err) { + console.error(err); + } + + return ( +
+ + + + v{APP_VERSION} + + + +
+ ); +} diff --git a/apps/client/src/components/settings/settings-sidebar.tsx b/apps/client/src/components/settings/settings-sidebar.tsx index 551758ce..16d94c64 100644 --- a/apps/client/src/components/settings/settings-sidebar.tsx +++ b/apps/client/src/components/settings/settings-sidebar.tsx @@ -27,6 +27,7 @@ import { prefetchSsoProviders, prefetchWorkspaceMembers, } from "@/components/settings/settings-queries.tsx"; +import AppVersion from "@/components/settings/app-version.tsx"; interface DataItem { label: string; @@ -205,19 +206,8 @@ export default function SettingsSidebar() { {menuItems} - {!isCloud() && ( -
- - v{APP_VERSION} - -
- )} + + {!isCloud() && } {isCloud() && (
diff --git a/apps/client/src/features/workspace/queries/workspace-query.ts b/apps/client/src/features/workspace/queries/workspace-query.ts index 81901381..1d1f1c73 100644 --- a/apps/client/src/features/workspace/queries/workspace-query.ts +++ b/apps/client/src/features/workspace/queries/workspace-query.ts @@ -15,6 +15,7 @@ import { revokeInvitation, getWorkspace, getWorkspacePublicData, + getAppVersion, } from "@/features/workspace/services/workspace-service"; import { IPagination, QueryParams } from "@/lib/types.ts"; import { notifications } from "@mantine/notifications"; @@ -22,6 +23,7 @@ import { ICreateInvite, IInvitation, IPublicWorkspace, + IVersion, IWorkspace, } from "@/features/workspace/types/workspace.types.ts"; import { IUser } from "@/features/user/types/user.types.ts"; @@ -153,3 +155,15 @@ export function useGetInvitationQuery( enabled: !!invitationId, }); } + +export function useAppVersion( + isEnabled: boolean, +): UseQueryResult { + return useQuery({ + queryKey: ["version"], + queryFn: () => getAppVersion(), + staleTime: 60 * 60 * 1000, // 1 hr + enabled: isEnabled, + refetchOnMount: true, + }); +} diff --git a/apps/client/src/features/workspace/services/workspace-service.ts b/apps/client/src/features/workspace/services/workspace-service.ts index 4f099750..ecaae58a 100644 --- a/apps/client/src/features/workspace/services/workspace-service.ts +++ b/apps/client/src/features/workspace/services/workspace-service.ts @@ -7,6 +7,7 @@ import { IAcceptInvite, IPublicWorkspace, IInvitationLink, + IVersion, } from "../types/workspace.types"; import { IPagination, QueryParams } from "@/lib/types.ts"; import { ISetupWorkspace } from "@/features/auth/types/auth.types.ts"; @@ -73,7 +74,6 @@ export async function getInviteLink(data: { export async function resendInvitation(data: { invitationId: string; }): Promise { - console.log(data); await api.post("/workspace/invites/resend", data); } @@ -97,6 +97,11 @@ export async function createWorkspace( return req.data; } +export async function getAppVersion(): Promise { + const req = await api.post("/version"); + return req.data; +} + export async function uploadLogo(file: File) { const formData = new FormData(); formData.append("type", "workspace-logo"); diff --git a/apps/client/src/features/workspace/types/workspace.types.ts b/apps/client/src/features/workspace/types/workspace.types.ts index 478f2a69..4112ee5c 100644 --- a/apps/client/src/features/workspace/types/workspace.types.ts +++ b/apps/client/src/features/workspace/types/workspace.types.ts @@ -57,3 +57,9 @@ export interface IPublicWorkspace { authProviders: IAuthProvider[]; hasLicenseKey?: boolean; } + +export interface IVersion { + currentVersion: string; + latestVersion: string; + releaseUrl: string; +} diff --git a/apps/server/src/integrations/security/security.module.ts b/apps/server/src/integrations/security/security.module.ts index 1a65b14e..5acb53b2 100644 --- a/apps/server/src/integrations/security/security.module.ts +++ b/apps/server/src/integrations/security/security.module.ts @@ -1,7 +1,10 @@ import { Module } from '@nestjs/common'; import { RobotsTxtController } from './robots.txt.controller'; +import { VersionController } from './version.controller'; +import { VersionService } from './version.service'; @Module({ - controllers: [RobotsTxtController], + controllers: [RobotsTxtController, VersionController], + providers: [VersionService], }) export class SecurityModule {} diff --git a/apps/server/src/integrations/security/version.controller.ts b/apps/server/src/integrations/security/version.controller.ts new file mode 100644 index 00000000..ac2cd47d --- /dev/null +++ b/apps/server/src/integrations/security/version.controller.ts @@ -0,0 +1,27 @@ +import { + Controller, + HttpCode, + HttpStatus, + NotFoundException, + Post, + UseGuards, +} from '@nestjs/common'; +import { VersionService } from './version.service'; +import { JwtAuthGuard } from '../../common/guards/jwt-auth.guard'; +import { EnvironmentService } from '../environment/environment.service'; + +@UseGuards(JwtAuthGuard) +@Controller('version') +export class VersionController { + constructor( + private readonly versionService: VersionService, + private readonly environmentService: EnvironmentService, + ) {} + + @HttpCode(HttpStatus.OK) + @Post() + async getVersion() { + if (this.environmentService.isCloud()) throw new NotFoundException(); + return this.versionService.getVersion(); + } +} diff --git a/apps/server/src/integrations/security/version.service.ts b/apps/server/src/integrations/security/version.service.ts new file mode 100644 index 00000000..2ceee86e --- /dev/null +++ b/apps/server/src/integrations/security/version.service.ts @@ -0,0 +1,28 @@ +import { Injectable } from '@nestjs/common'; +// eslint-disable-next-line @typescript-eslint/no-require-imports +const packageJson = require('./../../../package.json'); + +@Injectable() +export class VersionService { + constructor() {} + + async getVersion() { + const url = `https://api.github.com/repos/docmost/docmost/releases/latest`; + + let latestVersion = 0; + try { + const response = await fetch(url); + if (!response.ok) return; + const data = await response.json(); + latestVersion = data?.tag_name?.replace('v', ''); + } catch (err) { + /* empty */ + } + + return { + currentVersion: packageJson?.version, + latestVersion: latestVersion, + releaseUrl: 'https://github.com/docmost/docmost/releases', + }; + } +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 74cc3120..d81c0246 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -311,6 +311,9 @@ importers: react-router-dom: specifier: ^7.0.1 version: 7.0.1(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + semver: + specifier: ^7.7.1 + version: 7.7.1 socket.io-client: specifier: ^4.8.1 version: 4.8.1 @@ -8056,6 +8059,11 @@ packages: engines: {node: '>=10'} hasBin: true + semver@7.7.1: + resolution: {integrity: sha512-hlq8tAfn0m/61p4BVRcPzIGr6LKiMwo4VM6dGi6pt4qcRkmNzTcWq6eCEjEh+qXjkMDvPlOFFSGwQjoEa6gyMA==} + engines: {node: '>=10'} + hasBin: true + serialize-javascript@6.0.2: resolution: {integrity: sha512-Saa1xPByTTq2gdeFZYLLo+RFE35NHZkAbqZeWNd3BpzppeVisAqpDjcp8dyf6uIvEqJRd46jemmyA4iFIeVk8g==} @@ -13563,7 +13571,7 @@ snapshots: fast-glob: 3.3.2 is-glob: 4.0.3 minimatch: 9.0.4 - semver: 7.6.3 + semver: 7.7.1 ts-api-utils: 1.3.0(typescript@5.7.2) optionalDependencies: typescript: 5.7.2 @@ -18024,6 +18032,8 @@ snapshots: semver@7.6.3: {} + semver@7.7.1: {} + serialize-javascript@6.0.2: dependencies: randombytes: 2.1.0