Initial import of map-asset-gateway
This commit is contained in:
@@ -0,0 +1,535 @@
|
||||
package basemap
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/sha256"
|
||||
"database/sql"
|
||||
"encoding/hex"
|
||||
"errors"
|
||||
"fmt"
|
||||
"mime"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"strings"
|
||||
|
||||
"map-asset-gateway/api-go/internal/uid"
|
||||
)
|
||||
|
||||
var ErrBasemapVersionNotFound = errors.New("basemap version not found")
|
||||
|
||||
func (s *Store) CreateToken(ctx context.Context, input CreateTokenInput) (CreatedToken, error) {
|
||||
name := strings.TrimSpace(input.Name)
|
||||
if name == "" {
|
||||
return CreatedToken{}, errors.New("token name is required")
|
||||
}
|
||||
if len(input.BasemapCodes) == 0 {
|
||||
if len(input.VectorCodes) == 0 {
|
||||
return CreatedToken{}, errors.New("at least one basemap code or vector code is required")
|
||||
}
|
||||
}
|
||||
|
||||
resolvedVectorAssets := map[string]VectorAsset{}
|
||||
for _, ref := range input.VectorCodes {
|
||||
ref = strings.TrimSpace(ref)
|
||||
if ref == "" {
|
||||
continue
|
||||
}
|
||||
asset, err := s.GetVectorAssetByRef(ctx, ref)
|
||||
if err != nil {
|
||||
return CreatedToken{}, err
|
||||
}
|
||||
resolvedVectorAssets[asset.Code] = asset
|
||||
}
|
||||
|
||||
tx, err := s.db.BeginTx(ctx, nil)
|
||||
if err != nil {
|
||||
return CreatedToken{}, err
|
||||
}
|
||||
defer tx.Rollback()
|
||||
|
||||
token, tokenHash, err := s.generateTokenSecret()
|
||||
if err != nil {
|
||||
return CreatedToken{}, err
|
||||
}
|
||||
|
||||
now := nowUTC()
|
||||
tokenID := uid.New()
|
||||
prefix := token
|
||||
if len(prefix) > 16 {
|
||||
prefix = prefix[:16]
|
||||
}
|
||||
_, err = tx.ExecContext(ctx, `
|
||||
INSERT INTO service_tokens (id, name, token_hash, token_prefix, status, expires_at, last_used_at, created_at)
|
||||
VALUES (?, ?, ?, ?, 'active', ?, NULL, ?)
|
||||
`, tokenID, name, tokenHash, prefix, nullableTime(input.ExpiresAt), toRFC3339(now))
|
||||
if err != nil {
|
||||
return CreatedToken{}, err
|
||||
}
|
||||
|
||||
normalizedCodes := map[string]struct{}{}
|
||||
for _, code := range input.BasemapCodes {
|
||||
normalized := normalizeBasemapCode(code)
|
||||
if normalized != "" {
|
||||
normalizedCodes[normalized] = struct{}{}
|
||||
}
|
||||
}
|
||||
if len(normalizedCodes) == 0 {
|
||||
if len(resolvedVectorAssets) == 0 {
|
||||
return CreatedToken{}, errors.New("no valid basemap codes provided")
|
||||
}
|
||||
}
|
||||
|
||||
for _, code := range sortedKeys(normalizedCodes) {
|
||||
var basemapID string
|
||||
err := tx.QueryRowContext(ctx, `SELECT id FROM basemaps WHERE code = ?`, code).Scan(&basemapID)
|
||||
if err != nil {
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return CreatedToken{}, fmt.Errorf("basemap %q not found", code)
|
||||
}
|
||||
return CreatedToken{}, err
|
||||
}
|
||||
grantID := uid.New()
|
||||
if _, err := tx.ExecContext(ctx, `
|
||||
INSERT INTO token_grants (id, token_id, basemap_id, basemap_version_id, permission, created_at)
|
||||
VALUES (?, ?, ?, NULL, ?, ?)
|
||||
`, grantID, tokenID, basemapID, readPermission, toRFC3339(now)); err != nil {
|
||||
return CreatedToken{}, err
|
||||
}
|
||||
}
|
||||
|
||||
vectorCodes := make([]string, 0, len(resolvedVectorAssets))
|
||||
for code := range resolvedVectorAssets {
|
||||
vectorCodes = append(vectorCodes, code)
|
||||
}
|
||||
sort.Strings(vectorCodes)
|
||||
for _, code := range vectorCodes {
|
||||
asset := resolvedVectorAssets[code]
|
||||
grantID := uid.New()
|
||||
if _, err := tx.ExecContext(ctx, `
|
||||
INSERT INTO vector_token_grants (id, token_id, vector_asset_id, permission, created_at)
|
||||
VALUES (?, ?, ?, ?, ?)
|
||||
`, grantID, tokenID, asset.ID, readPermission, toRFC3339(now)); err != nil {
|
||||
return CreatedToken{}, err
|
||||
}
|
||||
}
|
||||
|
||||
if err := tx.Commit(); err != nil {
|
||||
return CreatedToken{}, err
|
||||
}
|
||||
meta, err := s.getTokenByID(ctx, tokenID)
|
||||
if err != nil {
|
||||
return CreatedToken{}, err
|
||||
}
|
||||
return CreatedToken{
|
||||
Meta: meta,
|
||||
Token: token,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (s *Store) ListTokens(ctx context.Context) ([]ServiceToken, error) {
|
||||
rows, err := s.db.QueryContext(ctx, `
|
||||
SELECT id, name, token_hash, token_prefix, status, expires_at, last_used_at, created_at
|
||||
FROM service_tokens
|
||||
ORDER BY created_at DESC
|
||||
`)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var items []ServiceToken
|
||||
for rows.Next() {
|
||||
item, _, err := scanServiceToken(rows)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
items = append(items, item)
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
grantsByToken, err := s.loadAllTokenGrants(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
vectorGrantsByToken, err := s.loadAllVectorTokenGrants(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for i := range items {
|
||||
items[i].Grants = grantsByToken[items[i].ID]
|
||||
items[i].VectorGrants = vectorGrantsByToken[items[i].ID]
|
||||
}
|
||||
return items, nil
|
||||
}
|
||||
|
||||
func (s *Store) loadAllTokenGrants(ctx context.Context) (map[string][]TokenGrant, error) {
|
||||
rows, err := s.db.QueryContext(ctx, `
|
||||
SELECT
|
||||
g.id,
|
||||
g.token_id,
|
||||
g.basemap_id,
|
||||
b.code,
|
||||
g.basemap_version_id,
|
||||
v.version,
|
||||
g.permission,
|
||||
g.created_at
|
||||
FROM token_grants g
|
||||
JOIN basemaps b ON b.id = g.basemap_id
|
||||
LEFT JOIN basemap_versions v ON v.id = g.basemap_version_id
|
||||
ORDER BY g.created_at DESC
|
||||
`)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
result := map[string][]TokenGrant{}
|
||||
for rows.Next() {
|
||||
item, err := scanTokenGrant(rows)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
result[item.TokenID] = append(result[item.TokenID], item)
|
||||
}
|
||||
return result, rows.Err()
|
||||
}
|
||||
|
||||
func (s *Store) loadAllVectorTokenGrants(ctx context.Context) (map[string][]VectorTokenGrant, error) {
|
||||
rows, err := s.db.QueryContext(ctx, `
|
||||
SELECT
|
||||
g.id,
|
||||
g.token_id,
|
||||
g.vector_asset_id,
|
||||
v.code,
|
||||
g.permission,
|
||||
g.created_at
|
||||
FROM vector_token_grants g
|
||||
JOIN vector_assets v ON v.id = g.vector_asset_id
|
||||
ORDER BY g.created_at DESC
|
||||
`)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
result := map[string][]VectorTokenGrant{}
|
||||
for rows.Next() {
|
||||
item, err := scanVectorTokenGrant(rows)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
result[item.TokenID] = append(result[item.TokenID], item)
|
||||
}
|
||||
return result, rows.Err()
|
||||
}
|
||||
|
||||
func (s *Store) DisableToken(ctx context.Context, tokenID string) error {
|
||||
result, err := s.db.ExecContext(ctx, `UPDATE service_tokens SET status = 'disabled' WHERE id = ?`, strings.TrimSpace(tokenID))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
count, err := result.RowsAffected()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if count == 0 {
|
||||
return fmt.Errorf("token %q not found", tokenID)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Store) getTokenByID(ctx context.Context, tokenID string) (ServiceToken, error) {
|
||||
row := s.db.QueryRowContext(ctx, `
|
||||
SELECT id, name, token_hash, token_prefix, status, expires_at, last_used_at, created_at
|
||||
FROM service_tokens
|
||||
WHERE id = ?
|
||||
`, tokenID)
|
||||
item, _, err := scanServiceToken(row)
|
||||
if err != nil {
|
||||
return ServiceToken{}, err
|
||||
}
|
||||
grants, err := s.loadAllTokenGrants(ctx)
|
||||
if err != nil {
|
||||
return ServiceToken{}, err
|
||||
}
|
||||
item.Grants = grants[item.ID]
|
||||
vectorGrants, err := s.loadAllVectorTokenGrants(ctx)
|
||||
if err != nil {
|
||||
return ServiceToken{}, err
|
||||
}
|
||||
item.VectorGrants = vectorGrants[item.ID]
|
||||
return item, nil
|
||||
}
|
||||
|
||||
func (s *Store) AuthorizeToken(ctx context.Context, rawToken string) (TokenAuth, error) {
|
||||
rawToken = strings.TrimSpace(rawToken)
|
||||
if rawToken == "" {
|
||||
return TokenAuth{}, errors.New("missing token")
|
||||
}
|
||||
sum := sha256.Sum256([]byte(rawToken))
|
||||
tokenHash := hex.EncodeToString(sum[:])
|
||||
|
||||
row := s.db.QueryRowContext(ctx, `
|
||||
SELECT id, name, token_hash, token_prefix, status, expires_at, last_used_at, created_at
|
||||
FROM service_tokens
|
||||
WHERE token_hash = ?
|
||||
`, tokenHash)
|
||||
item, _, err := scanServiceToken(row)
|
||||
if err != nil {
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return TokenAuth{}, errors.New("invalid token")
|
||||
}
|
||||
return TokenAuth{}, err
|
||||
}
|
||||
if item.Status != "active" {
|
||||
return TokenAuth{}, errors.New("token is disabled")
|
||||
}
|
||||
if item.ExpiresAt != nil && item.ExpiresAt.Before(nowUTC()) {
|
||||
return TokenAuth{}, errors.New("token is expired")
|
||||
}
|
||||
|
||||
grantsByToken, err := s.loadAllTokenGrants(ctx)
|
||||
if err != nil {
|
||||
return TokenAuth{}, err
|
||||
}
|
||||
item.Grants = grantsByToken[item.ID]
|
||||
vectorGrantsByToken, err := s.loadAllVectorTokenGrants(ctx)
|
||||
if err != nil {
|
||||
return TokenAuth{}, err
|
||||
}
|
||||
item.VectorGrants = vectorGrantsByToken[item.ID]
|
||||
permissions := map[string]map[string]VersionScope{}
|
||||
for _, grant := range item.Grants {
|
||||
group, ok := permissions[grant.Permission]
|
||||
if !ok {
|
||||
group = map[string]VersionScope{}
|
||||
permissions[grant.Permission] = group
|
||||
}
|
||||
scope := group[grant.BasemapCode]
|
||||
if grant.BasemapVersion == nil {
|
||||
scope.AllVersions = true
|
||||
scope.Versions = nil
|
||||
} else if !scope.AllVersions {
|
||||
if scope.Versions == nil {
|
||||
scope.Versions = map[string]struct{}{}
|
||||
}
|
||||
scope.Versions[*grant.BasemapVersion] = struct{}{}
|
||||
}
|
||||
group[grant.BasemapCode] = scope
|
||||
}
|
||||
vectorPermissions := map[string]map[string]struct{}{}
|
||||
for _, grant := range item.VectorGrants {
|
||||
group, ok := vectorPermissions[grant.Permission]
|
||||
if !ok {
|
||||
group = map[string]struct{}{}
|
||||
vectorPermissions[grant.Permission] = group
|
||||
}
|
||||
group[grant.VectorCode] = struct{}{}
|
||||
}
|
||||
_, _ = s.db.ExecContext(ctx, `UPDATE service_tokens SET last_used_at = ? WHERE id = ?`, toRFC3339(nowUTC()), item.ID)
|
||||
return TokenAuth{
|
||||
Token: item,
|
||||
Permissions: permissions,
|
||||
VectorPermissions: vectorPermissions,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (auth TokenAuth) CanReadBasemap(code string, version string) bool {
|
||||
group, ok := auth.Permissions[readPermission]
|
||||
if !ok {
|
||||
return false
|
||||
}
|
||||
scope, ok := group[normalizeBasemapCode(code)]
|
||||
if !ok {
|
||||
return false
|
||||
}
|
||||
if scope.AllVersions {
|
||||
return true
|
||||
}
|
||||
if version == "" {
|
||||
return len(scope.Versions) > 0
|
||||
}
|
||||
_, ok = scope.Versions[strings.TrimSpace(version)]
|
||||
return ok
|
||||
}
|
||||
|
||||
func (s *Store) FilterCatalog(auth TokenAuth, basemaps []Basemap) []Basemap {
|
||||
filtered := make([]Basemap, 0, len(basemaps))
|
||||
for _, basemap := range basemaps {
|
||||
if !auth.CanReadBasemap(basemap.Code, "") {
|
||||
continue
|
||||
}
|
||||
copyItem := basemap
|
||||
copyItem.Versions = nil
|
||||
copyItem.Default = nil
|
||||
for _, version := range basemap.Versions {
|
||||
if auth.CanReadBasemap(basemap.Code, version.Version) {
|
||||
copyItem.Versions = append(copyItem.Versions, version)
|
||||
if version.IsDefault {
|
||||
copyVersion := version
|
||||
copyItem.Default = ©Version
|
||||
}
|
||||
}
|
||||
}
|
||||
if len(copyItem.Versions) == 0 {
|
||||
continue
|
||||
}
|
||||
if copyItem.Default == nil {
|
||||
copyVersion := copyItem.Versions[0]
|
||||
copyItem.Default = ©Version
|
||||
}
|
||||
filtered = append(filtered, copyItem)
|
||||
}
|
||||
return filtered
|
||||
}
|
||||
|
||||
func (s *Store) ResolveTile(ctx context.Context, auth TokenAuth, basemapCode, version, tilePath string) (TileDescriptor, error) {
|
||||
basemapCode = normalizeBasemapCode(basemapCode)
|
||||
version = strings.TrimSpace(version)
|
||||
tilePath = filepath.Clean(strings.TrimPrefix(strings.TrimSpace(tilePath), "/"))
|
||||
if tilePath == "." || tilePath == "" {
|
||||
return TileDescriptor{}, errors.New("tile path is required")
|
||||
}
|
||||
if !auth.CanReadBasemap(basemapCode, version) {
|
||||
return TileDescriptor{}, errors.New("token has no access to this basemap")
|
||||
}
|
||||
|
||||
row := s.db.QueryRowContext(ctx, `
|
||||
SELECT
|
||||
v.id,
|
||||
v.basemap_id,
|
||||
b.code,
|
||||
v.version,
|
||||
v.status,
|
||||
v.is_default,
|
||||
v.manifest_path,
|
||||
v.tile_root_path,
|
||||
v.url_template,
|
||||
v.tile_format,
|
||||
v.tile_scheme,
|
||||
v.min_zoom,
|
||||
v.max_zoom,
|
||||
v.bbox_json,
|
||||
v.attribution,
|
||||
v.metadata_json,
|
||||
v.created_at,
|
||||
v.updated_at
|
||||
FROM basemap_versions v
|
||||
JOIN basemaps b ON b.id = v.basemap_id
|
||||
WHERE b.code = ? AND v.version = ?
|
||||
`, basemapCode, version)
|
||||
versionRow, err := scanBasemapVersionRow(row)
|
||||
if err != nil {
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return TileDescriptor{}, fmt.Errorf("%w: %s/%s", ErrBasemapVersionNotFound, basemapCode, version)
|
||||
}
|
||||
return TileDescriptor{}, err
|
||||
}
|
||||
versionItem := decodeBasemapVersion(versionRow)
|
||||
basemap, err := s.GetBasemapByCode(ctx, basemapCode)
|
||||
if err != nil {
|
||||
return TileDescriptor{}, err
|
||||
}
|
||||
|
||||
filePath, err := safeJoin(versionItem.TileRootPath, tilePath)
|
||||
if err != nil {
|
||||
return TileDescriptor{}, err
|
||||
}
|
||||
info, err := os.Stat(filePath)
|
||||
if err != nil {
|
||||
if errors.Is(err, os.ErrNotExist) {
|
||||
return TileDescriptor{}, fmt.Errorf("tile %s not found", tilePath)
|
||||
}
|
||||
return TileDescriptor{}, err
|
||||
}
|
||||
if info.IsDir() {
|
||||
return TileDescriptor{}, errors.New("tile path is a directory")
|
||||
}
|
||||
contentType := mime.TypeByExtension(filepath.Ext(filePath))
|
||||
if contentType == "" {
|
||||
contentType = "application/octet-stream"
|
||||
}
|
||||
return TileDescriptor{
|
||||
FilePath: filePath,
|
||||
ContentType: contentType,
|
||||
Basemap: basemap,
|
||||
Version: versionItem,
|
||||
Token: auth.Token,
|
||||
RelativePath: tilePath,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (s *Store) ResolveDefaultTile(ctx context.Context, auth TokenAuth, basemapCode, tilePath string) (TileDescriptor, error) {
|
||||
basemapCode = normalizeBasemapCode(basemapCode)
|
||||
if basemapCode == "" {
|
||||
return TileDescriptor{}, errors.New("basemap code is required")
|
||||
}
|
||||
if !auth.CanReadBasemap(basemapCode, "") {
|
||||
return TileDescriptor{}, errors.New("token has no access to this basemap")
|
||||
}
|
||||
version, err := s.GetDefaultBasemapVersion(ctx, basemapCode)
|
||||
if err != nil {
|
||||
return TileDescriptor{}, err
|
||||
}
|
||||
if !auth.CanReadBasemap(basemapCode, version.Version) {
|
||||
return TileDescriptor{}, errors.New("token has no access to the default basemap version")
|
||||
}
|
||||
return s.ResolveTile(ctx, auth, basemapCode, version.Version, tilePath)
|
||||
}
|
||||
|
||||
func (s *Store) GetDefaultBasemapVersion(ctx context.Context, basemapCode string) (BasemapVersion, error) {
|
||||
basemapCode = normalizeBasemapCode(basemapCode)
|
||||
row := s.db.QueryRowContext(ctx, `
|
||||
SELECT
|
||||
v.id,
|
||||
v.basemap_id,
|
||||
b.code,
|
||||
v.version,
|
||||
v.status,
|
||||
v.is_default,
|
||||
v.manifest_path,
|
||||
v.tile_root_path,
|
||||
v.url_template,
|
||||
v.tile_format,
|
||||
v.tile_scheme,
|
||||
v.min_zoom,
|
||||
v.max_zoom,
|
||||
v.bbox_json,
|
||||
v.attribution,
|
||||
v.metadata_json,
|
||||
v.created_at,
|
||||
v.updated_at
|
||||
FROM basemap_versions v
|
||||
JOIN basemaps b ON b.id = v.basemap_id
|
||||
WHERE b.code = ?
|
||||
ORDER BY v.is_default DESC, v.version ASC
|
||||
LIMIT 1
|
||||
`, basemapCode)
|
||||
versionRow, err := scanBasemapVersionRow(row)
|
||||
if err != nil {
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return BasemapVersion{}, fmt.Errorf("basemap %q has no versions", basemapCode)
|
||||
}
|
||||
return BasemapVersion{}, err
|
||||
}
|
||||
return decodeBasemapVersion(versionRow), nil
|
||||
}
|
||||
|
||||
func safeJoin(root, relative string) (string, error) {
|
||||
root = filepath.Clean(root)
|
||||
if root == "." || root == "" {
|
||||
return "", errors.New("invalid tile root")
|
||||
}
|
||||
target := filepath.Clean(filepath.Join(root, relative))
|
||||
rel, err := filepath.Rel(root, target)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if rel == ".." || strings.HasPrefix(rel, ".."+string(filepath.Separator)) {
|
||||
return "", errors.New("invalid tile path")
|
||||
}
|
||||
return target, nil
|
||||
}
|
||||
@@ -0,0 +1,302 @@
|
||||
package basemap
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"errors"
|
||||
"fmt"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"map-asset-gateway/api-go/internal/uid"
|
||||
)
|
||||
|
||||
func (s *Store) EnsureScanSource(ctx context.Context, input CreateScanSourceInput) (ScanSource, error) {
|
||||
code := normalizeBasemapCode(input.Code)
|
||||
if code == "" {
|
||||
return ScanSource{}, errors.New("scan source code is required")
|
||||
}
|
||||
name := strings.TrimSpace(input.Name)
|
||||
if name == "" {
|
||||
name = code
|
||||
}
|
||||
rootPath := strings.TrimSpace(input.RootPath)
|
||||
if rootPath == "" {
|
||||
return ScanSource{}, errors.New("scan source root path is required")
|
||||
}
|
||||
absRoot, err := filepath.Abs(rootPath)
|
||||
if err != nil {
|
||||
return ScanSource{}, fmt.Errorf("resolve root path: %w", err)
|
||||
}
|
||||
manifestName := strings.TrimSpace(input.ManifestName)
|
||||
if manifestName == "" {
|
||||
manifestName = defaultManifestName
|
||||
}
|
||||
|
||||
now := nowUTC()
|
||||
id := uid.Deterministic("scan-source", code)
|
||||
_, err = s.db.ExecContext(ctx, `
|
||||
INSERT INTO scan_sources (
|
||||
id, code, name, root_path, manifest_name, enabled, metadata_json, created_at, updated_at
|
||||
) VALUES (?, ?, ?, ?, ?, 1, '', ?, ?)
|
||||
ON CONFLICT(code) DO UPDATE SET
|
||||
name = excluded.name,
|
||||
root_path = excluded.root_path,
|
||||
manifest_name = excluded.manifest_name,
|
||||
enabled = 1,
|
||||
updated_at = excluded.updated_at
|
||||
`, id, code, name, absRoot, manifestName, toRFC3339(now), toRFC3339(now))
|
||||
if err != nil {
|
||||
return ScanSource{}, fmt.Errorf("upsert scan source: %w", err)
|
||||
}
|
||||
return s.GetScanSourceByCode(ctx, code)
|
||||
}
|
||||
|
||||
func (s *Store) GetScanSourceByCode(ctx context.Context, code string) (ScanSource, error) {
|
||||
row := s.db.QueryRowContext(ctx, `
|
||||
SELECT id, code, name, root_path, manifest_name, enabled, metadata_json, created_at, updated_at
|
||||
FROM scan_sources
|
||||
WHERE code = ?
|
||||
`, normalizeBasemapCode(code))
|
||||
item, err := scanScanSource(row)
|
||||
if err != nil {
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return ScanSource{}, fmt.Errorf("scan source %q not found", code)
|
||||
}
|
||||
return ScanSource{}, err
|
||||
}
|
||||
return item, nil
|
||||
}
|
||||
|
||||
func (s *Store) ListScanSources(ctx context.Context) ([]ScanSource, error) {
|
||||
rows, err := s.db.QueryContext(ctx, `
|
||||
SELECT id, code, name, root_path, manifest_name, enabled, metadata_json, created_at, updated_at
|
||||
FROM scan_sources
|
||||
ORDER BY code
|
||||
`)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var items []ScanSource
|
||||
for rows.Next() {
|
||||
item, err := scanScanSource(rows)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
items = append(items, item)
|
||||
}
|
||||
return items, rows.Err()
|
||||
}
|
||||
|
||||
func (s *Store) ListBasemaps(ctx context.Context) ([]Basemap, error) {
|
||||
rows, err := s.db.QueryContext(ctx, `
|
||||
SELECT id, code, name, type, status, description, created_at, updated_at
|
||||
FROM basemaps
|
||||
ORDER BY code
|
||||
`)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var items []Basemap
|
||||
indexByID := map[string]int{}
|
||||
for rows.Next() {
|
||||
item, err := scanBasemap(rows)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
indexByID[item.ID] = len(items)
|
||||
items = append(items, item)
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
versionRows, err := s.db.QueryContext(ctx, `
|
||||
SELECT
|
||||
v.id,
|
||||
v.basemap_id,
|
||||
b.code,
|
||||
v.version,
|
||||
v.status,
|
||||
v.is_default,
|
||||
v.manifest_path,
|
||||
v.tile_root_path,
|
||||
v.url_template,
|
||||
v.tile_format,
|
||||
v.tile_scheme,
|
||||
v.min_zoom,
|
||||
v.max_zoom,
|
||||
v.bbox_json,
|
||||
v.attribution,
|
||||
v.metadata_json,
|
||||
v.created_at,
|
||||
v.updated_at
|
||||
FROM basemap_versions v
|
||||
JOIN basemaps b ON b.id = v.basemap_id
|
||||
ORDER BY b.code, v.version
|
||||
`)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer versionRows.Close()
|
||||
|
||||
for versionRows.Next() {
|
||||
row, err := scanBasemapVersionRow(versionRows)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
index, ok := indexByID[row.BasemapID]
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
version := decodeBasemapVersion(row)
|
||||
items[index].Versions = append(items[index].Versions, version)
|
||||
if version.IsDefault {
|
||||
copyValue := version
|
||||
items[index].Default = ©Value
|
||||
}
|
||||
}
|
||||
if err := versionRows.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return items, nil
|
||||
}
|
||||
|
||||
func (s *Store) GetBasemapByCode(ctx context.Context, code string) (Basemap, error) {
|
||||
items, err := s.ListBasemaps(ctx)
|
||||
if err != nil {
|
||||
return Basemap{}, err
|
||||
}
|
||||
normalized := normalizeBasemapCode(code)
|
||||
for _, item := range items {
|
||||
if item.Code == normalized {
|
||||
return item, nil
|
||||
}
|
||||
}
|
||||
return Basemap{}, fmt.Errorf("basemap %q not found", code)
|
||||
}
|
||||
|
||||
func (s *Store) SetDefaultVersion(ctx context.Context, basemapCode, version string) error {
|
||||
basemapCode = normalizeBasemapCode(basemapCode)
|
||||
version = strings.TrimSpace(version)
|
||||
if basemapCode == "" || version == "" {
|
||||
return errors.New("basemap code and version are required")
|
||||
}
|
||||
tx, err := s.db.BeginTx(ctx, nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer tx.Rollback()
|
||||
|
||||
var basemapID string
|
||||
if err := tx.QueryRowContext(ctx, `SELECT id FROM basemaps WHERE code = ?`, basemapCode).Scan(&basemapID); err != nil {
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return fmt.Errorf("basemap %q not found", basemapCode)
|
||||
}
|
||||
return err
|
||||
}
|
||||
result, err := tx.ExecContext(ctx, `
|
||||
UPDATE basemap_versions
|
||||
SET is_default = CASE WHEN version = ? THEN 1 ELSE 0 END, updated_at = ?
|
||||
WHERE basemap_id = ?
|
||||
`, version, toRFC3339(nowUTC()), basemapID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
count, err := result.RowsAffected()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if count == 0 {
|
||||
return fmt.Errorf("version %q not found for basemap %q", version, basemapCode)
|
||||
}
|
||||
if _, err := tx.ExecContext(ctx, `UPDATE basemaps SET updated_at = ? WHERE id = ?`, toRFC3339(nowUTC()), basemapID); err != nil {
|
||||
return err
|
||||
}
|
||||
return tx.Commit()
|
||||
}
|
||||
|
||||
func (s *Store) ListScanRuns(ctx context.Context, limit int) ([]ScanRun, error) {
|
||||
if limit <= 0 {
|
||||
limit = 20
|
||||
}
|
||||
rows, err := s.db.QueryContext(ctx, `
|
||||
SELECT
|
||||
r.id,
|
||||
r.scan_source_id,
|
||||
s.code,
|
||||
r.status,
|
||||
r.scanned_count,
|
||||
r.added_count,
|
||||
r.updated_count,
|
||||
r.removed_count,
|
||||
r.summary_json,
|
||||
r.started_at,
|
||||
r.finished_at
|
||||
FROM scan_runs r
|
||||
JOIN scan_sources s ON s.id = r.scan_source_id
|
||||
ORDER BY r.started_at DESC
|
||||
LIMIT ?
|
||||
`, limit)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var items []ScanRun
|
||||
for rows.Next() {
|
||||
item, err := scanScanRun(rows)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
items = append(items, item)
|
||||
}
|
||||
return items, rows.Err()
|
||||
}
|
||||
|
||||
func (s *Store) ListPushRecords(ctx context.Context, limit int) ([]PushRecord, error) {
|
||||
if limit <= 0 {
|
||||
limit = 20
|
||||
}
|
||||
rows, err := s.db.QueryContext(ctx, `
|
||||
SELECT
|
||||
p.id,
|
||||
p.target_system_id,
|
||||
t.code,
|
||||
p.basemap_version_id,
|
||||
b.code,
|
||||
v.version,
|
||||
p.status,
|
||||
p.request_json,
|
||||
p.response_status,
|
||||
p.response_body,
|
||||
p.error_message,
|
||||
p.pushed_at,
|
||||
p.finished_at
|
||||
FROM push_records p
|
||||
JOIN target_systems t ON t.id = p.target_system_id
|
||||
JOIN basemap_versions v ON v.id = p.basemap_version_id
|
||||
JOIN basemaps b ON b.id = v.basemap_id
|
||||
ORDER BY p.pushed_at DESC
|
||||
LIMIT ?
|
||||
`, limit)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var items []PushRecord
|
||||
for rows.Next() {
|
||||
item, err := scanPushRecord(rows)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
items = append(items, item)
|
||||
}
|
||||
return items, rows.Err()
|
||||
}
|
||||
@@ -0,0 +1,106 @@
|
||||
package basemap
|
||||
|
||||
import (
|
||||
"context"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestInsarCompatibilityHelpers(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
root := t.TempDir()
|
||||
tileRoot := filepath.Join(root, "tiles")
|
||||
vectorRoot := filepath.Join(root, "vectors")
|
||||
sqlitePath := filepath.Join(root, "basemap.db")
|
||||
|
||||
if err := os.MkdirAll(filepath.Join(tileRoot, "google_image", "0", "0"), 0o755); err != nil {
|
||||
t.Fatalf("mkdir tile root: %v", err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(tileRoot, "google_image", "0", "0", "0.webp"), []byte("tile"), 0o644); err != nil {
|
||||
t.Fatalf("write tile: %v", err)
|
||||
}
|
||||
|
||||
if err := os.MkdirAll(filepath.Join(vectorRoot, "landuse"), 0o755); err != nil {
|
||||
t.Fatalf("mkdir vector root: %v", err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(vectorRoot, "全国行政区.geojson"), []byte(`{"type":"FeatureCollection","features":[]}`), 0o644); err != nil {
|
||||
t.Fatalf("write geojson: %v", err)
|
||||
}
|
||||
for _, name := range []string{"parcel.shp", "parcel.dbf", "parcel.shx"} {
|
||||
if err := os.WriteFile(filepath.Join(vectorRoot, "landuse", name), []byte(name), 0o644); err != nil {
|
||||
t.Fatalf("write shapefile part %s: %v", name, err)
|
||||
}
|
||||
}
|
||||
|
||||
store, err := OpenStore(StoreConfig{
|
||||
SQLitePath: sqlitePath,
|
||||
ScanRoot: tileRoot,
|
||||
VectorScanRoot: vectorRoot,
|
||||
APIBaseURL: "http://127.0.0.1:8910",
|
||||
TileBaseURL: "http://127.0.0.1:8910",
|
||||
VectorBaseURL: "http://127.0.0.1:8910",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("open store: %v", err)
|
||||
}
|
||||
defer func() {
|
||||
if err := store.Close(); err != nil {
|
||||
t.Fatalf("close store: %v", err)
|
||||
}
|
||||
}()
|
||||
|
||||
if _, err := store.RunScan(ctx, defaultScanSourceCode); err != nil {
|
||||
t.Fatalf("run basemap scan: %v", err)
|
||||
}
|
||||
if _, err := store.RunVectorScan(ctx); err != nil {
|
||||
t.Fatalf("run vector scan: %v", err)
|
||||
}
|
||||
|
||||
created, err := store.CreateToken(ctx, CreateTokenInput{
|
||||
Name: "insar-v2",
|
||||
BasemapCodes: []string{"google_image"},
|
||||
VectorCodes: []string{"全国行政区.geojson", "landuse/parcel.shp"},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("create token: %v", err)
|
||||
}
|
||||
|
||||
auth, err := store.AuthorizeToken(ctx, created.Token)
|
||||
if err != nil {
|
||||
t.Fatalf("authorize token: %v", err)
|
||||
}
|
||||
|
||||
tile, err := store.ResolveDefaultTile(ctx, auth, "google_image", "0/0/0.webp")
|
||||
if err != nil {
|
||||
t.Fatalf("resolve default tile: %v", err)
|
||||
}
|
||||
if tile.Version.Version != "current" {
|
||||
t.Fatalf("unexpected default version: %s", tile.Version.Version)
|
||||
}
|
||||
|
||||
geojson, err := store.ResolveVectorDownloadByRef(ctx, auth, "全国行政区.geojson", "geojson")
|
||||
if err != nil {
|
||||
t.Fatalf("resolve geojson by path: %v", err)
|
||||
}
|
||||
if geojson.Asset.Format != "geojson" {
|
||||
t.Fatalf("unexpected geojson format: %s", geojson.Asset.Format)
|
||||
}
|
||||
if filepath.Base(geojson.FilePath) != "全国行政区.geojson" {
|
||||
t.Fatalf("unexpected geojson file: %s", geojson.FilePath)
|
||||
}
|
||||
|
||||
shp, err := store.ResolveVectorDownloadByRef(ctx, auth, "landuse/parcel.shp", "shapefile")
|
||||
if err != nil {
|
||||
t.Fatalf("resolve shapefile by path: %v", err)
|
||||
}
|
||||
if !shp.Zipped {
|
||||
t.Fatal("expected shapefile download to be zipped")
|
||||
}
|
||||
if shp.FileName != "parcel.zip" {
|
||||
t.Fatalf("unexpected shapefile name: %s", shp.FileName)
|
||||
}
|
||||
if len(shp.PackageFiles) != 3 {
|
||||
t.Fatalf("unexpected shapefile package size: %d", len(shp.PackageFiles))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
package basemap
|
||||
|
||||
import "context"
|
||||
|
||||
func (s *Store) Dashboard(ctx context.Context, info SystemInfo, flash string, createdToken string) (DashboardData, error) {
|
||||
basemaps, err := s.ListBasemaps(ctx)
|
||||
if err != nil {
|
||||
return DashboardData{}, err
|
||||
}
|
||||
vectorAssets, err := s.ListVectorAssets(ctx)
|
||||
if err != nil {
|
||||
return DashboardData{}, err
|
||||
}
|
||||
scanSources, err := s.ListScanSources(ctx)
|
||||
if err != nil {
|
||||
return DashboardData{}, err
|
||||
}
|
||||
scanRuns, err := s.ListScanRuns(ctx, 10)
|
||||
if err != nil {
|
||||
return DashboardData{}, err
|
||||
}
|
||||
targetSystems, err := s.ListTargetSystems(ctx)
|
||||
if err != nil {
|
||||
return DashboardData{}, err
|
||||
}
|
||||
tokens, err := s.ListTokens(ctx)
|
||||
if err != nil {
|
||||
return DashboardData{}, err
|
||||
}
|
||||
pushRecords, err := s.ListPushRecords(ctx, 10)
|
||||
if err != nil {
|
||||
return DashboardData{}, err
|
||||
}
|
||||
vectorPushRecords, err := s.ListVectorPushRecords(ctx, 10)
|
||||
if err != nil {
|
||||
return DashboardData{}, err
|
||||
}
|
||||
return DashboardData{
|
||||
SystemInfo: info,
|
||||
Basemaps: basemaps,
|
||||
VectorAssets: vectorAssets,
|
||||
ScanSources: scanSources,
|
||||
ScanRuns: scanRuns,
|
||||
TargetSystems: targetSystems,
|
||||
Tokens: tokens,
|
||||
PushRecords: pushRecords,
|
||||
VectorPushRecords: vectorPushRecords,
|
||||
Flash: flash,
|
||||
CreatedToken: createdToken,
|
||||
}, nil
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
package basemap
|
||||
|
||||
func defaultText(value, fallback string) string {
|
||||
if value != "" {
|
||||
return value
|
||||
}
|
||||
return fallback
|
||||
}
|
||||
@@ -0,0 +1,93 @@
|
||||
package basemap
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
)
|
||||
|
||||
func LoadManifest(path string) (Manifest, error) {
|
||||
content, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return Manifest{}, err
|
||||
}
|
||||
|
||||
var item Manifest
|
||||
if err := json.Unmarshal(content, &item); err != nil {
|
||||
return Manifest{}, fmt.Errorf("decode manifest %s: %w", path, err)
|
||||
}
|
||||
|
||||
item.Code = normalizeCode(item.Code)
|
||||
item.Name = strings.TrimSpace(item.Name)
|
||||
item.Type = defaultText(strings.TrimSpace(item.Type), "xyz")
|
||||
item.Description = strings.TrimSpace(item.Description)
|
||||
item.Version = strings.TrimSpace(item.Version)
|
||||
item.Status = defaultText(strings.TrimSpace(item.Status), "ready")
|
||||
item.TileFormat = normalizeTileFormat(item.TileFormat)
|
||||
item.TileScheme = defaultText(strings.TrimSpace(item.TileScheme), "xyz")
|
||||
item.Attribution = strings.TrimSpace(item.Attribution)
|
||||
item.RootPath = strings.TrimSpace(item.RootPath)
|
||||
if item.RootPath == "" {
|
||||
item.RootPath = "tiles"
|
||||
}
|
||||
if item.MaxZoom < item.MinZoom {
|
||||
return Manifest{}, fmt.Errorf("manifest %s has invalid zoom range", path)
|
||||
}
|
||||
if len(item.BBox) != 0 && len(item.BBox) != 4 {
|
||||
return Manifest{}, fmt.Errorf("manifest %s has invalid bbox", path)
|
||||
}
|
||||
if item.Code == "" {
|
||||
return Manifest{}, fmt.Errorf("manifest %s missing code", path)
|
||||
}
|
||||
if item.Name == "" {
|
||||
item.Name = item.Code
|
||||
}
|
||||
if item.Version == "" {
|
||||
item.Version = filepath.Base(filepath.Dir(path))
|
||||
}
|
||||
if item.Metadata == nil {
|
||||
item.Metadata = map[string]any{}
|
||||
}
|
||||
return item, nil
|
||||
}
|
||||
|
||||
func normalizeCode(value string) string {
|
||||
value = strings.TrimSpace(strings.ToLower(value))
|
||||
if value == "" {
|
||||
return ""
|
||||
}
|
||||
var builder strings.Builder
|
||||
lastDash := false
|
||||
for _, r := range value {
|
||||
switch {
|
||||
case r >= 'a' && r <= 'z':
|
||||
builder.WriteRune(r)
|
||||
lastDash = false
|
||||
case r >= '0' && r <= '9':
|
||||
builder.WriteRune(r)
|
||||
lastDash = false
|
||||
case r == '_' || r == '-' || r == '.' || r == ' ':
|
||||
if builder.Len() > 0 && !lastDash {
|
||||
builder.WriteByte('-')
|
||||
lastDash = true
|
||||
}
|
||||
}
|
||||
}
|
||||
return strings.Trim(builder.String(), "-")
|
||||
}
|
||||
|
||||
func normalizeTileFormat(value string) string {
|
||||
value = strings.TrimSpace(strings.ToLower(value))
|
||||
switch value {
|
||||
case "jpg", "jpeg":
|
||||
return "jpg"
|
||||
case "png", "webp", "pbf":
|
||||
return value
|
||||
case "":
|
||||
return "png"
|
||||
default:
|
||||
return value
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,235 @@
|
||||
package basemap
|
||||
|
||||
import "time"
|
||||
|
||||
type SystemInfo struct {
|
||||
ServiceName string `json:"service_name"`
|
||||
ServiceVersion string `json:"service_version"`
|
||||
Commit string `json:"commit"`
|
||||
Env string `json:"env"`
|
||||
APIBaseURL string `json:"api_base_url"`
|
||||
TileBaseURL string `json:"tile_base_url"`
|
||||
VectorBaseURL string `json:"vector_base_url"`
|
||||
SQLitePath string `json:"sqlite_path"`
|
||||
ScanRoot string `json:"scan_root"`
|
||||
VectorScanRoot string `json:"vector_scan_root"`
|
||||
StartedAt time.Time `json:"started_at"`
|
||||
}
|
||||
|
||||
type Basemap struct {
|
||||
ID string `json:"id"`
|
||||
Code string `json:"code"`
|
||||
Name string `json:"name"`
|
||||
Type string `json:"type"`
|
||||
Status string `json:"status"`
|
||||
Description string `json:"description,omitempty"`
|
||||
Default *BasemapVersion `json:"default_version,omitempty"`
|
||||
Versions []BasemapVersion `json:"versions,omitempty"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
}
|
||||
|
||||
type BasemapVersion struct {
|
||||
ID string `json:"id"`
|
||||
BasemapID string `json:"basemap_id"`
|
||||
BasemapCode string `json:"basemap_code"`
|
||||
Version string `json:"version"`
|
||||
Status string `json:"status"`
|
||||
IsDefault bool `json:"is_default"`
|
||||
ManifestPath string `json:"manifest_path"`
|
||||
TileRootPath string `json:"tile_root_path"`
|
||||
URLTemplate string `json:"url_template"`
|
||||
TileFormat string `json:"tile_format"`
|
||||
TileScheme string `json:"tile_scheme"`
|
||||
MinZoom int `json:"min_zoom"`
|
||||
MaxZoom int `json:"max_zoom"`
|
||||
BBox []float64 `json:"bbox,omitempty"`
|
||||
Attribution string `json:"attribution,omitempty"`
|
||||
Metadata map[string]any `json:"metadata,omitempty"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
}
|
||||
|
||||
type ScanSource struct {
|
||||
ID string `json:"id"`
|
||||
Code string `json:"code"`
|
||||
Name string `json:"name"`
|
||||
RootPath string `json:"root_path"`
|
||||
ManifestName string `json:"manifest_name"`
|
||||
Enabled bool `json:"enabled"`
|
||||
Metadata map[string]any `json:"metadata,omitempty"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
}
|
||||
|
||||
type ScanRun struct {
|
||||
ID string `json:"id"`
|
||||
ScanSourceID string `json:"scan_source_id"`
|
||||
SourceCode string `json:"source_code"`
|
||||
Status string `json:"status"`
|
||||
ScannedCount int `json:"scanned_count"`
|
||||
AddedCount int `json:"added_count"`
|
||||
UpdatedCount int `json:"updated_count"`
|
||||
RemovedCount int `json:"removed_count"`
|
||||
Summary map[string]any `json:"summary,omitempty"`
|
||||
StartedAt time.Time `json:"started_at"`
|
||||
FinishedAt time.Time `json:"finished_at"`
|
||||
}
|
||||
|
||||
type TargetSystem struct {
|
||||
ID string `json:"id"`
|
||||
Code string `json:"code"`
|
||||
Name string `json:"name"`
|
||||
CallbackURL string `json:"callback_url"`
|
||||
CallbackMethod string `json:"callback_method"`
|
||||
CallbackHeaders map[string]string `json:"callback_headers,omitempty"`
|
||||
Enabled bool `json:"enabled"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
}
|
||||
|
||||
type ServiceToken struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
TokenPrefix string `json:"token_prefix"`
|
||||
Status string `json:"status"`
|
||||
ExpiresAt *time.Time `json:"expires_at,omitempty"`
|
||||
LastUsedAt *time.Time `json:"last_used_at,omitempty"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
Grants []TokenGrant `json:"grants,omitempty"`
|
||||
VectorGrants []VectorTokenGrant `json:"vector_grants,omitempty"`
|
||||
}
|
||||
|
||||
type TokenGrant struct {
|
||||
ID string `json:"id"`
|
||||
TokenID string `json:"token_id"`
|
||||
BasemapID string `json:"basemap_id"`
|
||||
BasemapCode string `json:"basemap_code"`
|
||||
BasemapVersionID *string `json:"basemap_version_id,omitempty"`
|
||||
BasemapVersion *string `json:"basemap_version,omitempty"`
|
||||
Permission string `json:"permission"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
}
|
||||
|
||||
type PushRecord struct {
|
||||
ID string `json:"id"`
|
||||
TargetSystemID string `json:"target_system_id"`
|
||||
TargetSystemCode string `json:"target_system_code"`
|
||||
BasemapVersionID string `json:"basemap_version_id"`
|
||||
BasemapCode string `json:"basemap_code"`
|
||||
BasemapVersion string `json:"basemap_version"`
|
||||
Status string `json:"status"`
|
||||
Request map[string]any `json:"request,omitempty"`
|
||||
ResponseStatus *int `json:"response_status,omitempty"`
|
||||
ResponseBody string `json:"response_body,omitempty"`
|
||||
ErrorMessage string `json:"error_message,omitempty"`
|
||||
PushedAt time.Time `json:"pushed_at"`
|
||||
FinishedAt *time.Time `json:"finished_at,omitempty"`
|
||||
}
|
||||
|
||||
type VectorAsset struct {
|
||||
ID string `json:"id"`
|
||||
Code string `json:"code"`
|
||||
Name string `json:"name"`
|
||||
Format string `json:"format"`
|
||||
Status string `json:"status"`
|
||||
Description string `json:"description,omitempty"`
|
||||
FilePath string `json:"file_path"`
|
||||
FileName string `json:"file_name"`
|
||||
FileSize int64 `json:"file_size"`
|
||||
Checksum string `json:"checksum"`
|
||||
DownloadURL string `json:"download_url"`
|
||||
Metadata map[string]any `json:"metadata,omitempty"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
}
|
||||
|
||||
type VectorTokenGrant struct {
|
||||
ID string `json:"id"`
|
||||
TokenID string `json:"token_id"`
|
||||
VectorID string `json:"vector_id"`
|
||||
VectorCode string `json:"vector_code"`
|
||||
Permission string `json:"permission"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
}
|
||||
|
||||
type VectorPushRecord struct {
|
||||
ID string `json:"id"`
|
||||
TargetSystemID string `json:"target_system_id"`
|
||||
TargetSystemCode string `json:"target_system_code"`
|
||||
VectorAssetID string `json:"vector_asset_id"`
|
||||
VectorCode string `json:"vector_code"`
|
||||
Status string `json:"status"`
|
||||
Request map[string]any `json:"request,omitempty"`
|
||||
ResponseStatus *int `json:"response_status,omitempty"`
|
||||
ResponseBody string `json:"response_body,omitempty"`
|
||||
ErrorMessage string `json:"error_message,omitempty"`
|
||||
PushedAt time.Time `json:"pushed_at"`
|
||||
FinishedAt *time.Time `json:"finished_at,omitempty"`
|
||||
}
|
||||
|
||||
type Manifest struct {
|
||||
Code string `json:"code"`
|
||||
Name string `json:"name"`
|
||||
Type string `json:"type"`
|
||||
Description string `json:"description"`
|
||||
Version string `json:"version"`
|
||||
Status string `json:"status"`
|
||||
TileFormat string `json:"tile_format"`
|
||||
TileScheme string `json:"tile_scheme"`
|
||||
MinZoom int `json:"min_zoom"`
|
||||
MaxZoom int `json:"max_zoom"`
|
||||
BBox []float64 `json:"bbox"`
|
||||
Attribution string `json:"attribution"`
|
||||
RootPath string `json:"root_path"`
|
||||
IsDefault bool `json:"is_default"`
|
||||
Metadata map[string]any `json:"metadata"`
|
||||
}
|
||||
|
||||
type CreateScanSourceInput struct {
|
||||
Code string `json:"code"`
|
||||
Name string `json:"name"`
|
||||
RootPath string `json:"root_path"`
|
||||
ManifestName string `json:"manifest_name"`
|
||||
}
|
||||
|
||||
type CreateTargetSystemInput struct {
|
||||
Code string `json:"code"`
|
||||
Name string `json:"name"`
|
||||
CallbackURL string `json:"callback_url"`
|
||||
CallbackMethod string `json:"callback_method"`
|
||||
CallbackHeaders map[string]string `json:"callback_headers"`
|
||||
}
|
||||
|
||||
type CreateTokenInput struct {
|
||||
Name string
|
||||
BasemapCodes []string
|
||||
VectorCodes []string
|
||||
ExpiresAt *time.Time
|
||||
}
|
||||
|
||||
type TokenAuth struct {
|
||||
Token ServiceToken
|
||||
// permission -> basemap_code -> version scope
|
||||
Permissions map[string]map[string]VersionScope
|
||||
VectorPermissions map[string]map[string]struct{}
|
||||
}
|
||||
|
||||
type VersionScope struct {
|
||||
AllVersions bool
|
||||
Versions map[string]struct{}
|
||||
}
|
||||
|
||||
type DashboardData struct {
|
||||
SystemInfo SystemInfo
|
||||
Basemaps []Basemap
|
||||
VectorAssets []VectorAsset
|
||||
ScanSources []ScanSource
|
||||
ScanRuns []ScanRun
|
||||
TargetSystems []TargetSystem
|
||||
Tokens []ServiceToken
|
||||
PushRecords []PushRecord
|
||||
VectorPushRecords []VectorPushRecord
|
||||
Flash string
|
||||
CreatedToken string
|
||||
}
|
||||
@@ -0,0 +1,327 @@
|
||||
package basemap
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"map-asset-gateway/api-go/internal/uid"
|
||||
)
|
||||
|
||||
func (s *Store) CreateTargetSystem(ctx context.Context, input CreateTargetSystemInput) (TargetSystem, error) {
|
||||
code := normalizeBasemapCode(input.Code)
|
||||
if code == "" {
|
||||
return TargetSystem{}, errors.New("target system code is required")
|
||||
}
|
||||
name := strings.TrimSpace(input.Name)
|
||||
if name == "" {
|
||||
name = code
|
||||
}
|
||||
callbackURL := strings.TrimSpace(input.CallbackURL)
|
||||
if callbackURL == "" {
|
||||
return TargetSystem{}, errors.New("callback url is required")
|
||||
}
|
||||
method := strings.ToUpper(strings.TrimSpace(input.CallbackMethod))
|
||||
if method == "" {
|
||||
method = http.MethodPost
|
||||
}
|
||||
if input.CallbackHeaders == nil {
|
||||
input.CallbackHeaders = map[string]string{}
|
||||
}
|
||||
|
||||
now := nowUTC()
|
||||
id := uid.Deterministic("target-system", code)
|
||||
_, err := s.db.ExecContext(ctx, `
|
||||
INSERT INTO target_systems (
|
||||
id, code, name, callback_url, callback_method, callback_headers_json, enabled, created_at, updated_at
|
||||
) VALUES (?, ?, ?, ?, ?, ?, 1, ?, ?)
|
||||
ON CONFLICT(code) DO UPDATE SET
|
||||
name = excluded.name,
|
||||
callback_url = excluded.callback_url,
|
||||
callback_method = excluded.callback_method,
|
||||
callback_headers_json = excluded.callback_headers_json,
|
||||
enabled = 1,
|
||||
updated_at = excluded.updated_at
|
||||
`, id, code, name, callbackURL, method, writeJSON(input.CallbackHeaders), toRFC3339(now), toRFC3339(now))
|
||||
if err != nil {
|
||||
return TargetSystem{}, err
|
||||
}
|
||||
return s.GetTargetSystemByCode(ctx, code)
|
||||
}
|
||||
|
||||
func (s *Store) GetTargetSystemByCode(ctx context.Context, code string) (TargetSystem, error) {
|
||||
row := s.db.QueryRowContext(ctx, `
|
||||
SELECT id, code, name, callback_url, callback_method, callback_headers_json, enabled, created_at, updated_at
|
||||
FROM target_systems
|
||||
WHERE code = ?
|
||||
`, normalizeBasemapCode(code))
|
||||
item, err := scanTargetSystem(row)
|
||||
if err != nil {
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return TargetSystem{}, fmt.Errorf("target system %q not found", code)
|
||||
}
|
||||
return TargetSystem{}, err
|
||||
}
|
||||
return item, nil
|
||||
}
|
||||
|
||||
func (s *Store) ListTargetSystems(ctx context.Context) ([]TargetSystem, error) {
|
||||
rows, err := s.db.QueryContext(ctx, `
|
||||
SELECT id, code, name, callback_url, callback_method, callback_headers_json, enabled, created_at, updated_at
|
||||
FROM target_systems
|
||||
ORDER BY code
|
||||
`)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var items []TargetSystem
|
||||
for rows.Next() {
|
||||
item, err := scanTargetSystem(rows)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
items = append(items, item)
|
||||
}
|
||||
return items, rows.Err()
|
||||
}
|
||||
|
||||
func (s *Store) PushBasemapVersion(ctx context.Context, targetCode, basemapCode, version string) (PushRecord, error) {
|
||||
target, err := s.GetTargetSystemByCode(ctx, targetCode)
|
||||
if err != nil {
|
||||
return PushRecord{}, err
|
||||
}
|
||||
if !target.Enabled {
|
||||
return PushRecord{}, fmt.Errorf("target system %q is disabled", target.Code)
|
||||
}
|
||||
basemapCode = normalizeBasemapCode(basemapCode)
|
||||
version = strings.TrimSpace(version)
|
||||
|
||||
row := s.db.QueryRowContext(ctx, `
|
||||
SELECT
|
||||
v.id,
|
||||
v.basemap_id,
|
||||
b.code,
|
||||
v.version,
|
||||
v.status,
|
||||
v.is_default,
|
||||
v.manifest_path,
|
||||
v.tile_root_path,
|
||||
v.url_template,
|
||||
v.tile_format,
|
||||
v.tile_scheme,
|
||||
v.min_zoom,
|
||||
v.max_zoom,
|
||||
v.bbox_json,
|
||||
v.attribution,
|
||||
v.metadata_json,
|
||||
v.created_at,
|
||||
v.updated_at
|
||||
FROM basemap_versions v
|
||||
JOIN basemaps b ON b.id = v.basemap_id
|
||||
WHERE b.code = ? AND v.version = ?
|
||||
`, basemapCode, version)
|
||||
versionRow, err := scanBasemapVersionRow(row)
|
||||
if err != nil {
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return PushRecord{}, fmt.Errorf("basemap version %s/%s not found", basemapCode, version)
|
||||
}
|
||||
return PushRecord{}, err
|
||||
}
|
||||
versionItem := decodeBasemapVersion(versionRow)
|
||||
basemapItem, err := s.GetBasemapByCode(ctx, basemapCode)
|
||||
if err != nil {
|
||||
return PushRecord{}, err
|
||||
}
|
||||
|
||||
payload := map[string]any{
|
||||
"event": "basemap.published",
|
||||
"service": strings.TrimSpace(s.cfg.APIBaseURL),
|
||||
"basemap": basemapItem,
|
||||
"version": versionItem,
|
||||
"pushed_at": toRFC3339(nowUTC()),
|
||||
"targetCode": target.Code,
|
||||
}
|
||||
body, err := json.Marshal(payload)
|
||||
if err != nil {
|
||||
return PushRecord{}, err
|
||||
}
|
||||
request, err := http.NewRequestWithContext(ctx, target.CallbackMethod, target.CallbackURL, bytes.NewReader(body))
|
||||
if err != nil {
|
||||
return PushRecord{}, err
|
||||
}
|
||||
request.Header.Set("Content-Type", "application/json")
|
||||
for key, value := range target.CallbackHeaders {
|
||||
request.Header.Set(key, value)
|
||||
}
|
||||
|
||||
response, err := s.httpClient.Do(request)
|
||||
record := PushRecord{
|
||||
ID: uid.New(),
|
||||
TargetSystemID: target.ID,
|
||||
TargetSystemCode: target.Code,
|
||||
BasemapVersionID: versionItem.ID,
|
||||
BasemapCode: basemapItem.Code,
|
||||
BasemapVersion: versionItem.Version,
|
||||
Request: payload,
|
||||
PushedAt: nowUTC(),
|
||||
}
|
||||
if err != nil {
|
||||
record.Status = "failed"
|
||||
record.ErrorMessage = err.Error()
|
||||
} else {
|
||||
defer response.Body.Close()
|
||||
record.ResponseBody = limitedReadAll(response.Body, 256*1024)
|
||||
status := response.StatusCode
|
||||
record.ResponseStatus = &status
|
||||
if status >= 200 && status < 300 {
|
||||
record.Status = "succeeded"
|
||||
} else {
|
||||
record.Status = "failed"
|
||||
record.ErrorMessage = fmt.Sprintf("target returned status %d", status)
|
||||
}
|
||||
}
|
||||
finishedAt := nowUTC()
|
||||
record.FinishedAt = &finishedAt
|
||||
|
||||
_, insertErr := s.db.ExecContext(ctx, `
|
||||
INSERT INTO push_records (
|
||||
id, target_system_id, basemap_version_id, status, request_json, response_status, response_body, error_message, pushed_at, finished_at
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
`, record.ID, record.TargetSystemID, record.BasemapVersionID, record.Status, writeJSON(record.Request), record.ResponseStatus, record.ResponseBody, record.ErrorMessage, toRFC3339(record.PushedAt), nullableTime(record.FinishedAt))
|
||||
if insertErr != nil {
|
||||
return PushRecord{}, insertErr
|
||||
}
|
||||
if err != nil {
|
||||
return record, err
|
||||
}
|
||||
if record.Status != "succeeded" {
|
||||
return record, errors.New(record.ErrorMessage)
|
||||
}
|
||||
return record, nil
|
||||
}
|
||||
|
||||
func (s *Store) ListVectorPushRecords(ctx context.Context, limit int) ([]VectorPushRecord, error) {
|
||||
if limit <= 0 {
|
||||
limit = 20
|
||||
}
|
||||
rows, err := s.db.QueryContext(ctx, `
|
||||
SELECT
|
||||
p.id,
|
||||
p.target_system_id,
|
||||
t.code,
|
||||
p.vector_asset_id,
|
||||
v.code,
|
||||
p.status,
|
||||
p.request_json,
|
||||
p.response_status,
|
||||
p.response_body,
|
||||
p.error_message,
|
||||
p.pushed_at,
|
||||
p.finished_at
|
||||
FROM vector_push_records p
|
||||
JOIN target_systems t ON t.id = p.target_system_id
|
||||
JOIN vector_assets v ON v.id = p.vector_asset_id
|
||||
ORDER BY p.pushed_at DESC
|
||||
LIMIT ?
|
||||
`, limit)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var items []VectorPushRecord
|
||||
for rows.Next() {
|
||||
item, err := scanVectorPushRecord(rows)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
items = append(items, item)
|
||||
}
|
||||
return items, rows.Err()
|
||||
}
|
||||
|
||||
func (s *Store) PushVectorAsset(ctx context.Context, targetCode, vectorCode string) (VectorPushRecord, error) {
|
||||
target, err := s.GetTargetSystemByCode(ctx, targetCode)
|
||||
if err != nil {
|
||||
return VectorPushRecord{}, err
|
||||
}
|
||||
if !target.Enabled {
|
||||
return VectorPushRecord{}, fmt.Errorf("target system %q is disabled", target.Code)
|
||||
}
|
||||
vectorCode = normalizeBasemapCode(vectorCode)
|
||||
asset, err := s.GetVectorAssetByCode(ctx, vectorCode)
|
||||
if err != nil {
|
||||
return VectorPushRecord{}, err
|
||||
}
|
||||
|
||||
payload := map[string]any{
|
||||
"event": "vector.published",
|
||||
"service": strings.TrimSpace(s.cfg.APIBaseURL),
|
||||
"vector": asset,
|
||||
"pushed_at": toRFC3339(nowUTC()),
|
||||
"targetCode": target.Code,
|
||||
}
|
||||
body, err := json.Marshal(payload)
|
||||
if err != nil {
|
||||
return VectorPushRecord{}, err
|
||||
}
|
||||
request, err := http.NewRequestWithContext(ctx, target.CallbackMethod, target.CallbackURL, bytes.NewReader(body))
|
||||
if err != nil {
|
||||
return VectorPushRecord{}, err
|
||||
}
|
||||
request.Header.Set("Content-Type", "application/json")
|
||||
for key, value := range target.CallbackHeaders {
|
||||
request.Header.Set(key, value)
|
||||
}
|
||||
|
||||
response, err := s.httpClient.Do(request)
|
||||
record := VectorPushRecord{
|
||||
ID: uid.New(),
|
||||
TargetSystemID: target.ID,
|
||||
TargetSystemCode: target.Code,
|
||||
VectorAssetID: asset.ID,
|
||||
VectorCode: asset.Code,
|
||||
Request: payload,
|
||||
PushedAt: nowUTC(),
|
||||
}
|
||||
if err != nil {
|
||||
record.Status = "failed"
|
||||
record.ErrorMessage = err.Error()
|
||||
} else {
|
||||
defer response.Body.Close()
|
||||
record.ResponseBody = limitedReadAll(response.Body, 256*1024)
|
||||
status := response.StatusCode
|
||||
record.ResponseStatus = &status
|
||||
if status >= 200 && status < 300 {
|
||||
record.Status = "succeeded"
|
||||
} else {
|
||||
record.Status = "failed"
|
||||
record.ErrorMessage = fmt.Sprintf("target returned status %d", status)
|
||||
}
|
||||
}
|
||||
finishedAt := nowUTC()
|
||||
record.FinishedAt = &finishedAt
|
||||
|
||||
_, insertErr := s.db.ExecContext(ctx, `
|
||||
INSERT INTO vector_push_records (
|
||||
id, target_system_id, vector_asset_id, status, request_json, response_status, response_body, error_message, pushed_at, finished_at
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
`, record.ID, record.TargetSystemID, record.VectorAssetID, record.Status, writeJSON(record.Request), record.ResponseStatus, record.ResponseBody, record.ErrorMessage, toRFC3339(record.PushedAt), nullableTime(record.FinishedAt))
|
||||
if insertErr != nil {
|
||||
return VectorPushRecord{}, insertErr
|
||||
}
|
||||
if err != nil {
|
||||
return record, err
|
||||
}
|
||||
if record.Status != "succeeded" {
|
||||
return record, errors.New(record.ErrorMessage)
|
||||
}
|
||||
return record, nil
|
||||
}
|
||||
@@ -0,0 +1,578 @@
|
||||
package basemap
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"map-asset-gateway/api-go/internal/uid"
|
||||
)
|
||||
|
||||
type scannedVersion struct {
|
||||
BasemapCode string
|
||||
Name string
|
||||
Type string
|
||||
Status string
|
||||
Description string
|
||||
Version string
|
||||
Manifest string
|
||||
TileRoot string
|
||||
TileFormat string
|
||||
TileScheme string
|
||||
MinZoom int
|
||||
MaxZoom int
|
||||
BBox []float64
|
||||
Attribution string
|
||||
Metadata map[string]any
|
||||
IsDefault bool
|
||||
}
|
||||
|
||||
func (s *Store) RunScan(ctx context.Context, sourceCode string) (ScanRun, error) {
|
||||
source, err := s.GetScanSourceByCode(ctx, sourceCode)
|
||||
if err != nil {
|
||||
return ScanRun{}, err
|
||||
}
|
||||
if !source.Enabled {
|
||||
return ScanRun{}, fmt.Errorf("scan source %q is disabled", sourceCode)
|
||||
}
|
||||
|
||||
startedAt := nowUTC()
|
||||
runID := uid.New()
|
||||
if _, err := s.db.ExecContext(ctx, `
|
||||
INSERT INTO scan_runs (
|
||||
id, scan_source_id, status, scanned_count, added_count, updated_count, removed_count, summary_json, started_at, finished_at
|
||||
) VALUES (?, ?, 'running', 0, 0, 0, 0, '', ?, ?)
|
||||
`, runID, source.ID, toRFC3339(startedAt), toRFC3339(startedAt)); err != nil {
|
||||
return ScanRun{}, fmt.Errorf("create scan run: %w", err)
|
||||
}
|
||||
|
||||
run, finalErr := s.runScan(ctx, runID, source, startedAt)
|
||||
return run, finalErr
|
||||
}
|
||||
|
||||
func (s *Store) runScan(ctx context.Context, runID string, source ScanSource, startedAt time.Time) (ScanRun, error) {
|
||||
rootInfo, err := os.Stat(source.RootPath)
|
||||
if err != nil {
|
||||
return s.finishScanRun(ctx, runID, source, startedAt, "failed", nil, 0, 0, 0, 0, fmt.Errorf("stat scan root: %w", err))
|
||||
}
|
||||
if !rootInfo.IsDir() {
|
||||
return s.finishScanRun(ctx, runID, source, startedAt, "failed", nil, 0, 0, 0, 0, fmt.Errorf("scan root %s is not a directory", source.RootPath))
|
||||
}
|
||||
|
||||
manifestItems, coveredRoots, err := s.findManifestVersions(source)
|
||||
if err != nil {
|
||||
return s.finishScanRun(ctx, runID, source, startedAt, "failed", nil, 0, 0, 0, 0, err)
|
||||
}
|
||||
fallbackItems, err := s.findDirectTileRoots(source, coveredRoots)
|
||||
if err != nil {
|
||||
return s.finishScanRun(ctx, runID, source, startedAt, "failed", nil, 0, 0, 0, 0, err)
|
||||
}
|
||||
|
||||
allItems := append(manifestItems, fallbackItems...)
|
||||
sort.Slice(allItems, func(i, j int) bool {
|
||||
if allItems[i].BasemapCode == allItems[j].BasemapCode {
|
||||
return allItems[i].Version < allItems[j].Version
|
||||
}
|
||||
return allItems[i].BasemapCode < allItems[j].BasemapCode
|
||||
})
|
||||
|
||||
addedCount, updatedCount, removedCount, err := s.persistScanResult(ctx, source, allItems)
|
||||
if err != nil {
|
||||
return s.finishScanRun(ctx, runID, source, startedAt, "failed", allItems, len(allItems), addedCount, updatedCount, removedCount, err)
|
||||
}
|
||||
return s.finishScanRun(ctx, runID, source, startedAt, "succeeded", allItems, len(allItems), addedCount, updatedCount, removedCount, nil)
|
||||
}
|
||||
|
||||
func (s *Store) finishScanRun(
|
||||
ctx context.Context,
|
||||
runID string,
|
||||
source ScanSource,
|
||||
startedAt time.Time,
|
||||
status string,
|
||||
items []scannedVersion,
|
||||
scannedCount int,
|
||||
addedCount int,
|
||||
updatedCount int,
|
||||
removedCount int,
|
||||
runErr error,
|
||||
) (ScanRun, error) {
|
||||
finishedAt := nowUTC()
|
||||
summary := map[string]any{
|
||||
"source_code": source.Code,
|
||||
"scan_root": source.RootPath,
|
||||
"scanned_count": scannedCount,
|
||||
"added_count": addedCount,
|
||||
"updated_count": updatedCount,
|
||||
"removed_count": removedCount,
|
||||
}
|
||||
if runErr != nil {
|
||||
summary["error"] = runErr.Error()
|
||||
}
|
||||
if len(items) > 0 {
|
||||
codes := map[string]struct{}{}
|
||||
for _, item := range items {
|
||||
codes[item.BasemapCode] = struct{}{}
|
||||
}
|
||||
summary["basemap_codes"] = sortedKeys(codes)
|
||||
}
|
||||
|
||||
_, updateErr := s.db.ExecContext(ctx, `
|
||||
UPDATE scan_runs
|
||||
SET status = ?, scanned_count = ?, added_count = ?, updated_count = ?, removed_count = ?, summary_json = ?, finished_at = ?
|
||||
WHERE id = ?
|
||||
`, status, scannedCount, addedCount, updatedCount, removedCount, writeJSON(summary), toRFC3339(finishedAt), runID)
|
||||
if updateErr != nil {
|
||||
if runErr != nil {
|
||||
return ScanRun{}, fmt.Errorf("%v; update scan run: %w", runErr, updateErr)
|
||||
}
|
||||
return ScanRun{}, updateErr
|
||||
}
|
||||
|
||||
run := ScanRun{
|
||||
ID: runID,
|
||||
ScanSourceID: source.ID,
|
||||
SourceCode: source.Code,
|
||||
Status: status,
|
||||
ScannedCount: scannedCount,
|
||||
AddedCount: addedCount,
|
||||
UpdatedCount: updatedCount,
|
||||
RemovedCount: removedCount,
|
||||
Summary: summary,
|
||||
StartedAt: startedAt,
|
||||
FinishedAt: finishedAt,
|
||||
}
|
||||
if runErr != nil {
|
||||
return run, runErr
|
||||
}
|
||||
return run, nil
|
||||
}
|
||||
|
||||
func (s *Store) findManifestVersions(source ScanSource) ([]scannedVersion, map[string]struct{}, error) {
|
||||
manifestName := strings.TrimSpace(source.ManifestName)
|
||||
if manifestName == "" {
|
||||
manifestName = defaultManifestName
|
||||
}
|
||||
|
||||
var items []scannedVersion
|
||||
coveredRoots := map[string]struct{}{}
|
||||
err := filepath.WalkDir(source.RootPath, func(path string, entry os.DirEntry, walkErr error) error {
|
||||
if walkErr != nil {
|
||||
return walkErr
|
||||
}
|
||||
if entry.IsDir() {
|
||||
return nil
|
||||
}
|
||||
if !strings.EqualFold(entry.Name(), manifestName) {
|
||||
return nil
|
||||
}
|
||||
|
||||
manifest, err := LoadManifest(path)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
tileRoot, err := resolveTileRoot(filepath.Dir(path), manifest.RootPath)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
format, minZoom, maxZoom, found, err := inspectTileRoot(tileRoot, manifest.TileFormat)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if found {
|
||||
manifest.TileFormat = format
|
||||
manifest.MinZoom = minZoom
|
||||
manifest.MaxZoom = maxZoom
|
||||
}
|
||||
|
||||
relPath, err := filepath.Rel(source.RootPath, tileRoot)
|
||||
if err == nil {
|
||||
first := strings.Split(filepath.ToSlash(relPath), "/")[0]
|
||||
if first != "" && first != "." {
|
||||
coveredRoots[first] = struct{}{}
|
||||
}
|
||||
}
|
||||
|
||||
items = append(items, scannedVersion{
|
||||
BasemapCode: manifest.Code,
|
||||
Name: defaultText(manifest.Name, manifest.Code),
|
||||
Type: defaultText(manifest.Type, "xyz"),
|
||||
Status: defaultText(manifest.Status, "ready"),
|
||||
Description: manifest.Description,
|
||||
Version: defaultText(manifest.Version, "current"),
|
||||
Manifest: path,
|
||||
TileRoot: tileRoot,
|
||||
TileFormat: normalizeTileFormat(manifest.TileFormat),
|
||||
TileScheme: defaultText(manifest.TileScheme, "xyz"),
|
||||
MinZoom: manifest.MinZoom,
|
||||
MaxZoom: manifest.MaxZoom,
|
||||
BBox: manifest.BBox,
|
||||
Attribution: manifest.Attribution,
|
||||
Metadata: manifest.Metadata,
|
||||
IsDefault: manifest.IsDefault,
|
||||
})
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
return nil, nil, fmt.Errorf("walk manifests: %w", err)
|
||||
}
|
||||
return items, coveredRoots, nil
|
||||
}
|
||||
|
||||
func (s *Store) findDirectTileRoots(source ScanSource, skip map[string]struct{}) ([]scannedVersion, error) {
|
||||
entries, err := os.ReadDir(source.RootPath)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("read scan root: %w", err)
|
||||
}
|
||||
|
||||
var items []scannedVersion
|
||||
for _, entry := range entries {
|
||||
if !entry.IsDir() {
|
||||
continue
|
||||
}
|
||||
name := entry.Name()
|
||||
if _, exists := skip[name]; exists {
|
||||
continue
|
||||
}
|
||||
|
||||
rootPath := filepath.Join(source.RootPath, name)
|
||||
format, minZoom, maxZoom, found, err := inspectTileRoot(rootPath, "")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if !found {
|
||||
continue
|
||||
}
|
||||
|
||||
code := normalizeBasemapCode(name)
|
||||
if code == "" {
|
||||
continue
|
||||
}
|
||||
items = append(items, scannedVersion{
|
||||
BasemapCode: code,
|
||||
Name: name,
|
||||
Type: "xyz",
|
||||
Status: "ready",
|
||||
Version: "current",
|
||||
TileRoot: rootPath,
|
||||
TileFormat: format,
|
||||
TileScheme: "xyz",
|
||||
MinZoom: minZoom,
|
||||
MaxZoom: maxZoom,
|
||||
Metadata: map[string]any{
|
||||
"discovered_from": "directory_scan",
|
||||
},
|
||||
IsDefault: true,
|
||||
})
|
||||
}
|
||||
return items, nil
|
||||
}
|
||||
|
||||
func inspectTileRoot(rootPath string, hintFormat string) (string, int, int, bool, error) {
|
||||
entries, err := os.ReadDir(rootPath)
|
||||
if err != nil {
|
||||
if errors.Is(err, os.ErrNotExist) {
|
||||
return "", 0, 0, false, nil
|
||||
}
|
||||
return "", 0, 0, false, fmt.Errorf("read tile root %s: %w", rootPath, err)
|
||||
}
|
||||
|
||||
var zooms []int
|
||||
format := normalizeTileFormat(hintFormat)
|
||||
for _, entry := range entries {
|
||||
if !entry.IsDir() {
|
||||
continue
|
||||
}
|
||||
zoom, err := strconv.Atoi(entry.Name())
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
zooms = append(zooms, zoom)
|
||||
if format == "" || format == "png" {
|
||||
detected, found, err := detectTileFormat(filepath.Join(rootPath, entry.Name()))
|
||||
if err != nil {
|
||||
return "", 0, 0, false, err
|
||||
}
|
||||
if found {
|
||||
format = detected
|
||||
}
|
||||
}
|
||||
}
|
||||
if len(zooms) == 0 {
|
||||
return "", 0, 0, false, nil
|
||||
}
|
||||
sort.Ints(zooms)
|
||||
if format == "" {
|
||||
format = "png"
|
||||
}
|
||||
return format, zooms[0], zooms[len(zooms)-1], true, nil
|
||||
}
|
||||
|
||||
func detectTileFormat(zoomPath string) (string, bool, error) {
|
||||
xEntries, err := os.ReadDir(zoomPath)
|
||||
if err != nil {
|
||||
return "", false, fmt.Errorf("read zoom path %s: %w", zoomPath, err)
|
||||
}
|
||||
for _, xEntry := range xEntries {
|
||||
if !xEntry.IsDir() {
|
||||
continue
|
||||
}
|
||||
tileEntries, err := os.ReadDir(filepath.Join(zoomPath, xEntry.Name()))
|
||||
if err != nil {
|
||||
return "", false, err
|
||||
}
|
||||
for _, tileEntry := range tileEntries {
|
||||
if tileEntry.IsDir() {
|
||||
continue
|
||||
}
|
||||
ext := strings.TrimPrefix(strings.ToLower(filepath.Ext(tileEntry.Name())), ".")
|
||||
if ext == "" {
|
||||
continue
|
||||
}
|
||||
return normalizeTileFormat(ext), true, nil
|
||||
}
|
||||
}
|
||||
return "", false, nil
|
||||
}
|
||||
|
||||
func resolveTileRoot(baseDir, relativePath string) (string, error) {
|
||||
if strings.TrimSpace(relativePath) == "" {
|
||||
relativePath = "tiles"
|
||||
}
|
||||
if filepath.IsAbs(relativePath) {
|
||||
return filepath.Clean(relativePath), nil
|
||||
}
|
||||
resolved := filepath.Clean(filepath.Join(baseDir, relativePath))
|
||||
return resolved, nil
|
||||
}
|
||||
|
||||
func (s *Store) persistScanResult(ctx context.Context, source ScanSource, items []scannedVersion) (int, int, int, error) {
|
||||
tx, err := s.db.BeginTx(ctx, nil)
|
||||
if err != nil {
|
||||
return 0, 0, 0, err
|
||||
}
|
||||
defer tx.Rollback()
|
||||
|
||||
existing := map[string]struct{}{}
|
||||
rows, err := tx.QueryContext(ctx, `
|
||||
SELECT b.code, v.version
|
||||
FROM basemap_versions v
|
||||
JOIN basemaps b ON b.id = v.basemap_id
|
||||
WHERE v.scan_source_id = ?
|
||||
`, source.ID)
|
||||
if err != nil {
|
||||
return 0, 0, 0, err
|
||||
}
|
||||
for rows.Next() {
|
||||
var code string
|
||||
var version string
|
||||
if err := rows.Scan(&code, &version); err != nil {
|
||||
rows.Close()
|
||||
return 0, 0, 0, err
|
||||
}
|
||||
existing[code+"::"+version] = struct{}{}
|
||||
}
|
||||
rows.Close()
|
||||
if err := rows.Err(); err != nil {
|
||||
return 0, 0, 0, err
|
||||
}
|
||||
|
||||
now := nowUTC()
|
||||
found := map[string]struct{}{}
|
||||
touchedBasemaps := map[string]struct{}{}
|
||||
addedCount := 0
|
||||
updatedCount := 0
|
||||
for _, item := range items {
|
||||
key := item.BasemapCode + "::" + item.Version
|
||||
found[key] = struct{}{}
|
||||
if _, exists := existing[key]; exists {
|
||||
updatedCount++
|
||||
} else {
|
||||
addedCount++
|
||||
}
|
||||
if err := s.upsertScannedVersionTx(ctx, tx, source, item, now); err != nil {
|
||||
return 0, 0, 0, err
|
||||
}
|
||||
touchedBasemaps[item.BasemapCode] = struct{}{}
|
||||
}
|
||||
|
||||
removedCount := 0
|
||||
for key := range existing {
|
||||
if _, ok := found[key]; ok {
|
||||
continue
|
||||
}
|
||||
parts := strings.SplitN(key, "::", 2)
|
||||
if len(parts) != 2 {
|
||||
continue
|
||||
}
|
||||
if err := s.deleteVersionBySourceTx(ctx, tx, source.ID, parts[0], parts[1]); err != nil {
|
||||
return 0, 0, 0, err
|
||||
}
|
||||
touchedBasemaps[parts[0]] = struct{}{}
|
||||
removedCount++
|
||||
}
|
||||
|
||||
for code := range touchedBasemaps {
|
||||
if err := s.normalizeBasemapDefaultsTx(ctx, tx, code, now); err != nil {
|
||||
return 0, 0, 0, err
|
||||
}
|
||||
}
|
||||
|
||||
if err := tx.Commit(); err != nil {
|
||||
return 0, 0, 0, err
|
||||
}
|
||||
return addedCount, updatedCount, removedCount, nil
|
||||
}
|
||||
|
||||
func (s *Store) upsertScannedVersionTx(ctx context.Context, tx *sql.Tx, source ScanSource, item scannedVersion, now time.Time) error {
|
||||
basemapID := uid.Deterministic("basemap", item.BasemapCode)
|
||||
versionID := uid.Deterministic("basemap-version", item.BasemapCode, item.Version)
|
||||
|
||||
_, err := tx.ExecContext(ctx, `
|
||||
INSERT INTO basemaps (id, code, name, type, status, description, created_at, updated_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
|
||||
ON CONFLICT(code) DO UPDATE SET
|
||||
name = excluded.name,
|
||||
type = excluded.type,
|
||||
status = excluded.status,
|
||||
description = excluded.description,
|
||||
updated_at = excluded.updated_at
|
||||
`, basemapID, item.BasemapCode, item.Name, item.Type, item.Status, item.Description, toRFC3339(now), toRFC3339(now))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
isDefault := 0
|
||||
if item.IsDefault {
|
||||
isDefault = 1
|
||||
}
|
||||
_, err = tx.ExecContext(ctx, `
|
||||
INSERT INTO basemap_versions (
|
||||
id, basemap_id, scan_source_id, version, status, is_default, manifest_path, tile_root_path, url_template,
|
||||
tile_format, tile_scheme, min_zoom, max_zoom, bbox_json, attribution, metadata_json, created_at, updated_at
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
ON CONFLICT(basemap_id, version) DO UPDATE SET
|
||||
scan_source_id = excluded.scan_source_id,
|
||||
status = excluded.status,
|
||||
is_default = CASE WHEN excluded.is_default = 1 THEN 1 ELSE basemap_versions.is_default END,
|
||||
manifest_path = excluded.manifest_path,
|
||||
tile_root_path = excluded.tile_root_path,
|
||||
url_template = excluded.url_template,
|
||||
tile_format = excluded.tile_format,
|
||||
tile_scheme = excluded.tile_scheme,
|
||||
min_zoom = excluded.min_zoom,
|
||||
max_zoom = excluded.max_zoom,
|
||||
bbox_json = excluded.bbox_json,
|
||||
attribution = excluded.attribution,
|
||||
metadata_json = excluded.metadata_json,
|
||||
updated_at = excluded.updated_at
|
||||
`, versionID, basemapID, source.ID, item.Version, item.Status, isDefault, item.Manifest, item.TileRoot, s.buildURLTemplate(item.BasemapCode, item.Version, item.TileFormat), item.TileFormat, item.TileScheme, item.MinZoom, item.MaxZoom, writeJSON(item.BBox), item.Attribution, writeJSON(item.Metadata), toRFC3339(now), toRFC3339(now))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if item.IsDefault {
|
||||
_, err = tx.ExecContext(ctx, `
|
||||
UPDATE basemap_versions
|
||||
SET is_default = CASE WHEN version = ? THEN 1 ELSE 0 END, updated_at = ?
|
||||
WHERE basemap_id = ?
|
||||
`, item.Version, toRFC3339(now), basemapID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Store) deleteVersionBySourceTx(ctx context.Context, tx *sql.Tx, sourceID, basemapCode, version string) error {
|
||||
result, err := tx.ExecContext(ctx, `
|
||||
DELETE FROM basemap_versions
|
||||
WHERE scan_source_id = ? AND version = ? AND basemap_id = (SELECT id FROM basemaps WHERE code = ?)
|
||||
`, sourceID, version, basemapCode)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := result.RowsAffected(); err != nil {
|
||||
return err
|
||||
}
|
||||
_, err = tx.ExecContext(ctx, `
|
||||
DELETE FROM basemaps
|
||||
WHERE code = ? AND NOT EXISTS (
|
||||
SELECT 1 FROM basemap_versions WHERE basemap_id = basemaps.id
|
||||
)
|
||||
`, basemapCode)
|
||||
return err
|
||||
}
|
||||
|
||||
func (s *Store) normalizeBasemapDefaultsTx(ctx context.Context, tx *sql.Tx, basemapCode string, now time.Time) error {
|
||||
var basemapID string
|
||||
err := tx.QueryRowContext(ctx, `SELECT id FROM basemaps WHERE code = ?`, basemapCode).Scan(&basemapID)
|
||||
if err != nil {
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return nil
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
rows, err := tx.QueryContext(ctx, `
|
||||
SELECT version, is_default
|
||||
FROM basemap_versions
|
||||
WHERE basemap_id = ?
|
||||
ORDER BY is_default DESC, version ASC
|
||||
`, basemapID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
type versionFlag struct {
|
||||
Version string
|
||||
IsDefault bool
|
||||
}
|
||||
var versions []versionFlag
|
||||
for rows.Next() {
|
||||
var item versionFlag
|
||||
var isDefault int
|
||||
if err := rows.Scan(&item.Version, &isDefault); err != nil {
|
||||
return err
|
||||
}
|
||||
item.IsDefault = isDefault == 1
|
||||
versions = append(versions, item)
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return err
|
||||
}
|
||||
if len(versions) == 0 {
|
||||
return nil
|
||||
}
|
||||
defaultVersion := versions[0].Version
|
||||
for _, item := range versions {
|
||||
if item.IsDefault {
|
||||
defaultVersion = item.Version
|
||||
break
|
||||
}
|
||||
}
|
||||
if _, err := tx.ExecContext(ctx, `
|
||||
UPDATE basemap_versions
|
||||
SET is_default = CASE WHEN version = ? THEN 1 ELSE 0 END, updated_at = ?
|
||||
WHERE basemap_id = ?
|
||||
`, defaultVersion, toRFC3339(now), basemapID); err != nil {
|
||||
return err
|
||||
}
|
||||
_, err = tx.ExecContext(ctx, `UPDATE basemaps SET updated_at = ? WHERE id = ?`, toRFC3339(now), basemapID)
|
||||
return err
|
||||
}
|
||||
|
||||
func (s *Store) buildURLTemplate(code, version, format string) string {
|
||||
base := trimURL(s.cfg.TileBaseURL)
|
||||
if base == "" {
|
||||
base = trimURL(s.cfg.APIBaseURL)
|
||||
}
|
||||
format = normalizeTileFormat(format)
|
||||
return fmt.Sprintf("%s/tiles/%s/%s/{z}/{x}/{y}.%s", base, code, version, format)
|
||||
}
|
||||
@@ -0,0 +1,364 @@
|
||||
package basemap
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/rand"
|
||||
"crypto/sha256"
|
||||
"database/sql"
|
||||
"encoding/base32"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
_ "modernc.org/sqlite"
|
||||
)
|
||||
|
||||
const (
|
||||
defaultScanSourceCode = "local-basemaps"
|
||||
defaultManifestName = "manifest.json"
|
||||
readPermission = "read"
|
||||
defaultVectorSourceCode = "local-vectors"
|
||||
)
|
||||
|
||||
type StoreConfig struct {
|
||||
SQLitePath string
|
||||
ScanRoot string
|
||||
VectorScanRoot string
|
||||
APIBaseURL string
|
||||
TileBaseURL string
|
||||
VectorBaseURL string
|
||||
AutoScanOnStart bool
|
||||
}
|
||||
|
||||
type Store struct {
|
||||
db *sql.DB
|
||||
cfg StoreConfig
|
||||
httpClient *http.Client
|
||||
}
|
||||
|
||||
type CreatedToken struct {
|
||||
Meta ServiceToken `json:"meta"`
|
||||
Token string `json:"token"`
|
||||
}
|
||||
|
||||
type TileDescriptor struct {
|
||||
FilePath string
|
||||
ContentType string
|
||||
Basemap Basemap
|
||||
Version BasemapVersion
|
||||
Token ServiceToken
|
||||
RelativePath string
|
||||
}
|
||||
|
||||
func OpenStore(cfg StoreConfig) (*Store, error) {
|
||||
if strings.TrimSpace(cfg.SQLitePath) == "" {
|
||||
return nil, errors.New("sqlite path is required")
|
||||
}
|
||||
if err := os.MkdirAll(filepath.Dir(cfg.SQLitePath), 0o755); err != nil {
|
||||
return nil, fmt.Errorf("create sqlite dir: %w", err)
|
||||
}
|
||||
|
||||
db, err := sql.Open("sqlite", cfg.SQLitePath)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("open sqlite: %w", err)
|
||||
}
|
||||
db.SetConnMaxLifetime(10 * time.Minute)
|
||||
db.SetMaxOpenConns(1)
|
||||
db.SetMaxIdleConns(1)
|
||||
|
||||
store := &Store{
|
||||
db: db,
|
||||
cfg: cfg,
|
||||
httpClient: &http.Client{
|
||||
Timeout: 15 * time.Second,
|
||||
},
|
||||
}
|
||||
if err := store.bootstrap(context.Background()); err != nil {
|
||||
_ = db.Close()
|
||||
return nil, err
|
||||
}
|
||||
if strings.TrimSpace(cfg.ScanRoot) != "" {
|
||||
if _, err := store.EnsureScanSource(context.Background(), CreateScanSourceInput{
|
||||
Code: defaultScanSourceCode,
|
||||
Name: "Local Basemaps",
|
||||
RootPath: cfg.ScanRoot,
|
||||
ManifestName: defaultManifestName,
|
||||
}); err != nil {
|
||||
_ = db.Close()
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
if strings.TrimSpace(cfg.VectorScanRoot) != "" {
|
||||
if err := os.MkdirAll(cfg.VectorScanRoot, 0o755); err != nil {
|
||||
_ = db.Close()
|
||||
return nil, fmt.Errorf("create vector scan root: %w", err)
|
||||
}
|
||||
}
|
||||
return store, nil
|
||||
}
|
||||
|
||||
func (s *Store) Close() error {
|
||||
if s == nil || s.db == nil {
|
||||
return nil
|
||||
}
|
||||
return s.db.Close()
|
||||
}
|
||||
|
||||
func (s *Store) bootstrap(ctx context.Context) error {
|
||||
statements := []string{
|
||||
`PRAGMA foreign_keys = ON;`,
|
||||
`CREATE TABLE IF NOT EXISTS basemaps (
|
||||
id TEXT PRIMARY KEY,
|
||||
code TEXT NOT NULL UNIQUE,
|
||||
name TEXT NOT NULL,
|
||||
type TEXT NOT NULL,
|
||||
status TEXT NOT NULL,
|
||||
description TEXT NOT NULL DEFAULT '',
|
||||
created_at TEXT NOT NULL,
|
||||
updated_at TEXT NOT NULL
|
||||
);`,
|
||||
`CREATE TABLE IF NOT EXISTS basemap_versions (
|
||||
id TEXT PRIMARY KEY,
|
||||
basemap_id TEXT NOT NULL,
|
||||
scan_source_id TEXT,
|
||||
version TEXT NOT NULL,
|
||||
status TEXT NOT NULL,
|
||||
is_default INTEGER NOT NULL DEFAULT 0,
|
||||
manifest_path TEXT NOT NULL DEFAULT '',
|
||||
tile_root_path TEXT NOT NULL,
|
||||
url_template TEXT NOT NULL,
|
||||
tile_format TEXT NOT NULL,
|
||||
tile_scheme TEXT NOT NULL,
|
||||
min_zoom INTEGER NOT NULL DEFAULT 0,
|
||||
max_zoom INTEGER NOT NULL DEFAULT 0,
|
||||
bbox_json TEXT NOT NULL DEFAULT '',
|
||||
attribution TEXT NOT NULL DEFAULT '',
|
||||
metadata_json TEXT NOT NULL DEFAULT '',
|
||||
created_at TEXT NOT NULL,
|
||||
updated_at TEXT NOT NULL,
|
||||
UNIQUE(basemap_id, version),
|
||||
FOREIGN KEY (basemap_id) REFERENCES basemaps(id) ON DELETE CASCADE,
|
||||
FOREIGN KEY (scan_source_id) REFERENCES scan_sources(id) ON DELETE SET NULL
|
||||
);`,
|
||||
`CREATE TABLE IF NOT EXISTS scan_sources (
|
||||
id TEXT PRIMARY KEY,
|
||||
code TEXT NOT NULL UNIQUE,
|
||||
name TEXT NOT NULL,
|
||||
root_path TEXT NOT NULL,
|
||||
manifest_name TEXT NOT NULL,
|
||||
enabled INTEGER NOT NULL DEFAULT 1,
|
||||
metadata_json TEXT NOT NULL DEFAULT '',
|
||||
created_at TEXT NOT NULL,
|
||||
updated_at TEXT NOT NULL
|
||||
);`,
|
||||
`CREATE TABLE IF NOT EXISTS scan_runs (
|
||||
id TEXT PRIMARY KEY,
|
||||
scan_source_id TEXT NOT NULL,
|
||||
status TEXT NOT NULL,
|
||||
scanned_count INTEGER NOT NULL DEFAULT 0,
|
||||
added_count INTEGER NOT NULL DEFAULT 0,
|
||||
updated_count INTEGER NOT NULL DEFAULT 0,
|
||||
removed_count INTEGER NOT NULL DEFAULT 0,
|
||||
summary_json TEXT NOT NULL DEFAULT '',
|
||||
started_at TEXT NOT NULL,
|
||||
finished_at TEXT NOT NULL,
|
||||
FOREIGN KEY (scan_source_id) REFERENCES scan_sources(id) ON DELETE CASCADE
|
||||
);`,
|
||||
`CREATE TABLE IF NOT EXISTS target_systems (
|
||||
id TEXT PRIMARY KEY,
|
||||
code TEXT NOT NULL UNIQUE,
|
||||
name TEXT NOT NULL,
|
||||
callback_url TEXT NOT NULL,
|
||||
callback_method TEXT NOT NULL,
|
||||
callback_headers_json TEXT NOT NULL DEFAULT '',
|
||||
enabled INTEGER NOT NULL DEFAULT 1,
|
||||
created_at TEXT NOT NULL,
|
||||
updated_at TEXT NOT NULL
|
||||
);`,
|
||||
`CREATE TABLE IF NOT EXISTS service_tokens (
|
||||
id TEXT PRIMARY KEY,
|
||||
name TEXT NOT NULL,
|
||||
token_hash TEXT NOT NULL UNIQUE,
|
||||
token_prefix TEXT NOT NULL,
|
||||
status TEXT NOT NULL,
|
||||
expires_at TEXT,
|
||||
last_used_at TEXT,
|
||||
created_at TEXT NOT NULL
|
||||
);`,
|
||||
`CREATE TABLE IF NOT EXISTS token_grants (
|
||||
id TEXT PRIMARY KEY,
|
||||
token_id TEXT NOT NULL,
|
||||
basemap_id TEXT NOT NULL,
|
||||
basemap_version_id TEXT,
|
||||
permission TEXT NOT NULL,
|
||||
created_at TEXT NOT NULL,
|
||||
FOREIGN KEY (token_id) REFERENCES service_tokens(id) ON DELETE CASCADE,
|
||||
FOREIGN KEY (basemap_id) REFERENCES basemaps(id) ON DELETE CASCADE,
|
||||
FOREIGN KEY (basemap_version_id) REFERENCES basemap_versions(id) ON DELETE CASCADE
|
||||
);`,
|
||||
`CREATE TABLE IF NOT EXISTS push_records (
|
||||
id TEXT PRIMARY KEY,
|
||||
target_system_id TEXT NOT NULL,
|
||||
basemap_version_id TEXT NOT NULL,
|
||||
status TEXT NOT NULL,
|
||||
request_json TEXT NOT NULL DEFAULT '',
|
||||
response_status INTEGER,
|
||||
response_body TEXT NOT NULL DEFAULT '',
|
||||
error_message TEXT NOT NULL DEFAULT '',
|
||||
pushed_at TEXT NOT NULL,
|
||||
finished_at TEXT,
|
||||
FOREIGN KEY (target_system_id) REFERENCES target_systems(id) ON DELETE CASCADE,
|
||||
FOREIGN KEY (basemap_version_id) REFERENCES basemap_versions(id) ON DELETE CASCADE
|
||||
);`,
|
||||
`CREATE TABLE IF NOT EXISTS vector_assets (
|
||||
id TEXT PRIMARY KEY,
|
||||
code TEXT NOT NULL UNIQUE,
|
||||
name TEXT NOT NULL,
|
||||
format TEXT NOT NULL,
|
||||
status TEXT NOT NULL,
|
||||
description TEXT NOT NULL DEFAULT '',
|
||||
file_path TEXT NOT NULL,
|
||||
file_name TEXT NOT NULL,
|
||||
file_size INTEGER NOT NULL DEFAULT 0,
|
||||
checksum TEXT NOT NULL DEFAULT '',
|
||||
download_url TEXT NOT NULL,
|
||||
metadata_json TEXT NOT NULL DEFAULT '',
|
||||
created_at TEXT NOT NULL,
|
||||
updated_at TEXT NOT NULL
|
||||
);`,
|
||||
`CREATE TABLE IF NOT EXISTS vector_token_grants (
|
||||
id TEXT PRIMARY KEY,
|
||||
token_id TEXT NOT NULL,
|
||||
vector_asset_id TEXT NOT NULL,
|
||||
permission TEXT NOT NULL,
|
||||
created_at TEXT NOT NULL,
|
||||
FOREIGN KEY (token_id) REFERENCES service_tokens(id) ON DELETE CASCADE,
|
||||
FOREIGN KEY (vector_asset_id) REFERENCES vector_assets(id) ON DELETE CASCADE
|
||||
);`,
|
||||
`CREATE TABLE IF NOT EXISTS vector_push_records (
|
||||
id TEXT PRIMARY KEY,
|
||||
target_system_id TEXT NOT NULL,
|
||||
vector_asset_id TEXT NOT NULL,
|
||||
status TEXT NOT NULL,
|
||||
request_json TEXT NOT NULL DEFAULT '',
|
||||
response_status INTEGER,
|
||||
response_body TEXT NOT NULL DEFAULT '',
|
||||
error_message TEXT NOT NULL DEFAULT '',
|
||||
pushed_at TEXT NOT NULL,
|
||||
finished_at TEXT,
|
||||
FOREIGN KEY (target_system_id) REFERENCES target_systems(id) ON DELETE CASCADE,
|
||||
FOREIGN KEY (vector_asset_id) REFERENCES vector_assets(id) ON DELETE CASCADE
|
||||
);`,
|
||||
`CREATE INDEX IF NOT EXISTS idx_versions_basemap ON basemap_versions(basemap_id);`,
|
||||
`CREATE INDEX IF NOT EXISTS idx_versions_source ON basemap_versions(scan_source_id);`,
|
||||
`CREATE INDEX IF NOT EXISTS idx_grants_token ON token_grants(token_id);`,
|
||||
`CREATE INDEX IF NOT EXISTS idx_push_target ON push_records(target_system_id);`,
|
||||
`CREATE INDEX IF NOT EXISTS idx_vector_grants_token ON vector_token_grants(token_id);`,
|
||||
`CREATE INDEX IF NOT EXISTS idx_vector_push_target ON vector_push_records(target_system_id);`,
|
||||
}
|
||||
|
||||
for _, statement := range statements {
|
||||
if _, err := s.db.ExecContext(ctx, statement); err != nil {
|
||||
return fmt.Errorf("bootstrap sqlite: %w", err)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Store) AutoScanEnabled() bool {
|
||||
return s.cfg.AutoScanOnStart
|
||||
}
|
||||
|
||||
func (s *Store) ScanRoot() string {
|
||||
return s.cfg.ScanRoot
|
||||
}
|
||||
|
||||
func (s *Store) VectorScanRoot() string {
|
||||
return s.cfg.VectorScanRoot
|
||||
}
|
||||
|
||||
func (s *Store) generateTokenSecret() (string, string, error) {
|
||||
var raw [32]byte
|
||||
if _, err := rand.Read(raw[:]); err != nil {
|
||||
return "", "", err
|
||||
}
|
||||
|
||||
encoder := base32.StdEncoding.WithPadding(base32.NoPadding)
|
||||
secret := strings.ToLower(encoder.EncodeToString(raw[:]))
|
||||
token := "bmp_" + secret
|
||||
sum := sha256.Sum256([]byte(token))
|
||||
return token, hex.EncodeToString(sum[:]), nil
|
||||
}
|
||||
|
||||
func normalizeBasemapCode(value string) string {
|
||||
return normalizeCode(value)
|
||||
}
|
||||
|
||||
func nowUTC() time.Time {
|
||||
return time.Now().UTC().Round(time.Second)
|
||||
}
|
||||
|
||||
func toRFC3339(t time.Time) string {
|
||||
return t.UTC().Format(time.RFC3339)
|
||||
}
|
||||
|
||||
func nullableTime(t *time.Time) any {
|
||||
if t == nil || t.IsZero() {
|
||||
return nil
|
||||
}
|
||||
return toRFC3339(*t)
|
||||
}
|
||||
|
||||
func scanNullString(value string) sql.NullString {
|
||||
if strings.TrimSpace(value) == "" {
|
||||
return sql.NullString{}
|
||||
}
|
||||
return sql.NullString{String: value, Valid: true}
|
||||
}
|
||||
|
||||
func readMaybeJSON[T any](value string, target *T) error {
|
||||
if strings.TrimSpace(value) == "" {
|
||||
return nil
|
||||
}
|
||||
return json.Unmarshal([]byte(value), target)
|
||||
}
|
||||
|
||||
func writeJSON(value any) string {
|
||||
if value == nil {
|
||||
return ""
|
||||
}
|
||||
encoded, err := json.Marshal(value)
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
return string(encoded)
|
||||
}
|
||||
|
||||
func sortedKeys(values map[string]struct{}) []string {
|
||||
keys := make([]string, 0, len(values))
|
||||
for key := range values {
|
||||
keys = append(keys, key)
|
||||
}
|
||||
sort.Strings(keys)
|
||||
return keys
|
||||
}
|
||||
|
||||
func trimURL(value string) string {
|
||||
return strings.TrimRight(strings.TrimSpace(value), "/")
|
||||
}
|
||||
|
||||
func limitedReadAll(body io.Reader, limit int64) string {
|
||||
content, err := io.ReadAll(io.LimitReader(body, limit))
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
return string(content)
|
||||
}
|
||||
@@ -0,0 +1,344 @@
|
||||
package basemap
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
type basemapVersionRow struct {
|
||||
ID string
|
||||
BasemapID string
|
||||
BasemapCode string
|
||||
Version string
|
||||
Status string
|
||||
IsDefault bool
|
||||
ManifestPath string
|
||||
TileRootPath string
|
||||
URLTemplate string
|
||||
TileFormat string
|
||||
TileScheme string
|
||||
MinZoom int
|
||||
MaxZoom int
|
||||
BBoxJSON string
|
||||
Attribution string
|
||||
MetadataJSON string
|
||||
CreatedAt string
|
||||
UpdatedAt string
|
||||
}
|
||||
|
||||
func parseTime(value string) time.Time {
|
||||
if strings.TrimSpace(value) == "" {
|
||||
return time.Time{}
|
||||
}
|
||||
parsed, err := time.Parse(time.RFC3339, value)
|
||||
if err != nil {
|
||||
return time.Time{}
|
||||
}
|
||||
return parsed
|
||||
}
|
||||
|
||||
func scanBasemapVersionRow(scanner interface {
|
||||
Scan(dest ...any) error
|
||||
}) (basemapVersionRow, error) {
|
||||
var row basemapVersionRow
|
||||
var isDefault int
|
||||
if err := scanner.Scan(
|
||||
&row.ID,
|
||||
&row.BasemapID,
|
||||
&row.BasemapCode,
|
||||
&row.Version,
|
||||
&row.Status,
|
||||
&isDefault,
|
||||
&row.ManifestPath,
|
||||
&row.TileRootPath,
|
||||
&row.URLTemplate,
|
||||
&row.TileFormat,
|
||||
&row.TileScheme,
|
||||
&row.MinZoom,
|
||||
&row.MaxZoom,
|
||||
&row.BBoxJSON,
|
||||
&row.Attribution,
|
||||
&row.MetadataJSON,
|
||||
&row.CreatedAt,
|
||||
&row.UpdatedAt,
|
||||
); err != nil {
|
||||
return basemapVersionRow{}, err
|
||||
}
|
||||
row.IsDefault = isDefault == 1
|
||||
return row, nil
|
||||
}
|
||||
|
||||
func decodeBasemapVersion(row basemapVersionRow) BasemapVersion {
|
||||
item := BasemapVersion{
|
||||
ID: row.ID,
|
||||
BasemapID: row.BasemapID,
|
||||
BasemapCode: row.BasemapCode,
|
||||
Version: row.Version,
|
||||
Status: row.Status,
|
||||
IsDefault: row.IsDefault,
|
||||
ManifestPath: row.ManifestPath,
|
||||
TileRootPath: row.TileRootPath,
|
||||
URLTemplate: row.URLTemplate,
|
||||
TileFormat: row.TileFormat,
|
||||
TileScheme: row.TileScheme,
|
||||
MinZoom: row.MinZoom,
|
||||
MaxZoom: row.MaxZoom,
|
||||
Attribution: row.Attribution,
|
||||
CreatedAt: parseTime(row.CreatedAt),
|
||||
UpdatedAt: parseTime(row.UpdatedAt),
|
||||
}
|
||||
if strings.TrimSpace(row.BBoxJSON) != "" {
|
||||
_ = readMaybeJSON(row.BBoxJSON, &item.BBox)
|
||||
}
|
||||
if strings.TrimSpace(row.MetadataJSON) != "" {
|
||||
_ = readMaybeJSON(row.MetadataJSON, &item.Metadata)
|
||||
}
|
||||
if item.Metadata == nil {
|
||||
item.Metadata = map[string]any{}
|
||||
}
|
||||
return item
|
||||
}
|
||||
|
||||
func scanBasemap(scanner interface {
|
||||
Scan(dest ...any) error
|
||||
}) (Basemap, error) {
|
||||
var item Basemap
|
||||
var createdAt string
|
||||
var updatedAt string
|
||||
if err := scanner.Scan(
|
||||
&item.ID,
|
||||
&item.Code,
|
||||
&item.Name,
|
||||
&item.Type,
|
||||
&item.Status,
|
||||
&item.Description,
|
||||
&createdAt,
|
||||
&updatedAt,
|
||||
); err != nil {
|
||||
return Basemap{}, err
|
||||
}
|
||||
item.CreatedAt = parseTime(createdAt)
|
||||
item.UpdatedAt = parseTime(updatedAt)
|
||||
return item, nil
|
||||
}
|
||||
|
||||
func scanScanSource(scanner interface {
|
||||
Scan(dest ...any) error
|
||||
}) (ScanSource, error) {
|
||||
var item ScanSource
|
||||
var enabled int
|
||||
var metadataJSON string
|
||||
var createdAt string
|
||||
var updatedAt string
|
||||
if err := scanner.Scan(
|
||||
&item.ID,
|
||||
&item.Code,
|
||||
&item.Name,
|
||||
&item.RootPath,
|
||||
&item.ManifestName,
|
||||
&enabled,
|
||||
&metadataJSON,
|
||||
&createdAt,
|
||||
&updatedAt,
|
||||
); err != nil {
|
||||
return ScanSource{}, err
|
||||
}
|
||||
item.Enabled = enabled == 1
|
||||
item.CreatedAt = parseTime(createdAt)
|
||||
item.UpdatedAt = parseTime(updatedAt)
|
||||
if metadataJSON != "" {
|
||||
_ = readMaybeJSON(metadataJSON, &item.Metadata)
|
||||
}
|
||||
if item.Metadata == nil {
|
||||
item.Metadata = map[string]any{}
|
||||
}
|
||||
return item, nil
|
||||
}
|
||||
|
||||
func scanScanRun(scanner interface {
|
||||
Scan(dest ...any) error
|
||||
}) (ScanRun, error) {
|
||||
var item ScanRun
|
||||
var summaryJSON string
|
||||
var startedAt string
|
||||
var finishedAt string
|
||||
if err := scanner.Scan(
|
||||
&item.ID,
|
||||
&item.ScanSourceID,
|
||||
&item.SourceCode,
|
||||
&item.Status,
|
||||
&item.ScannedCount,
|
||||
&item.AddedCount,
|
||||
&item.UpdatedCount,
|
||||
&item.RemovedCount,
|
||||
&summaryJSON,
|
||||
&startedAt,
|
||||
&finishedAt,
|
||||
); err != nil {
|
||||
return ScanRun{}, err
|
||||
}
|
||||
item.StartedAt = parseTime(startedAt)
|
||||
item.FinishedAt = parseTime(finishedAt)
|
||||
if summaryJSON != "" {
|
||||
_ = readMaybeJSON(summaryJSON, &item.Summary)
|
||||
}
|
||||
if item.Summary == nil {
|
||||
item.Summary = map[string]any{}
|
||||
}
|
||||
return item, nil
|
||||
}
|
||||
|
||||
func scanTargetSystem(scanner interface {
|
||||
Scan(dest ...any) error
|
||||
}) (TargetSystem, error) {
|
||||
var item TargetSystem
|
||||
var enabled int
|
||||
var headersJSON string
|
||||
var createdAt string
|
||||
var updatedAt string
|
||||
if err := scanner.Scan(
|
||||
&item.ID,
|
||||
&item.Code,
|
||||
&item.Name,
|
||||
&item.CallbackURL,
|
||||
&item.CallbackMethod,
|
||||
&headersJSON,
|
||||
&enabled,
|
||||
&createdAt,
|
||||
&updatedAt,
|
||||
); err != nil {
|
||||
return TargetSystem{}, err
|
||||
}
|
||||
item.Enabled = enabled == 1
|
||||
item.CreatedAt = parseTime(createdAt)
|
||||
item.UpdatedAt = parseTime(updatedAt)
|
||||
if headersJSON != "" {
|
||||
_ = readMaybeJSON(headersJSON, &item.CallbackHeaders)
|
||||
}
|
||||
if item.CallbackHeaders == nil {
|
||||
item.CallbackHeaders = map[string]string{}
|
||||
}
|
||||
return item, nil
|
||||
}
|
||||
|
||||
func scanServiceToken(scanner interface {
|
||||
Scan(dest ...any) error
|
||||
}) (ServiceToken, string, error) {
|
||||
var item ServiceToken
|
||||
var tokenHash string
|
||||
var expiresAt sql.NullString
|
||||
var lastUsedAt sql.NullString
|
||||
var createdAt string
|
||||
if err := scanner.Scan(
|
||||
&item.ID,
|
||||
&item.Name,
|
||||
&tokenHash,
|
||||
&item.TokenPrefix,
|
||||
&item.Status,
|
||||
&expiresAt,
|
||||
&lastUsedAt,
|
||||
&createdAt,
|
||||
); err != nil {
|
||||
return ServiceToken{}, "", err
|
||||
}
|
||||
item.CreatedAt = parseTime(createdAt)
|
||||
if expiresAt.Valid {
|
||||
value := parseTime(expiresAt.String)
|
||||
item.ExpiresAt = &value
|
||||
}
|
||||
if lastUsedAt.Valid {
|
||||
value := parseTime(lastUsedAt.String)
|
||||
item.LastUsedAt = &value
|
||||
}
|
||||
return item, tokenHash, nil
|
||||
}
|
||||
|
||||
func scanTokenGrant(scanner interface {
|
||||
Scan(dest ...any) error
|
||||
}) (TokenGrant, error) {
|
||||
var item TokenGrant
|
||||
var versionID sql.NullString
|
||||
var version sql.NullString
|
||||
var createdAt string
|
||||
if err := scanner.Scan(
|
||||
&item.ID,
|
||||
&item.TokenID,
|
||||
&item.BasemapID,
|
||||
&item.BasemapCode,
|
||||
&versionID,
|
||||
&version,
|
||||
&item.Permission,
|
||||
&createdAt,
|
||||
); err != nil {
|
||||
return TokenGrant{}, err
|
||||
}
|
||||
if versionID.Valid {
|
||||
item.BasemapVersionID = &versionID.String
|
||||
}
|
||||
if version.Valid {
|
||||
item.BasemapVersion = &version.String
|
||||
}
|
||||
item.CreatedAt = parseTime(createdAt)
|
||||
return item, nil
|
||||
}
|
||||
|
||||
func scanPushRecord(scanner interface {
|
||||
Scan(dest ...any) error
|
||||
}) (PushRecord, error) {
|
||||
var item PushRecord
|
||||
var requestJSON string
|
||||
var responseStatus sql.NullInt64
|
||||
var finishedAt sql.NullString
|
||||
var pushedAt string
|
||||
if err := scanner.Scan(
|
||||
&item.ID,
|
||||
&item.TargetSystemID,
|
||||
&item.TargetSystemCode,
|
||||
&item.BasemapVersionID,
|
||||
&item.BasemapCode,
|
||||
&item.BasemapVersion,
|
||||
&item.Status,
|
||||
&requestJSON,
|
||||
&responseStatus,
|
||||
&item.ResponseBody,
|
||||
&item.ErrorMessage,
|
||||
&pushedAt,
|
||||
&finishedAt,
|
||||
); err != nil {
|
||||
return PushRecord{}, err
|
||||
}
|
||||
if requestJSON != "" {
|
||||
_ = readMaybeJSON(requestJSON, &item.Request)
|
||||
}
|
||||
if item.Request == nil {
|
||||
item.Request = map[string]any{}
|
||||
}
|
||||
if responseStatus.Valid {
|
||||
value := int(responseStatus.Int64)
|
||||
item.ResponseStatus = &value
|
||||
}
|
||||
item.PushedAt = parseTime(pushedAt)
|
||||
if finishedAt.Valid {
|
||||
value := parseTime(finishedAt.String)
|
||||
item.FinishedAt = &value
|
||||
}
|
||||
return item, nil
|
||||
}
|
||||
|
||||
func mustOneRow[T any](item T, err error) (T, error) {
|
||||
if err != nil {
|
||||
return item, err
|
||||
}
|
||||
return item, nil
|
||||
}
|
||||
|
||||
func unexpectedRowsAffected(count int64, expected ...int64) error {
|
||||
for _, value := range expected {
|
||||
if count == value {
|
||||
return nil
|
||||
}
|
||||
}
|
||||
return fmt.Errorf("unexpected rows affected: %d", count)
|
||||
}
|
||||
@@ -0,0 +1,102 @@
|
||||
package basemap
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
)
|
||||
|
||||
func scanVectorAsset(scanner interface {
|
||||
Scan(dest ...any) error
|
||||
}) (VectorAsset, error) {
|
||||
var item VectorAsset
|
||||
var metadataJSON string
|
||||
var createdAt string
|
||||
var updatedAt string
|
||||
if err := scanner.Scan(
|
||||
&item.ID,
|
||||
&item.Code,
|
||||
&item.Name,
|
||||
&item.Format,
|
||||
&item.Status,
|
||||
&item.Description,
|
||||
&item.FilePath,
|
||||
&item.FileName,
|
||||
&item.FileSize,
|
||||
&item.Checksum,
|
||||
&item.DownloadURL,
|
||||
&metadataJSON,
|
||||
&createdAt,
|
||||
&updatedAt,
|
||||
); err != nil {
|
||||
return VectorAsset{}, err
|
||||
}
|
||||
item.CreatedAt = parseTime(createdAt)
|
||||
item.UpdatedAt = parseTime(updatedAt)
|
||||
if metadataJSON != "" {
|
||||
_ = readMaybeJSON(metadataJSON, &item.Metadata)
|
||||
}
|
||||
if item.Metadata == nil {
|
||||
item.Metadata = map[string]any{}
|
||||
}
|
||||
return item, nil
|
||||
}
|
||||
|
||||
func scanVectorTokenGrant(scanner interface {
|
||||
Scan(dest ...any) error
|
||||
}) (VectorTokenGrant, error) {
|
||||
var item VectorTokenGrant
|
||||
var createdAt string
|
||||
if err := scanner.Scan(
|
||||
&item.ID,
|
||||
&item.TokenID,
|
||||
&item.VectorID,
|
||||
&item.VectorCode,
|
||||
&item.Permission,
|
||||
&createdAt,
|
||||
); err != nil {
|
||||
return VectorTokenGrant{}, err
|
||||
}
|
||||
item.CreatedAt = parseTime(createdAt)
|
||||
return item, nil
|
||||
}
|
||||
|
||||
func scanVectorPushRecord(scanner interface {
|
||||
Scan(dest ...any) error
|
||||
}) (VectorPushRecord, error) {
|
||||
var item VectorPushRecord
|
||||
var requestJSON string
|
||||
var responseStatus sql.NullInt64
|
||||
var finishedAt sql.NullString
|
||||
var pushedAt string
|
||||
if err := scanner.Scan(
|
||||
&item.ID,
|
||||
&item.TargetSystemID,
|
||||
&item.TargetSystemCode,
|
||||
&item.VectorAssetID,
|
||||
&item.VectorCode,
|
||||
&item.Status,
|
||||
&requestJSON,
|
||||
&responseStatus,
|
||||
&item.ResponseBody,
|
||||
&item.ErrorMessage,
|
||||
&pushedAt,
|
||||
&finishedAt,
|
||||
); err != nil {
|
||||
return VectorPushRecord{}, err
|
||||
}
|
||||
if requestJSON != "" {
|
||||
_ = readMaybeJSON(requestJSON, &item.Request)
|
||||
}
|
||||
if item.Request == nil {
|
||||
item.Request = map[string]any{}
|
||||
}
|
||||
if responseStatus.Valid {
|
||||
value := int(responseStatus.Int64)
|
||||
item.ResponseStatus = &value
|
||||
}
|
||||
item.PushedAt = parseTime(pushedAt)
|
||||
if finishedAt.Valid {
|
||||
value := parseTime(finishedAt.String)
|
||||
item.FinishedAt = &value
|
||||
}
|
||||
return item, nil
|
||||
}
|
||||
@@ -0,0 +1,555 @@
|
||||
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()
|
||||
}
|
||||
Reference in New Issue
Block a user