diff --git a/docs/docs/Rest Api/Application/_source/stats.yml b/docs/docs/Rest Api/Application/_source/stats.yml new file mode 100644 index 00000000..a8ddee3e --- /dev/null +++ b/docs/docs/Rest Api/Application/_source/stats.yml @@ -0,0 +1,47 @@ +openapi: 3.1.0 +info: + title: ZTNet Statistics Rest API + # version: 1.0.0 + description: | + Access the ZTNet Statistics API to get various statistics that can be used in 3rd party applications. + Available from version v0.6.4. + + This interface is subject to a rate limit of 50 requests per minute to ensure service reliability. + +servers: + - url: https://ztnet.network/api/v1 + description: ZTNet API + variables: + version: + default: v1 + description: API version + +components: + $ref: '../../_schema/security.yml#/components' + +security: + - x-ztnet-auth: [] + +paths: + /stats: + get: + summary: Returns statistics for ztnet + description: | + Returns various statistics that can be used in 3rd party applications. + Available from version v0.6.4 + operationId: getAppStats + responses: + 200: + description: An object of statistics + content: + application/json: + schema: + $ref: '../../_schema/StatsSchema.yml#/StatsResponse' + example: + $ref: '../../_example/StatsExample.yml#/StatsExample' + 401: + $ref: '../../_http_responses/Unauthorized.yml#/Unauthorized' + 429: + $ref: '../../_http_responses/RateLimitExceeded.yml#/RateLimitExceeded' + 500: + $ref: '../../_http_responses/InternalServerError.yml#/InternalServerError' \ No newline at end of file diff --git a/docs/docs/Rest Api/Organization/_source/network.yml b/docs/docs/Rest Api/Organization/_source/network.yml index f19605b1..a88e4d4e 100644 --- a/docs/docs/Rest Api/Organization/_source/network.yml +++ b/docs/docs/Rest Api/Organization/_source/network.yml @@ -1,9 +1,9 @@ openapi: 3.1.0 info: title: ZTNet Organization Network Rest API - version: 1.0.0 + # version: 1.0.0 description: | - Access the ZTNet Organization suite through our RESTful Web API, compatible with version 0.6.0 and beyond. + Access the ZTNet Organization suite through our RESTful Web API. This interface is subject to a rate limit of 50 requests per minute to ensure service reliability. diff --git a/docs/docs/Rest Api/Organization/_source/networkMember.yml b/docs/docs/Rest Api/Organization/_source/networkMember.yml index 5da860a7..61ffd38a 100644 --- a/docs/docs/Rest Api/Organization/_source/networkMember.yml +++ b/docs/docs/Rest Api/Organization/_source/networkMember.yml @@ -1,9 +1,9 @@ openapi: 3.1.0 info: - title: ZTNet Organization Member Rest API - version: 1.0.0 + title: ZTNet Organization Network Member Rest API + # version: 1.0.0 description: | - Access the ZTNet Organization suite through our RESTful Web API, compatible with version 0.6.0 and beyond. + Access the ZTNet Organization suite through our RESTful Web API. This interface is subject to a rate limit of 50 requests per minute to ensure service reliability. diff --git a/docs/docs/Rest Api/Organization/_source/organization.yml b/docs/docs/Rest Api/Organization/_source/organization.yml index cebc6144..7770f54d 100644 --- a/docs/docs/Rest Api/Organization/_source/organization.yml +++ b/docs/docs/Rest Api/Organization/_source/organization.yml @@ -1,9 +1,9 @@ openapi: 3.1.0 info: - title: ZTNet Network Rest API - version: 1.0.0 + title: ZTNet Organization Rest API + # version: 1.0.0 description: | - Access the ZTNet Organization suite through our RESTful Web API, compatible with version 0.6.0 and beyond. + Access the ZTNet Organization suite through our RESTful Web API. This interface is subject to a rate limit of 50 requests per minute to ensure service reliability. diff --git a/docs/docs/Rest Api/Organization/_source/users.yml b/docs/docs/Rest Api/Organization/_source/users.yml index 3689c1df..37c86e21 100644 --- a/docs/docs/Rest Api/Organization/_source/users.yml +++ b/docs/docs/Rest Api/Organization/_source/users.yml @@ -1,9 +1,9 @@ openapi: 3.1.0 info: title: ZTNet Organization User Rest API - version: 1.0.0 + # version: 1.0.0 description: | - The official ZTNet Organization Web API, beginning with version 0.6.0, provides public access with a rate limit of 50 requests per minute to maintain optimal service performance. + The official ZTNet Organization Web API, provides public access with a rate limit of 50 requests per minute to maintain optimal service performance. servers: - url: https://ztnet.network/api/v1 description: ZTNet API diff --git a/docs/docs/Rest Api/Personal/_source/network.yml b/docs/docs/Rest Api/Personal/_source/network.yml index b3b2575e..f13f5971 100644 --- a/docs/docs/Rest Api/Personal/_source/network.yml +++ b/docs/docs/Rest Api/Personal/_source/network.yml @@ -1,9 +1,9 @@ openapi: 3.1.0 info: title: ZTNet Network Rest API - version: 1.0.0 + # version: 1.0.0 description: | - Access the ZTNet suite through our RESTful Web API, compatible with version 0.4.0 and beyond. + Access the ZTNet suite through our RESTful Web API. This interface is subject to a rate limit of 50 requests per minute to ensure service reliability. diff --git a/docs/docs/Rest Api/Personal/_source/networkMember.yml b/docs/docs/Rest Api/Personal/_source/networkMember.yml index 81fd6936..e539d1bc 100644 --- a/docs/docs/Rest Api/Personal/_source/networkMember.yml +++ b/docs/docs/Rest Api/Personal/_source/networkMember.yml @@ -1,9 +1,9 @@ openapi: 3.1.0 info: - title: ZTNet Network Rest API - version: 1.0.0 + title: ZTNet Network Member Rest API + # version: 1.0.0 description: | - Access the ZTNet suite through our RESTful Web API, compatible with version 0.4.0 and beyond. + Access the ZTNet suite through our RESTful Web API. This interface is subject to a rate limit of 50 requests per minute to ensure service reliability. diff --git a/docs/docs/Rest Api/Personal/_source/user.yml b/docs/docs/Rest Api/Personal/_source/user.yml index a75b3a80..55fa7002 100644 --- a/docs/docs/Rest Api/Personal/_source/user.yml +++ b/docs/docs/Rest Api/Personal/_source/user.yml @@ -1,9 +1,12 @@ openapi: 3.1.0 info: title: ZTNet User Rest API - version: 1.0.0 + # version: 1.0.0 description: | - The official ZTNet Web API, beginning with version 0.4.0, provides public access with a rate limit of 50 requests per minute to maintain optimal service performance. + Access the ZTNet suite through our RESTful Web API. + + This interface is subject to a rate limit of 50 requests per minute to ensure service reliability. + servers: - url: https://ztnet.network/api/v1 description: ZTNet API diff --git a/docs/docs/Rest Api/_example/StatsExample.yml b/docs/docs/Rest Api/_example/StatsExample.yml new file mode 100644 index 00000000..93ed35c6 --- /dev/null +++ b/docs/docs/Rest Api/_example/StatsExample.yml @@ -0,0 +1,11 @@ +StatsExample: + - users: 1 + networks: 8 + networkMembers: 7 + appVersion: "v0.6.3" + loginsLast24h: 1 + pendingUserInvitations: 0 + activeWebhooks: 0 + ztnetUptime: 735.491790795 + registrationEnabled: false + hasPrivatRoot: true \ No newline at end of file diff --git a/docs/docs/Rest Api/_schema/StatsSchema.yml b/docs/docs/Rest Api/_schema/StatsSchema.yml new file mode 100644 index 00000000..f1a1f69c --- /dev/null +++ b/docs/docs/Rest Api/_schema/StatsSchema.yml @@ -0,0 +1,34 @@ +StatsResponse: + type: object + properties: + users: + type: integer + description: The number of users. + networks: + type: integer + description: The number of networks. + networkMembers: + type: integer + description: The total number of network members. + appVersion: + type: string + description: The current version of the ztnet application. + loginsLast24h: + type: integer + description: The number of logins in the last 24 hours. + pendingUserInvitations: + type: integer + description: The number of pending user invitations. + activeWebhooks: + type: integer + description: The number of active webhooks. + ztnetUptime: + type: number + format: float + description: The uptime of ztnet in minutes. + registrationEnabled: + type: boolean + description: Indicates if registration is enabled. + hasPrivatRoot: + type: boolean + description: Indicates if a private root is currently in use. \ No newline at end of file diff --git a/docs/docusaurus.config.js b/docs/docusaurus.config.js index 49c473cf..aa64558a 100644 --- a/docs/docusaurus.config.js +++ b/docs/docusaurus.config.js @@ -40,6 +40,13 @@ const config = { id: "api", // plugin id docsPluginId: "classic", // id of plugin-content-docs or preset for rendering docs config: { + stats:{ + specPath: "docs/Rest Api/Application/_source/stats.yml", // path to OpenAPI spec, URLs supported + outputDir: "docs/Rest Api/Application/Statistics", // output directory for generated files + sidebarOptions: { // optional, instructs plugin to generate sidebar.js + groupPathsBy: "tag", // group sidebar items by operation "tag" + }, + }, // Personal Controller personal_user: { specPath: "docs/Rest Api/Personal/_source/user.yml", // path to OpenAPI spec, URLs supported diff --git a/src/pages/api/__tests__/v1/application/statistic.test.ts b/src/pages/api/__tests__/v1/application/statistic.test.ts new file mode 100644 index 00000000..b6d1b71f --- /dev/null +++ b/src/pages/api/__tests__/v1/application/statistic.test.ts @@ -0,0 +1,44 @@ +import apiStatsHandler from "~/pages/api/v1/stats/index"; +import { NextApiRequest, NextApiResponse } from "next"; + +describe("/api/stats", () => { + it("should allow only GET method", async () => { + const methods = ["DELETE", "POST", "PUT", "PATCH", "OPTIONS", "HEAD"]; + const req = {} as NextApiRequest; + const res = { + status: jest.fn().mockReturnThis(), + end: jest.fn(), + json: jest.fn().mockReturnThis(), + setHeader: jest.fn(), // Mock `setHeader` rate limiter uses it + } as unknown as NextApiResponse; + + for (const method of methods) { + req.method = method; + await apiStatsHandler(req, res); + + expect(res.status).toHaveBeenCalledWith(405); + + // expect json to be called with text "Method Not Allowed" + expect(res.json).toHaveBeenCalledWith( + expect.objectContaining({ error: "Method Not Allowed" }), + ); + } + }); + + it("should respond 401 when invalid API key for GET", async () => { + const req = { + method: "GET", + headers: { "x-ztnet-auth": "invalidApiKey" }, + } as unknown as NextApiRequest; + const res = { + status: jest.fn().mockReturnThis(), + end: jest.fn(), + json: jest.fn().mockReturnThis(), + setHeader: jest.fn(), // Mock `setHeader` rate limiter uses it + } as unknown as NextApiResponse; + + await apiStatsHandler(req, res); + + expect(res.status).toHaveBeenCalledWith(401); + }); +}); diff --git a/src/pages/api/v1/stats/index.ts b/src/pages/api/v1/stats/index.ts new file mode 100644 index 00000000..32e4950f --- /dev/null +++ b/src/pages/api/v1/stats/index.ts @@ -0,0 +1,106 @@ +import type { NextApiRequest, NextApiResponse } from "next"; +import { prisma } from "~/server/db"; +import { AuthorizationType } from "~/types/apiTypes"; +import { decryptAndVerifyToken } from "~/utils/encryption"; +import { handleApiErrors } from "~/utils/errors"; +import { globalSiteVersion } from "~/utils/global"; +import rateLimit from "~/utils/rateLimit"; + +// Number of allowed requests per minute +const limiter = rateLimit({ + interval: 60 * 1000, // 60 seconds + uniqueTokenPerInterval: 500, // Max 500 users per second +}); + +const REQUEST_PR_MINUTE = 50; + +export default async function createStatsHandler( + req: NextApiRequest, + res: NextApiResponse, +) { + try { + await limiter.check(res, REQUEST_PR_MINUTE, "CREATE_USER_CACHE_TOKEN"); // 10 requests per minute + } catch { + return res.status(429).json({ error: "Rate limit exceeded" }); + } + + // create a switch based on the HTTP method + switch (req.method) { + case "GET": + await GET_stats(req, res); + break; + default: // Method Not Allowed + res.status(405).json({ error: "Method Not Allowed" }); + break; + } +} + +export const GET_stats = async (req: NextApiRequest, res: NextApiResponse) => { + const apiKey = req.headers["x-ztnet-auth"] as string; + + const requireAdmin = true; + + try { + await decryptAndVerifyToken({ + apiKey, + apiAuthorizationType: AuthorizationType.PERSONAL, + requireAdmin, + }); + + // get number of users + const users = await prisma.user.count(); + + // get number of networks + const networks = await prisma.network.count(); + + // get number of members + const networkMembers = await prisma.network_members.count(); + + // get application version + const appVersion = globalSiteVersion; + + // get logins last 24 hours + const loginsLast24h = await prisma.user.count({ + where: { + lastLogin: { + gte: new Date(new Date().getTime() - 24 * 60 * 60 * 1000), + }, + }, + }); + + // get UserInvitation + const pendingUserInvitations = await prisma.userInvitation.count(); + + // get pending Webhook + const activeWebhooks = await prisma.webhook.count(); + + // get uptime + const ztnetUptime = process.uptime(); + + // get if customPlanetUsed is used + const rootServer = await prisma.planet.count(); + + // get global options + const globalOptions = await prisma.globalOptions.findFirst({ + where: { + id: 1, + }, + }); + + // return all json + return res.status(200).json({ + users, + networks, + networkMembers, + appVersion, + loginsLast24h, + pendingUserInvitations, + activeWebhooks, + ztnetUptime, + registrationEnabled: globalOptions?.enableRegistration || false, + hasPrivatRoot: !!rootServer, + }); + } catch (cause) { + return handleApiErrors(cause, res); + } +}; diff --git a/src/server/auth.ts b/src/server/auth.ts index 87ea7b6e..b010a5ab 100644 --- a/src/server/auth.ts +++ b/src/server/auth.ts @@ -175,6 +175,24 @@ export const authOptions: NextAuthOptions = { */ async signIn({ user, account }) { if (account.provider === "credentials") { + // Check if the user already exists + const existingUser = await prisma.user.findUnique({ + where: { + email: user.email, + }, + }); + + if (existingUser) { + // User exists, update last login or other fields as necessary + await prisma.user.update({ + where: { + id: existingUser.id, + }, + data: { + lastLogin: new Date().toISOString(), + }, + }); + } return true; } if (account.provider === "oauth") { diff --git a/src/utils/global.ts b/src/utils/global.ts index 7ccede61..f720aa70 100644 --- a/src/utils/global.ts +++ b/src/utils/global.ts @@ -5,4 +5,4 @@ import { env } from "~/env.mjs"; // If it is not set, use the default value "ZTnet". export const globalSiteTitle = process.env.NEXT_PUBLIC_SITE_NAME || "ZTNET"; -export const globalSiteVersion = env.NEXT_PUBLIC_APP_VERSION || null; +export const globalSiteVersion = env.NEXT_PUBLIC_APP_VERSION || "development";