mirror of
https://github.com/Crosstalk-Solutions/project-nomad.git
synced 2026-10-16 05:40:23 +08:00
The UI enforces 500 characters but the validator did not, so the API accepted up to the MySQL TEXT ceiling. The API needs no authentication by design, so an uncapped free-text column reachable from any page a user visits is a cheap way to bloat the database. Matches the limit the form already shows. Found in a security review of the release PRs. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01F8Mgg1vd2eKSs8StZiuyMV
265 lines
8.9 KiB
TypeScript
265 lines
8.9 KiB
TypeScript
import { MapService } from '#services/map_service'
|
|
import MapMarker from '#models/map_marker'
|
|
import {
|
|
assertNotPrivateUrl,
|
|
downloadCollectionValidator,
|
|
filenameParamValidator,
|
|
mapExtractPreflightValidator,
|
|
mapExtractValidator,
|
|
remoteDownloadValidator,
|
|
remoteDownloadValidatorOptional,
|
|
} from '#validators/common'
|
|
import { inject } from '@adonisjs/core'
|
|
import type { HttpContext } from '@adonisjs/core/http'
|
|
import vine from '@vinejs/vine'
|
|
|
|
@inject()
|
|
export default class MapsController {
|
|
constructor(private mapService: MapService) {}
|
|
|
|
async index({ inertia }: HttpContext) {
|
|
const baseAssetsCheck = await this.mapService.ensureBaseAssets()
|
|
const [regionFiles, worldBasemapExists] = await Promise.all([
|
|
this.mapService.listRegions(),
|
|
this.mapService.checkWorldBasemapExists(),
|
|
])
|
|
return inertia.render('maps', {
|
|
maps: {
|
|
baseAssetsExist: baseAssetsCheck,
|
|
worldBasemapExists,
|
|
regionFiles: regionFiles.files,
|
|
},
|
|
})
|
|
}
|
|
|
|
async downloadBaseAssets({ request }: HttpContext) {
|
|
const payload = await request.validateUsing(remoteDownloadValidatorOptional)
|
|
if (payload.url) assertNotPrivateUrl(payload.url)
|
|
await this.mapService.downloadBaseAssets(payload.url)
|
|
return { success: true }
|
|
}
|
|
|
|
async setupWorldBasemap({ response }: HttpContext) {
|
|
try {
|
|
const ready = await this.mapService.provisionWorldBasemap()
|
|
if (!ready) {
|
|
return response.status(500).send({
|
|
message:
|
|
'Could not download the base map. Please connect this NOMAD to the internet and try again.',
|
|
})
|
|
}
|
|
return { success: true }
|
|
} catch {
|
|
return response.status(500).send({
|
|
message:
|
|
'Could not download the base map. Please connect this NOMAD to the internet and try again.',
|
|
})
|
|
}
|
|
}
|
|
|
|
async downloadRemote({ request }: HttpContext) {
|
|
const payload = await request.validateUsing(remoteDownloadValidator)
|
|
assertNotPrivateUrl(payload.url)
|
|
const filename = await this.mapService.downloadRemote(payload.url)
|
|
return {
|
|
message: 'Download started successfully',
|
|
filename,
|
|
url: payload.url,
|
|
}
|
|
}
|
|
|
|
async downloadCollection({ request }: HttpContext) {
|
|
const payload = await request.validateUsing(downloadCollectionValidator)
|
|
const resources = await this.mapService.downloadCollection(payload.slug)
|
|
return {
|
|
message: 'Collection download started successfully',
|
|
slug: payload.slug,
|
|
resources,
|
|
}
|
|
}
|
|
|
|
// For providing a "preflight" check in the UI before actually starting a background download
|
|
async downloadRemotePreflight({ request }: HttpContext) {
|
|
const payload = await request.validateUsing(remoteDownloadValidator)
|
|
assertNotPrivateUrl(payload.url)
|
|
const info = await this.mapService.downloadRemotePreflight(payload.url)
|
|
return info
|
|
}
|
|
|
|
async fetchLatestCollections({}: HttpContext) {
|
|
const success = await this.mapService.fetchLatestCollections()
|
|
return { success }
|
|
}
|
|
|
|
async listCuratedCollections({}: HttpContext) {
|
|
return await this.mapService.listCuratedCollections()
|
|
}
|
|
|
|
async listRegions({}: HttpContext) {
|
|
return await this.mapService.listRegions()
|
|
}
|
|
|
|
async globalMapInfo({}: HttpContext) {
|
|
return await this.mapService.getGlobalMapInfo()
|
|
}
|
|
|
|
async downloadGlobalMap({}: HttpContext) {
|
|
const result = await this.mapService.downloadGlobalMap()
|
|
return {
|
|
message: 'Download started successfully',
|
|
...result,
|
|
}
|
|
}
|
|
|
|
async listCountries({}: HttpContext) {
|
|
return { countries: await this.mapService.listCountries() }
|
|
}
|
|
|
|
async listCountryGroups({}: HttpContext) {
|
|
return { groups: await this.mapService.listCountryGroups() }
|
|
}
|
|
|
|
async extractPreflight({ request }: HttpContext) {
|
|
const payload = await request.validateUsing(mapExtractPreflightValidator)
|
|
return await this.mapService.extractPreflight(payload)
|
|
}
|
|
|
|
async extractRegion({ request }: HttpContext) {
|
|
const payload = await request.validateUsing(mapExtractValidator)
|
|
const result = await this.mapService.extractRegion(payload)
|
|
return {
|
|
message: 'Extract started successfully',
|
|
...result,
|
|
}
|
|
}
|
|
|
|
async styles({ request, response }: HttpContext) {
|
|
// Automatically ensure base assets are present before generating styles
|
|
const baseAssetsExist = await this.mapService.ensureBaseAssets()
|
|
if (!baseAssetsExist) {
|
|
return response.status(500).send({
|
|
message:
|
|
'Base map assets are missing and could not be downloaded. Please check your connection and try again.',
|
|
})
|
|
}
|
|
|
|
const forwardedProto = request.headers()['x-forwarded-proto'];
|
|
|
|
const protocol: string = forwardedProto
|
|
? (typeof forwardedProto === 'string' ? forwardedProto : request.protocol())
|
|
: request.protocol();
|
|
|
|
const styles = await this.mapService.generateStylesJSON(request.host(), protocol)
|
|
return response.json(styles)
|
|
}
|
|
|
|
async delete({ request, response }: HttpContext) {
|
|
const payload = await request.validateUsing(filenameParamValidator)
|
|
|
|
try {
|
|
await this.mapService.delete(payload.params.filename)
|
|
} catch (error) {
|
|
if (error.message === 'not_found') {
|
|
return response.status(404).send({
|
|
message: `Map file with key ${payload.params.filename} not found`,
|
|
})
|
|
}
|
|
throw error // Re-throw any other errors and let the global error handler catch
|
|
}
|
|
|
|
return {
|
|
message: 'Map file deleted successfully',
|
|
}
|
|
}
|
|
|
|
// --- Map Markers ---
|
|
|
|
async listMarkers({}: HttpContext) {
|
|
return await MapMarker.query().orderBy('created_at', 'asc')
|
|
}
|
|
|
|
async createMarker({ request }: HttpContext) {
|
|
const payload = await request.validateUsing(
|
|
vine.compile(
|
|
vine.object({
|
|
name: vine.string().trim().minLength(1).maxLength(255),
|
|
longitude: vine.number().min(-180).max(180),
|
|
latitude: vine.number().min(-90).max(90),
|
|
color: vine.string().trim().maxLength(20).optional(),
|
|
custom_color: vine.string().trim().maxLength(7).nullable().optional(),
|
|
icon: vine.string().trim().maxLength(50).nullable().optional(),
|
|
icon_color: vine.string().trim().maxLength(7).nullable().optional(),
|
|
visible: vine.boolean().optional(),
|
|
notes: vine.string().trim().maxLength(500).nullable().optional(),
|
|
marker_type: vine.string().trim().maxLength(20).optional(),
|
|
})
|
|
)
|
|
)
|
|
|
|
const marker = await MapMarker.create({
|
|
name: payload.name,
|
|
longitude: payload.longitude,
|
|
latitude: payload.latitude,
|
|
color: payload.color ?? 'orange',
|
|
custom_color: payload.custom_color ?? null,
|
|
icon: payload.icon ?? null,
|
|
icon_color: payload.icon_color ?? null,
|
|
visible: payload.visible ?? true,
|
|
notes: payload.notes ?? null,
|
|
marker_type: payload.marker_type ?? 'pin',
|
|
})
|
|
|
|
return marker
|
|
}
|
|
|
|
async updateMarker({ request, response }: HttpContext) {
|
|
const { id } = request.params()
|
|
const marker = await MapMarker.find(id)
|
|
|
|
if (!marker) {
|
|
return response.status(404).send({ message: 'Marker not found' })
|
|
}
|
|
|
|
const payload = await request.validateUsing(
|
|
vine.compile(
|
|
vine.object({
|
|
name: vine.string().trim().minLength(1).maxLength(255).optional(),
|
|
color: vine.string().trim().maxLength(20).optional(),
|
|
custom_color: vine.string().trim().maxLength(7).nullable().optional(),
|
|
icon: vine.string().trim().maxLength(50).nullable().optional(),
|
|
icon_color: vine.string().trim().maxLength(7).nullable().optional(),
|
|
visible: vine.boolean().optional(),
|
|
longitude: vine.number().min(-180).max(180).optional(),
|
|
latitude: vine.number().min(-90).max(90).optional(),
|
|
notes: vine.string().trim().maxLength(500).nullable().optional(),
|
|
marker_type: vine.string().trim().maxLength(20).optional(),
|
|
})
|
|
)
|
|
)
|
|
|
|
if (payload.name !== undefined) marker.name = payload.name
|
|
if (payload.color !== undefined) marker.color = payload.color
|
|
if (payload.custom_color !== undefined) marker.custom_color = payload.custom_color
|
|
if (payload.icon !== undefined) marker.icon = payload.icon
|
|
if (payload.icon_color !== undefined) marker.icon_color = payload.icon_color
|
|
if (payload.visible !== undefined) marker.visible = payload.visible
|
|
if (payload.longitude !== undefined) marker.longitude = payload.longitude
|
|
if (payload.latitude !== undefined) marker.latitude = payload.latitude
|
|
if (payload.notes !== undefined) marker.notes = payload.notes
|
|
if (payload.marker_type !== undefined) marker.marker_type = payload.marker_type
|
|
|
|
await marker.save()
|
|
return marker
|
|
}
|
|
|
|
async deleteMarker({ request, response }: HttpContext) {
|
|
const { id } = request.params()
|
|
const marker = await MapMarker.find(id)
|
|
if (!marker) {
|
|
return response.status(404).send({ message: 'Marker not found' })
|
|
}
|
|
await marker.delete()
|
|
return { message: 'Marker deleted' }
|
|
}
|
|
}
|