diff --git a/fluxer_api/src/api/geolocation/GeolocationController.ts b/fluxer_api/src/api/geolocation/GeolocationController.ts index 58c1c266e..652293e96 100644 --- a/fluxer_api/src/api/geolocation/GeolocationController.ts +++ b/fluxer_api/src/api/geolocation/GeolocationController.ts @@ -1,24 +1,40 @@ // SPDX-License-Identifier: AGPL-3.0-or-later +import {GeolocationResponse} from '@fluxer/schema/src/domains/geolocation/GeolocationSchemas'; import {resolveClientGeoip} from '@pkgs/geoip/src/ResolveClientGeoip'; import type {Hono} from 'hono'; import {Config} from '../Config'; import {RateLimitMiddleware} from '../middleware/RateLimitMiddleware'; +import {OpenAPI} from '../middleware/ResponseTypeMiddleware'; import {RateLimitConfigs} from '../RateLimitConfig'; import type {HonoEnv} from '../types/HonoEnv'; export function GeolocationController(app: Hono): void { - app.get('/ip', RateLimitMiddleware(RateLimitConfigs.IP_GEO_LOOKUP), async (ctx) => { - ctx.header('Access-Control-Allow-Origin', '*'); - ctx.header('Access-Control-Allow-Headers', 'Content-Type'); - ctx.header('Access-Control-Allow-Methods', 'GET, OPTIONS'); - const response = await resolveClientGeoip(ctx.req.raw, { - maxmindDbPath: Config.geoip.maxmindDbPath, - trustClientIpHeader: Config.proxy.trust_client_ip_header, - clientIpHeaderName: Config.proxy.client_ip_header, - }); - return ctx.json(response); - }); + app.get( + '/ip', + RateLimitMiddleware(RateLimitConfigs.IP_GEO_LOOKUP), + OpenAPI({ + operationId: 'get_client_geolocation', + summary: 'Get client geolocation', + responseSchema: GeolocationResponse, + statusCode: 200, + security: [], + tags: ['Geolocation'], + description: + 'Resolves the approximate location of the requesting client from its IP address, together with the locations where age restricted content is gated or unavailable.', + }), + async (ctx) => { + ctx.header('Access-Control-Allow-Origin', '*'); + ctx.header('Access-Control-Allow-Headers', 'Content-Type'); + ctx.header('Access-Control-Allow-Methods', 'GET, OPTIONS'); + const response = await resolveClientGeoip(ctx.req.raw, { + maxmindDbPath: Config.geoip.maxmindDbPath, + trustClientIpHeader: Config.proxy.trust_client_ip_header, + clientIpHeaderName: Config.proxy.client_ip_header, + }); + return ctx.json(response); + }, + ); app.options('/ip', (ctx) => { ctx.header('Access-Control-Allow-Origin', '*'); ctx.header('Access-Control-Allow-Headers', 'Content-Type'); diff --git a/fluxer_api/src/api/openapi/openapi.json b/fluxer_api/src/api/openapi/openapi.json index 47ad5b025..599ac1006 100644 --- a/fluxer_api/src/api/openapi/openapi.json +++ b/fluxer_api/src/api/openapi/openapi.json @@ -10800,6 +10800,64 @@ ] } }, + "/ip": { + "get": { + "operationId": "get_client_geolocation", + "summary": "Get client geolocation", + "tags": ["Geolocation"], + "responses": { + "200": { + "description": "Success", + "content": {"application/json": {"schema": {"$ref": "#/components/schemas/GeolocationResponse"}}} + }, + "400": { + "description": "Bad Request - The request was malformed or contained invalid data", + "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}} + }, + "429": { + "description": "Too Many Requests - You are being rate limited", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "code": {"type": "string", "enum": ["RATE_LIMITED"]}, + "message": {"type": "string"}, + "retry_after": {"type": "number", "description": "Seconds to wait before retrying"}, + "global": {"type": "boolean", "description": "Whether this is a global rate limit"} + }, + "required": ["code", "message", "retry_after"] + } + } + }, + "headers": { + "Retry-After": { + "description": "Number of seconds to wait before retrying (only on 429)", + "schema": {"type": "integer"} + }, + "X-RateLimit-Limit": { + "description": "The number of requests that can be made in the current window", + "schema": {"type": "integer"} + }, + "X-RateLimit-Remaining": { + "description": "The number of remaining requests that can be made", + "schema": {"type": "integer"} + }, + "X-RateLimit-Reset": { + "description": "Unix timestamp when the rate limit resets", + "schema": {"type": "integer"} + } + } + }, + "500": { + "description": "Internal Server Error - An unexpected error occurred", + "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}} + } + }, + "x-mint": {"metadata": {"title": "Get client geolocation"}}, + "description": "Resolves the approximate location of the requesting client from its IP address, together with the locations where age restricted content is gated or unavailable." + } + }, "/oauth2/@me": { "get": { "operationId": "get_current_user_oauth2", @@ -31926,6 +31984,49 @@ }, "required": ["code", "type", "pack", "temporary"] }, + "GeolocationResponse": { + "type": "object", + "properties": { + "countryCode": { + "anyOf": [{"type": "string"}, {"type": "null"}], + "description": "ISO 3166-1 alpha-2 country code resolved for the client, or null when it cannot be resolved" + }, + "regionCode": { + "anyOf": [{"type": "string"}, {"type": "null"}], + "description": "ISO 3166-2 subdivision code resolved for the client, or null when it cannot be resolved" + }, + "latitude": { + "anyOf": [{"type": "string"}, {"type": "null"}], + "description": "Approximate latitude of the client, or null when it cannot be resolved" + }, + "longitude": { + "anyOf": [{"type": "string"}, {"type": "null"}], + "description": "Approximate longitude of the client, or null when it cannot be resolved" + }, + "ageRestrictedGeos": { + "type": "array", + "items": {"$ref": "#/components/schemas/GeoEntry"}, + "description": "Locations where age restricted content requires an age check" + }, + "ageBlockedGeos": { + "type": "array", + "items": {"$ref": "#/components/schemas/GeoEntry"}, + "description": "Locations where age restricted content is unavailable" + } + }, + "required": ["countryCode", "regionCode", "latitude", "longitude", "ageRestrictedGeos", "ageBlockedGeos"] + }, + "GeoEntry": { + "type": "object", + "properties": { + "countryCode": {"type": "string", "description": "ISO 3166-1 alpha-2 country code"}, + "regionCode": { + "anyOf": [{"type": "string"}, {"type": "null"}], + "description": "ISO 3166-2 subdivision code, or null when the entry covers the whole country" + } + }, + "required": ["countryCode", "regionCode"] + }, "OAuth2MeResponse": { "type": "object", "properties": { @@ -38163,6 +38264,7 @@ {"name": "Connections"}, {"name": "Messages"}, {"name": "Donations"}, + {"name": "Geolocation"}, {"name": "Discovery"}, {"name": "Emojis"}, {"name": "Stickers"}, diff --git a/packages/schema/src/domains/geolocation/GeolocationSchemas.ts b/packages/schema/src/domains/geolocation/GeolocationSchemas.ts new file mode 100644 index 000000000..e41407443 --- /dev/null +++ b/packages/schema/src/domains/geolocation/GeolocationSchemas.ts @@ -0,0 +1,30 @@ +// SPDX-License-Identifier: AGPL-3.0-or-later + +import {z} from 'zod'; + +export const GeoEntry = z.object({ + countryCode: z.string().describe('ISO 3166-1 alpha-2 country code'), + regionCode: z + .string() + .nullable() + .describe('ISO 3166-2 subdivision code, or null when the entry covers the whole country'), +}); + +export type GeoEntry = z.infer; + +export const GeolocationResponse = z.object({ + countryCode: z + .string() + .nullable() + .describe('ISO 3166-1 alpha-2 country code resolved for the client, or null when it cannot be resolved'), + regionCode: z + .string() + .nullable() + .describe('ISO 3166-2 subdivision code resolved for the client, or null when it cannot be resolved'), + latitude: z.string().nullable().describe('Approximate latitude of the client, or null when it cannot be resolved'), + longitude: z.string().nullable().describe('Approximate longitude of the client, or null when it cannot be resolved'), + ageRestrictedGeos: z.array(GeoEntry).describe('Locations where age restricted content requires an age check'), + ageBlockedGeos: z.array(GeoEntry).describe('Locations where age restricted content is unavailable'), +}); + +export type GeolocationResponse = z.infer;