Files
map-asset-gateway/services/api-go/internal/basemap/vectors.go
T

556 lines
14 KiB
Go

package basemap
import (
"archive/zip"
"context"
"crypto/sha256"
"database/sql"
"encoding/hex"
"errors"
"fmt"
"io"
"mime"
"net/http"
"os"
"path"
"path/filepath"
"sort"
"strings"
"time"
"map-asset-gateway/api-go/internal/uid"
)
type VectorDownloadDescriptor struct {
Asset VectorAsset
FilePath string
ContentType string
FileName string
Zipped bool
PackageFiles []string
}
func (s *Store) ListVectorAssets(ctx context.Context) ([]VectorAsset, error) {
rows, err := s.db.QueryContext(ctx, `
SELECT id, code, name, format, status, description, file_path, file_name, file_size, checksum, download_url, metadata_json, created_at, updated_at
FROM vector_assets
ORDER BY code
`)
if err != nil {
return nil, err
}
defer rows.Close()
var items []VectorAsset
for rows.Next() {
item, err := scanVectorAsset(rows)
if err != nil {
return nil, err
}
items = append(items, item)
}
return items, rows.Err()
}
func (s *Store) GetVectorAssetByCode(ctx context.Context, code string) (VectorAsset, error) {
row := s.db.QueryRowContext(ctx, `
SELECT id, code, name, format, status, description, file_path, file_name, file_size, checksum, download_url, metadata_json, created_at, updated_at
FROM vector_assets
WHERE code = ?
`, normalizeBasemapCode(code))
item, err := scanVectorAsset(row)
if err != nil {
if errors.Is(err, sql.ErrNoRows) {
return VectorAsset{}, fmt.Errorf("vector asset %q not found", code)
}
return VectorAsset{}, err
}
return item, nil
}
func (s *Store) GetVectorAssetByRef(ctx context.Context, ref string) (VectorAsset, error) {
cleaned, err := cleanVectorAssetRef(ref)
if err != nil {
return VectorAsset{}, err
}
items, err := s.ListVectorAssets(ctx)
if err != nil {
return VectorAsset{}, err
}
for _, item := range items {
if vectorAssetMatchesRef(item, cleaned) {
return item, nil
}
}
return VectorAsset{}, fmt.Errorf("vector asset %q not found", ref)
}
func (s *Store) RunVectorScan(ctx context.Context) (map[string]any, error) {
root := strings.TrimSpace(s.cfg.VectorScanRoot)
if root == "" {
return nil, nil
}
if err := os.MkdirAll(root, 0o755); err != nil {
return nil, err
}
items, err := scanVectorRoot(root)
if err != nil {
return nil, err
}
tx, err := s.db.BeginTx(ctx, nil)
if err != nil {
return nil, err
}
defer tx.Rollback()
rows, err := tx.QueryContext(ctx, `SELECT code FROM vector_assets`)
if err != nil {
return nil, err
}
existing := map[string]struct{}{}
for rows.Next() {
var code string
if err := rows.Scan(&code); err != nil {
rows.Close()
return nil, err
}
existing[code] = struct{}{}
}
rows.Close()
if err := rows.Err(); err != nil {
return nil, err
}
found := map[string]struct{}{}
added := 0
updated := 0
now := nowUTC()
for _, item := range items {
found[item.Code] = struct{}{}
if _, ok := existing[item.Code]; ok {
updated++
} else {
added++
}
if err := s.upsertVectorAssetTx(ctx, tx, item, now); err != nil {
return nil, err
}
}
removed := 0
for code := range existing {
if _, ok := found[code]; ok {
continue
}
if _, err := tx.ExecContext(ctx, `DELETE FROM vector_assets WHERE code = ?`, code); err != nil {
return nil, err
}
removed++
}
if err := tx.Commit(); err != nil {
return nil, err
}
return map[string]any{
"scan_root": root,
"scanned_count": len(items),
"added_count": added,
"updated_count": updated,
"removed_count": removed,
}, nil
}
type scannedVector struct {
Code string
Name string
Format string
Status string
Description string
FilePath string
FileName string
FileSize int64
Checksum string
Metadata map[string]any
}
func scanVectorRoot(root string) ([]scannedVector, error) {
shapefiles := map[string]string{}
var items []scannedVector
err := filepath.WalkDir(root, func(path string, entry os.DirEntry, walkErr error) error {
if walkErr != nil {
return walkErr
}
if entry.IsDir() {
return nil
}
ext := strings.ToLower(filepath.Ext(entry.Name()))
switch ext {
case ".geojson", ".json", ".zip":
item, err := buildScannedVector(root, path, ext)
if err != nil {
return err
}
items = append(items, item)
case ".shp":
base := strings.TrimSuffix(path, filepath.Ext(path))
shapefiles[base] = path
}
return nil
})
if err != nil {
return nil, err
}
for _, base := range sortedKeysStringMap(shapefiles) {
path := shapefiles[base]
item, err := buildScannedVector(root, path, ".shp")
if err != nil {
return nil, err
}
items = append(items, item)
}
sort.Slice(items, func(i, j int) bool {
return items[i].Code < items[j].Code
})
return items, nil
}
func sortedKeysStringMap(values map[string]string) []string {
keys := make([]string, 0, len(values))
for key := range values {
keys = append(keys, key)
}
sort.Strings(keys)
return keys
}
func buildScannedVector(root, path, ext string) (scannedVector, error) {
absPath, err := filepath.Abs(path)
if err != nil {
return scannedVector{}, err
}
info, err := os.Stat(absPath)
if err != nil {
return scannedVector{}, err
}
rel, err := filepath.Rel(root, absPath)
if err != nil {
return scannedVector{}, err
}
code := vectorCodeFromRel(rel, ext)
name := strings.TrimSuffix(filepath.Base(absPath), filepath.Ext(absPath))
format := vectorFormatFromExt(ext)
fileName := filepath.Base(absPath)
fileSize := info.Size()
checksum, err := hashVector(absPath, ext)
if err != nil {
return scannedVector{}, err
}
if format == "shapefile" {
fileName = name + ".zip"
}
return scannedVector{
Code: code,
Name: name,
Format: format,
Status: "ready",
FilePath: absPath,
FileName: fileName,
FileSize: fileSize,
Checksum: checksum,
Metadata: map[string]any{
"relative_path": filepath.ToSlash(rel),
},
}, nil
}
func vectorCodeFromRel(rel, ext string) string {
rel = filepath.ToSlash(rel)
rel = strings.TrimSuffix(rel, strings.ToLower(filepath.Ext(rel)))
rel = strings.TrimSuffix(rel, filepath.Ext(rel))
rel = strings.ReplaceAll(rel, "/", "-")
code := normalizeBasemapCode(rel)
if code != "" {
return code
}
sum := sha256.Sum256([]byte(rel))
return "vector-" + hex.EncodeToString(sum[:6])
}
func vectorFormatFromExt(ext string) string {
switch strings.ToLower(ext) {
case ".geojson", ".json":
return "geojson"
case ".shp":
return "shapefile"
case ".zip":
return "shpzip"
default:
return strings.TrimPrefix(strings.ToLower(ext), ".")
}
}
func hashVector(path, ext string) (string, error) {
sum := sha256.New()
if strings.EqualFold(ext, ".shp") {
for _, sibling := range shapefilePackageFiles(path) {
info, err := os.Stat(sibling)
if err != nil || info.IsDir() {
continue
}
if _, err := io.WriteString(sum, filepath.Base(sibling)); err != nil {
return "", err
}
if err := hashFileInto(sum, sibling); err != nil {
return "", err
}
}
return hex.EncodeToString(sum.Sum(nil)), nil
}
if err := hashFileInto(sum, path); err != nil {
return "", err
}
return hex.EncodeToString(sum.Sum(nil)), nil
}
func hashFileInto(writer io.Writer, path string) error {
file, err := os.Open(path)
if err != nil {
return err
}
defer file.Close()
_, err = io.Copy(writer, file)
return err
}
func shapefilePackageFiles(path string) []string {
base := strings.TrimSuffix(path, filepath.Ext(path))
candidates := []string{".shp", ".dbf", ".shx", ".prj", ".cpg", ".qix", ".sbn", ".sbx"}
files := make([]string, 0, len(candidates))
for _, ext := range candidates {
file := base + ext
if _, err := os.Stat(file); err == nil {
files = append(files, file)
}
}
sort.Strings(files)
return files
}
func (s *Store) upsertVectorAssetTx(ctx context.Context, tx *sql.Tx, item scannedVector, now time.Time) error {
id := uid.Deterministic("vector-asset", item.Code)
_, err := tx.ExecContext(ctx, `
INSERT INTO vector_assets (
id, code, name, format, status, description, file_path, file_name, file_size, checksum, download_url, metadata_json, created_at, updated_at
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
ON CONFLICT(code) DO UPDATE SET
name = excluded.name,
format = excluded.format,
status = excluded.status,
description = excluded.description,
file_path = excluded.file_path,
file_name = excluded.file_name,
file_size = excluded.file_size,
checksum = excluded.checksum,
download_url = excluded.download_url,
metadata_json = excluded.metadata_json,
updated_at = excluded.updated_at
`, id, item.Code, item.Name, item.Format, item.Status, item.Description, item.FilePath, item.FileName, item.FileSize, item.Checksum, s.buildVectorDownloadURL(item.Code), writeJSON(item.Metadata), toRFC3339(now), toRFC3339(now))
return err
}
func (s *Store) buildVectorDownloadURL(code string) string {
base := trimURL(s.cfg.VectorBaseURL)
if base == "" {
base = trimURL(s.cfg.APIBaseURL)
}
return fmt.Sprintf("%s/vectors/%s/download", base, code)
}
func (auth TokenAuth) CanReadVector(code string) bool {
group, ok := auth.VectorPermissions[readPermission]
if !ok {
return false
}
_, ok = group[normalizeBasemapCode(code)]
return ok
}
func (s *Store) FilterVectors(auth TokenAuth, items []VectorAsset) []VectorAsset {
filtered := make([]VectorAsset, 0, len(items))
for _, item := range items {
if auth.CanReadVector(item.Code) {
filtered = append(filtered, item)
}
}
return filtered
}
func (s *Store) ResolveVectorDownload(ctx context.Context, auth TokenAuth, code string) (VectorDownloadDescriptor, error) {
code = normalizeBasemapCode(code)
if !auth.CanReadVector(code) {
return VectorDownloadDescriptor{}, errors.New("token has no access to this vector asset")
}
item, err := s.GetVectorAssetByCode(ctx, code)
if err != nil {
return VectorDownloadDescriptor{}, err
}
return buildVectorDownloadDescriptor(item)
}
func (s *Store) ResolveVectorDownloadByRef(ctx context.Context, auth TokenAuth, ref string, allowedFormats ...string) (VectorDownloadDescriptor, error) {
item, err := s.GetVectorAssetByRef(ctx, ref)
if err != nil {
return VectorDownloadDescriptor{}, err
}
if !auth.CanReadVector(item.Code) {
return VectorDownloadDescriptor{}, errors.New("token has no access to this vector asset")
}
if !vectorFormatAllowed(item.Format, allowedFormats) {
return VectorDownloadDescriptor{}, fmt.Errorf("vector asset %q is not available in requested format", ref)
}
return buildVectorDownloadDescriptor(item)
}
func buildVectorDownloadDescriptor(item VectorAsset) (VectorDownloadDescriptor, error) {
switch item.Format {
case "geojson":
contentType := mime.TypeByExtension(filepath.Ext(item.FilePath))
if contentType == "" {
contentType = "application/geo+json"
}
return VectorDownloadDescriptor{
Asset: item,
FilePath: item.FilePath,
FileName: item.FileName,
ContentType: contentType,
}, nil
case "shapefile":
files := shapefilePackageFiles(item.FilePath)
if len(files) == 0 {
return VectorDownloadDescriptor{}, errors.New("shapefile package is missing sidecar files")
}
return VectorDownloadDescriptor{
Asset: item,
FileName: item.FileName,
ContentType: "application/zip",
Zipped: true,
PackageFiles: files,
}, nil
case "shpzip":
return VectorDownloadDescriptor{
Asset: item,
FilePath: item.FilePath,
FileName: item.FileName,
ContentType: "application/zip",
}, nil
default:
contentType := mime.TypeByExtension(filepath.Ext(item.FilePath))
if contentType == "" {
contentType = "application/octet-stream"
}
return VectorDownloadDescriptor{
Asset: item,
FilePath: item.FilePath,
FileName: item.FileName,
ContentType: contentType,
}, nil
}
}
func cleanVectorAssetRef(value string) (string, error) {
value = strings.TrimSpace(strings.ReplaceAll(value, "\\", "/"))
value = strings.TrimPrefix(value, "/")
if value == "" {
return "", errors.New("vector asset path is required")
}
cleaned := path.Clean(value)
if cleaned == "." || cleaned == "/" || cleaned == ".." || strings.HasPrefix(cleaned, "../") {
return "", errors.New("invalid vector asset path")
}
return strings.TrimPrefix(cleaned, "./"), nil
}
func vectorAssetMatchesRef(item VectorAsset, ref string) bool {
for _, candidate := range vectorAssetRefs(item) {
if strings.EqualFold(candidate, ref) {
return true
}
}
return false
}
func vectorAssetRefs(item VectorAsset) []string {
values := []string{item.Code}
if relativePath := vectorRelativePath(item); relativePath != "" {
values = append(values, relativePath)
}
if item.FileName != "" {
values = append(values, strings.ReplaceAll(item.FileName, "\\", "/"))
}
return values
}
func vectorRelativePath(item VectorAsset) string {
if item.Metadata == nil {
return ""
}
value, ok := item.Metadata["relative_path"].(string)
if !ok {
return ""
}
cleaned, err := cleanVectorAssetRef(value)
if err != nil {
return ""
}
return cleaned
}
func vectorFormatAllowed(format string, allowed []string) bool {
if len(allowed) == 0 {
return true
}
for _, value := range allowed {
if strings.EqualFold(strings.TrimSpace(value), format) {
return true
}
}
return false
}
func WriteVectorZip(w http.ResponseWriter, descriptor VectorDownloadDescriptor) error {
archive := zip.NewWriter(w)
for _, path := range descriptor.PackageFiles {
file, err := os.Open(path)
if err != nil {
return err
}
info, err := file.Stat()
if err != nil {
file.Close()
return err
}
header, err := zip.FileInfoHeader(info)
if err != nil {
file.Close()
return err
}
header.Name = filepath.Base(path)
header.Method = zip.Deflate
writer, err := archive.CreateHeader(header)
if err != nil {
file.Close()
return err
}
if _, err := io.Copy(writer, file); err != nil {
file.Close()
return err
}
file.Close()
}
return archive.Close()
}