556 lines
14 KiB
Go
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()
|
|
}
|