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()
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,108 @@
|
||||
package basemapapi
|
||||
|
||||
import (
|
||||
"context"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"map-asset-gateway/api-go/internal/basemap"
|
||||
)
|
||||
|
||||
func TestCompatibilityRoutes(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(vectorRoot, 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)
|
||||
}
|
||||
|
||||
store, err := basemap.OpenStore(basemap.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, "local-basemaps"); err != nil {
|
||||
t.Fatalf("run scan: %v", err)
|
||||
}
|
||||
if _, err := store.RunVectorScan(ctx); err != nil {
|
||||
t.Fatalf("run vector scan: %v", err)
|
||||
}
|
||||
token, err := store.CreateToken(ctx, basemap.CreateTokenInput{
|
||||
Name: "compat",
|
||||
BasemapCodes: []string{"google_image"},
|
||||
VectorCodes: []string{"全国行政区.geojson"},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("create token: %v", err)
|
||||
}
|
||||
|
||||
server, err := New(store, basemap.SystemInfo{
|
||||
ServiceName: "map-asset-gateway",
|
||||
ServiceVersion: "test",
|
||||
APIBaseURL: "http://127.0.0.1:8910",
|
||||
TileBaseURL: "http://127.0.0.1:8910",
|
||||
VectorBaseURL: "http://127.0.0.1:8910",
|
||||
SQLitePath: sqlitePath,
|
||||
ScanRoot: tileRoot,
|
||||
VectorScanRoot: vectorRoot,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("new server: %v", err)
|
||||
}
|
||||
|
||||
httpServer := httptest.NewServer(server.Handler())
|
||||
defer httpServer.Close()
|
||||
|
||||
response, err := http.Get(httpServer.URL + "/tiles/google_image/0/0/0.webp?token=" + token.Token)
|
||||
if err != nil {
|
||||
t.Fatalf("request default tile route: %v", err)
|
||||
}
|
||||
defer response.Body.Close()
|
||||
if response.StatusCode != http.StatusOK {
|
||||
t.Fatalf("unexpected tile status: %d", response.StatusCode)
|
||||
}
|
||||
tileBody, err := io.ReadAll(response.Body)
|
||||
if err != nil {
|
||||
t.Fatalf("read tile body: %v", err)
|
||||
}
|
||||
if string(tileBody) != "tile" {
|
||||
t.Fatalf("unexpected tile body: %q", string(tileBody))
|
||||
}
|
||||
|
||||
response, err = http.Get(httpServer.URL + "/geojson/%E5%85%A8%E5%9B%BD%E8%A1%8C%E6%94%BF%E5%8C%BA.geojson?token=" + token.Token)
|
||||
if err != nil {
|
||||
t.Fatalf("request geojson route: %v", err)
|
||||
}
|
||||
defer response.Body.Close()
|
||||
if response.StatusCode != http.StatusOK {
|
||||
t.Fatalf("unexpected geojson status: %d", response.StatusCode)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,888 @@
|
||||
package basemapapi
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"html/template"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"map-asset-gateway/api-go/internal/basemap"
|
||||
)
|
||||
|
||||
type Server struct {
|
||||
store *basemap.Store
|
||||
system basemap.SystemInfo
|
||||
startedAt time.Time
|
||||
template *template.Template
|
||||
mux *http.ServeMux
|
||||
}
|
||||
|
||||
func New(store *basemap.Store, system basemap.SystemInfo) (*Server, error) {
|
||||
tmpl, err := template.New("admin").Funcs(template.FuncMap{
|
||||
"fmtTime": func(value time.Time) string {
|
||||
if value.IsZero() {
|
||||
return "-"
|
||||
}
|
||||
return value.Local().Format("2006-01-02 15:04:05")
|
||||
},
|
||||
"fmtTimePtr": func(value *time.Time) string {
|
||||
if value == nil || value.IsZero() {
|
||||
return "-"
|
||||
}
|
||||
return value.Local().Format("2006-01-02 15:04:05")
|
||||
},
|
||||
"defaultVersion": func(value *basemap.BasemapVersion) string {
|
||||
if value == nil {
|
||||
return "-"
|
||||
}
|
||||
return value.Version
|
||||
},
|
||||
"tokenGrants": func(grants []basemap.TokenGrant) string {
|
||||
if len(grants) == 0 {
|
||||
return "-"
|
||||
}
|
||||
parts := make([]string, 0, len(grants))
|
||||
for _, grant := range grants {
|
||||
if grant.BasemapVersion != nil {
|
||||
parts = append(parts, grant.BasemapCode+":"+*grant.BasemapVersion)
|
||||
} else {
|
||||
parts = append(parts, grant.BasemapCode+":*")
|
||||
}
|
||||
}
|
||||
return strings.Join(parts, ", ")
|
||||
},
|
||||
"vectorTokenGrants": func(grants []basemap.VectorTokenGrant) string {
|
||||
if len(grants) == 0 {
|
||||
return "-"
|
||||
}
|
||||
parts := make([]string, 0, len(grants))
|
||||
for _, grant := range grants {
|
||||
parts = append(parts, grant.VectorCode)
|
||||
}
|
||||
return strings.Join(parts, ", ")
|
||||
},
|
||||
"versionLabel": func(item basemap.BasemapVersion) string {
|
||||
if item.IsDefault {
|
||||
return item.Version + "(默认)"
|
||||
}
|
||||
return item.Version
|
||||
},
|
||||
"vectorPath": func(item basemap.VectorAsset) string {
|
||||
if value, ok := item.Metadata["relative_path"].(string); ok && strings.TrimSpace(value) != "" {
|
||||
return value
|
||||
}
|
||||
return item.FileName
|
||||
},
|
||||
"statusText": statusText,
|
||||
"statusTone": statusTone,
|
||||
"enabledText": func(enabled bool) string {
|
||||
if enabled {
|
||||
return "启用"
|
||||
}
|
||||
return "停用"
|
||||
},
|
||||
"enabledTone": func(enabled bool) string {
|
||||
if enabled {
|
||||
return "success"
|
||||
}
|
||||
return "danger"
|
||||
},
|
||||
"formatBytes": formatBytes,
|
||||
"flashClass": func(value string) string {
|
||||
if strings.HasPrefix(strings.TrimSpace(value), "操作失败:") {
|
||||
return "danger"
|
||||
}
|
||||
return ""
|
||||
},
|
||||
}).Parse(adminTemplate)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
server := &Server{
|
||||
store: store,
|
||||
system: system,
|
||||
startedAt: time.Now().UTC(),
|
||||
template: tmpl,
|
||||
mux: http.NewServeMux(),
|
||||
}
|
||||
server.routes()
|
||||
return server, nil
|
||||
}
|
||||
|
||||
func (s *Server) Handler() http.Handler {
|
||||
return s.mux
|
||||
}
|
||||
|
||||
func (s *Server) routes() {
|
||||
s.mux.HandleFunc("GET /", func(w http.ResponseWriter, r *http.Request) {
|
||||
http.Redirect(w, r, "/admin/", http.StatusFound)
|
||||
})
|
||||
s.mux.HandleFunc("GET /health", s.handleHealth)
|
||||
s.mux.HandleFunc("GET /ready", s.handleReady)
|
||||
s.mux.HandleFunc("GET /api/v1/system", s.handleSystem)
|
||||
|
||||
s.mux.HandleFunc("GET /api/v1/admin/dashboard", s.handleDashboardJSON)
|
||||
s.mux.HandleFunc("GET /api/v1/admin/basemaps", s.handleBasemapsJSON)
|
||||
s.mux.HandleFunc("GET /api/v1/admin/vectors", s.handleVectorsJSON)
|
||||
s.mux.HandleFunc("GET /api/v1/admin/scan-sources", s.handleScanSourcesJSON)
|
||||
s.mux.HandleFunc("POST /api/v1/admin/scan-sources", s.handleCreateScanSourceJSON)
|
||||
s.mux.HandleFunc("POST /api/v1/admin/scan-sources/{code}/run", s.handleRunScanJSON)
|
||||
s.mux.HandleFunc("POST /api/v1/admin/vectors/rescan", s.handleRunVectorScanJSON)
|
||||
s.mux.HandleFunc("POST /api/v1/admin/basemaps/{code}/default-version", s.handleSetDefaultVersionJSON)
|
||||
s.mux.HandleFunc("GET /api/v1/admin/targets", s.handleTargetsJSON)
|
||||
s.mux.HandleFunc("POST /api/v1/admin/targets", s.handleCreateTargetJSON)
|
||||
s.mux.HandleFunc("GET /api/v1/admin/tokens", s.handleTokensJSON)
|
||||
s.mux.HandleFunc("POST /api/v1/admin/tokens", s.handleCreateTokenJSON)
|
||||
s.mux.HandleFunc("POST /api/v1/admin/tokens/{id}/disable", s.handleDisableTokenJSON)
|
||||
s.mux.HandleFunc("GET /api/v1/admin/push-records", s.handlePushRecordsJSON)
|
||||
s.mux.HandleFunc("POST /api/v1/admin/pushes", s.handlePushJSON)
|
||||
s.mux.HandleFunc("GET /api/v1/admin/vector-push-records", s.handleVectorPushRecordsJSON)
|
||||
s.mux.HandleFunc("POST /api/v1/admin/vector-pushes", s.handleVectorPushJSON)
|
||||
|
||||
s.mux.HandleFunc("GET /api/v1/catalog/basemaps", s.handleCatalogList)
|
||||
s.mux.HandleFunc("GET /api/v1/catalog/basemaps/{code}", s.handleCatalogGet)
|
||||
s.mux.HandleFunc("GET /api/v1/catalog/vectors", s.handleVectorCatalogList)
|
||||
s.mux.HandleFunc("GET /api/v1/catalog/vectors/{code}", s.handleVectorCatalogGet)
|
||||
s.mux.HandleFunc("GET /tiles/{code}/{version}/{tilePath...}", s.handleTile)
|
||||
s.mux.HandleFunc("GET /geojson/{assetPath...}", s.handleGeoJSONByPath)
|
||||
s.mux.HandleFunc("GET /shp/{assetPath...}", s.handleShapeByPath)
|
||||
s.mux.HandleFunc("GET /vectors/{code}/download", s.handleVectorDownload)
|
||||
|
||||
s.mux.HandleFunc("GET /admin/", s.handleAdminPage)
|
||||
s.mux.HandleFunc("POST /admin/scan-sources", s.handleCreateScanSourceForm)
|
||||
s.mux.HandleFunc("POST /admin/scan-sources/{code}/run", s.handleRunScanForm)
|
||||
s.mux.HandleFunc("POST /admin/vectors/rescan", s.handleRunVectorScanForm)
|
||||
s.mux.HandleFunc("POST /admin/basemaps/{code}/default", s.handleSetDefaultVersionForm)
|
||||
s.mux.HandleFunc("POST /admin/targets", s.handleCreateTargetForm)
|
||||
s.mux.HandleFunc("POST /admin/tokens", s.handleCreateTokenForm)
|
||||
s.mux.HandleFunc("POST /admin/tokens/{id}/disable", s.handleDisableTokenForm)
|
||||
s.mux.HandleFunc("POST /admin/pushes", s.handlePushForm)
|
||||
s.mux.HandleFunc("POST /admin/vector-pushes", s.handleVectorPushForm)
|
||||
}
|
||||
|
||||
func (s *Server) handleHealth(w http.ResponseWriter, r *http.Request) {
|
||||
writeJSON(w, http.StatusOK, map[string]any{
|
||||
"status": "ok",
|
||||
"service": s.system.ServiceName,
|
||||
"version": s.system.ServiceVersion,
|
||||
})
|
||||
}
|
||||
|
||||
func (s *Server) handleReady(w http.ResponseWriter, r *http.Request) {
|
||||
writeJSON(w, http.StatusOK, map[string]any{
|
||||
"status": "ready",
|
||||
"started_at": s.startedAt,
|
||||
"scan_root": s.system.ScanRoot,
|
||||
"sqlite": s.system.SQLitePath,
|
||||
})
|
||||
}
|
||||
|
||||
func (s *Server) handleSystem(w http.ResponseWriter, r *http.Request) {
|
||||
writeJSON(w, http.StatusOK, s.system)
|
||||
}
|
||||
|
||||
func (s *Server) handleDashboardJSON(w http.ResponseWriter, r *http.Request) {
|
||||
data, err := s.store.Dashboard(r.Context(), s.system, "", "")
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, data)
|
||||
}
|
||||
|
||||
func (s *Server) handleBasemapsJSON(w http.ResponseWriter, r *http.Request) {
|
||||
items, err := s.store.ListBasemaps(r.Context())
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, items)
|
||||
}
|
||||
|
||||
func (s *Server) handleVectorsJSON(w http.ResponseWriter, r *http.Request) {
|
||||
items, err := s.store.ListVectorAssets(r.Context())
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, items)
|
||||
}
|
||||
|
||||
func (s *Server) handleScanSourcesJSON(w http.ResponseWriter, r *http.Request) {
|
||||
items, err := s.store.ListScanSources(r.Context())
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, items)
|
||||
}
|
||||
|
||||
func (s *Server) handleCreateScanSourceJSON(w http.ResponseWriter, r *http.Request) {
|
||||
var input basemap.CreateScanSourceInput
|
||||
if err := json.NewDecoder(r.Body).Decode(&input); err != nil {
|
||||
writeError(w, http.StatusBadRequest, fmt.Errorf("decode json: %w", err))
|
||||
return
|
||||
}
|
||||
item, err := s.store.EnsureScanSource(r.Context(), input)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusBadRequest, err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusCreated, item)
|
||||
}
|
||||
|
||||
func (s *Server) handleRunScanJSON(w http.ResponseWriter, r *http.Request) {
|
||||
run, err := s.store.RunScan(r.Context(), r.PathValue("code"))
|
||||
if err != nil {
|
||||
writeError(w, http.StatusBadRequest, err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, run)
|
||||
}
|
||||
|
||||
func (s *Server) handleRunVectorScanJSON(w http.ResponseWriter, r *http.Request) {
|
||||
result, err := s.store.RunVectorScan(r.Context())
|
||||
if err != nil {
|
||||
writeError(w, http.StatusBadRequest, err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, result)
|
||||
}
|
||||
|
||||
func (s *Server) handleSetDefaultVersionJSON(w http.ResponseWriter, r *http.Request) {
|
||||
var payload struct {
|
||||
Version string `json:"version"`
|
||||
}
|
||||
if err := json.NewDecoder(r.Body).Decode(&payload); err != nil {
|
||||
writeError(w, http.StatusBadRequest, err)
|
||||
return
|
||||
}
|
||||
if err := s.store.SetDefaultVersion(r.Context(), r.PathValue("code"), payload.Version); err != nil {
|
||||
writeError(w, http.StatusBadRequest, err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]string{"status": "ok"})
|
||||
}
|
||||
|
||||
func (s *Server) handleTargetsJSON(w http.ResponseWriter, r *http.Request) {
|
||||
items, err := s.store.ListTargetSystems(r.Context())
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, items)
|
||||
}
|
||||
|
||||
func (s *Server) handleCreateTargetJSON(w http.ResponseWriter, r *http.Request) {
|
||||
var input basemap.CreateTargetSystemInput
|
||||
if err := json.NewDecoder(r.Body).Decode(&input); err != nil {
|
||||
writeError(w, http.StatusBadRequest, err)
|
||||
return
|
||||
}
|
||||
item, err := s.store.CreateTargetSystem(r.Context(), input)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusBadRequest, err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusCreated, item)
|
||||
}
|
||||
|
||||
func (s *Server) handleTokensJSON(w http.ResponseWriter, r *http.Request) {
|
||||
items, err := s.store.ListTokens(r.Context())
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, items)
|
||||
}
|
||||
|
||||
func (s *Server) handleCreateTokenJSON(w http.ResponseWriter, r *http.Request) {
|
||||
input, err := decodeTokenInput(r)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusBadRequest, err)
|
||||
return
|
||||
}
|
||||
item, err := s.store.CreateToken(r.Context(), input)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusBadRequest, err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusCreated, item)
|
||||
}
|
||||
|
||||
func (s *Server) handleDisableTokenJSON(w http.ResponseWriter, r *http.Request) {
|
||||
if err := s.store.DisableToken(r.Context(), r.PathValue("id")); err != nil {
|
||||
writeError(w, http.StatusBadRequest, err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]string{"status": "ok"})
|
||||
}
|
||||
|
||||
func (s *Server) handlePushRecordsJSON(w http.ResponseWriter, r *http.Request) {
|
||||
items, err := s.store.ListPushRecords(r.Context(), 20)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, items)
|
||||
}
|
||||
|
||||
func (s *Server) handleVectorPushRecordsJSON(w http.ResponseWriter, r *http.Request) {
|
||||
items, err := s.store.ListVectorPushRecords(r.Context(), 20)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, items)
|
||||
}
|
||||
|
||||
func (s *Server) handlePushJSON(w http.ResponseWriter, r *http.Request) {
|
||||
var payload struct {
|
||||
TargetCode string `json:"target_code"`
|
||||
BasemapCode string `json:"basemap_code"`
|
||||
Version string `json:"version"`
|
||||
}
|
||||
if err := json.NewDecoder(r.Body).Decode(&payload); err != nil {
|
||||
writeError(w, http.StatusBadRequest, err)
|
||||
return
|
||||
}
|
||||
item, err := s.store.PushBasemapVersion(r.Context(), payload.TargetCode, payload.BasemapCode, payload.Version)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusBadRequest, err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusCreated, item)
|
||||
}
|
||||
|
||||
func (s *Server) handleVectorPushJSON(w http.ResponseWriter, r *http.Request) {
|
||||
var payload struct {
|
||||
TargetCode string `json:"target_code"`
|
||||
VectorCode string `json:"vector_code"`
|
||||
}
|
||||
if err := json.NewDecoder(r.Body).Decode(&payload); err != nil {
|
||||
writeError(w, http.StatusBadRequest, err)
|
||||
return
|
||||
}
|
||||
item, err := s.store.PushVectorAsset(r.Context(), payload.TargetCode, payload.VectorCode)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusBadRequest, err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusCreated, item)
|
||||
}
|
||||
|
||||
func (s *Server) handleCatalogList(w http.ResponseWriter, r *http.Request) {
|
||||
auth, ok := s.requireToken(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
items, err := s.store.ListBasemaps(r.Context())
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, s.store.FilterCatalog(auth, items))
|
||||
}
|
||||
|
||||
func (s *Server) handleCatalogGet(w http.ResponseWriter, r *http.Request) {
|
||||
auth, ok := s.requireToken(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
item, err := s.store.GetBasemapByCode(r.Context(), r.PathValue("code"))
|
||||
if err != nil {
|
||||
writeError(w, http.StatusNotFound, err)
|
||||
return
|
||||
}
|
||||
filtered := s.store.FilterCatalog(auth, []basemap.Basemap{item})
|
||||
if len(filtered) == 0 {
|
||||
writeError(w, http.StatusForbidden, errors.New("token has no access to this basemap"))
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, filtered[0])
|
||||
}
|
||||
|
||||
func (s *Server) handleVectorCatalogList(w http.ResponseWriter, r *http.Request) {
|
||||
auth, ok := s.requireToken(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
items, err := s.store.ListVectorAssets(r.Context())
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, s.store.FilterVectors(auth, items))
|
||||
}
|
||||
|
||||
func (s *Server) handleVectorCatalogGet(w http.ResponseWriter, r *http.Request) {
|
||||
auth, ok := s.requireToken(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
item, err := s.store.GetVectorAssetByCode(r.Context(), r.PathValue("code"))
|
||||
if err != nil {
|
||||
writeError(w, http.StatusNotFound, err)
|
||||
return
|
||||
}
|
||||
filtered := s.store.FilterVectors(auth, []basemap.VectorAsset{item})
|
||||
if len(filtered) == 0 {
|
||||
writeError(w, http.StatusForbidden, errors.New("token has no access to this vector asset"))
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, filtered[0])
|
||||
}
|
||||
|
||||
func (s *Server) handleTile(w http.ResponseWriter, r *http.Request) {
|
||||
auth, ok := s.requireToken(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
version := r.PathValue("version")
|
||||
tilePath := r.PathValue("tilePath")
|
||||
item, err := s.store.ResolveTile(r.Context(), auth, r.PathValue("code"), version, tilePath)
|
||||
if err != nil && errors.Is(err, basemap.ErrBasemapVersionNotFound) && isTileZoom(version) {
|
||||
item, err = s.store.ResolveDefaultTile(r.Context(), auth, r.PathValue("code"), version+"/"+tilePath)
|
||||
}
|
||||
if err != nil {
|
||||
writeError(w, http.StatusNotFound, err)
|
||||
return
|
||||
}
|
||||
w.Header().Set("Content-Type", item.ContentType)
|
||||
http.ServeFile(w, r, item.FilePath)
|
||||
}
|
||||
|
||||
func (s *Server) handleVectorDownload(w http.ResponseWriter, r *http.Request) {
|
||||
auth, ok := s.requireToken(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
item, err := s.store.ResolveVectorDownload(r.Context(), auth, r.PathValue("code"))
|
||||
if err != nil {
|
||||
writeError(w, http.StatusNotFound, err)
|
||||
return
|
||||
}
|
||||
w.Header().Set("Content-Type", item.ContentType)
|
||||
w.Header().Set("Content-Disposition", fmt.Sprintf("attachment; filename=%q", item.FileName))
|
||||
if item.Zipped {
|
||||
if err := basemap.WriteVectorZip(w, item); err != nil {
|
||||
writeError(w, http.StatusInternalServerError, err)
|
||||
}
|
||||
return
|
||||
}
|
||||
http.ServeFile(w, r, item.FilePath)
|
||||
}
|
||||
|
||||
func (s *Server) handleGeoJSONByPath(w http.ResponseWriter, r *http.Request) {
|
||||
auth, ok := s.requireToken(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
item, err := s.store.ResolveVectorDownloadByRef(r.Context(), auth, r.PathValue("assetPath"), "geojson")
|
||||
if err != nil {
|
||||
writeError(w, http.StatusNotFound, err)
|
||||
return
|
||||
}
|
||||
w.Header().Set("Content-Type", item.ContentType)
|
||||
http.ServeFile(w, r, item.FilePath)
|
||||
}
|
||||
|
||||
func (s *Server) handleShapeByPath(w http.ResponseWriter, r *http.Request) {
|
||||
auth, ok := s.requireToken(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
item, err := s.store.ResolveVectorDownloadByRef(r.Context(), auth, r.PathValue("assetPath"), "shapefile", "shpzip")
|
||||
if err != nil {
|
||||
writeError(w, http.StatusNotFound, err)
|
||||
return
|
||||
}
|
||||
w.Header().Set("Content-Type", item.ContentType)
|
||||
w.Header().Set("Content-Disposition", fmt.Sprintf("attachment; filename=%q", item.FileName))
|
||||
if item.Zipped {
|
||||
if err := basemap.WriteVectorZip(w, item); err != nil {
|
||||
writeError(w, http.StatusInternalServerError, err)
|
||||
}
|
||||
return
|
||||
}
|
||||
http.ServeFile(w, r, item.FilePath)
|
||||
}
|
||||
|
||||
func (s *Server) handleAdminPage(w http.ResponseWriter, r *http.Request) {
|
||||
data, err := s.store.Dashboard(r.Context(), s.system, r.URL.Query().Get("flash"), r.URL.Query().Get("token"))
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, err)
|
||||
return
|
||||
}
|
||||
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||
if err := s.template.Execute(w, data); err != nil {
|
||||
writeError(w, http.StatusInternalServerError, err)
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Server) handleCreateScanSourceForm(w http.ResponseWriter, r *http.Request) {
|
||||
if err := r.ParseForm(); err != nil {
|
||||
s.redirectAdmin(w, r, "", "", err)
|
||||
return
|
||||
}
|
||||
_, err := s.store.EnsureScanSource(r.Context(), basemap.CreateScanSourceInput{
|
||||
Code: r.FormValue("code"),
|
||||
Name: r.FormValue("name"),
|
||||
RootPath: r.FormValue("root_path"),
|
||||
ManifestName: r.FormValue("manifest_name"),
|
||||
})
|
||||
s.redirectAdmin(w, r, "扫描源已保存", "", err)
|
||||
}
|
||||
|
||||
func (s *Server) handleRunScanForm(w http.ResponseWriter, r *http.Request) {
|
||||
_, err := s.store.RunScan(r.Context(), r.PathValue("code"))
|
||||
s.redirectAdmin(w, r, "底图扫描已完成", "", err)
|
||||
}
|
||||
|
||||
func (s *Server) handleRunVectorScanForm(w http.ResponseWriter, r *http.Request) {
|
||||
_, err := s.store.RunVectorScan(r.Context())
|
||||
s.redirectAdmin(w, r, "矢量扫描已完成", "", err)
|
||||
}
|
||||
|
||||
func (s *Server) handleSetDefaultVersionForm(w http.ResponseWriter, r *http.Request) {
|
||||
if err := r.ParseForm(); err != nil {
|
||||
s.redirectAdmin(w, r, "", "", err)
|
||||
return
|
||||
}
|
||||
err := s.store.SetDefaultVersion(r.Context(), r.PathValue("code"), r.FormValue("version"))
|
||||
s.redirectAdmin(w, r, "默认版本已更新", "", err)
|
||||
}
|
||||
|
||||
func (s *Server) handleCreateTargetForm(w http.ResponseWriter, r *http.Request) {
|
||||
if err := r.ParseForm(); err != nil {
|
||||
s.redirectAdmin(w, r, "", "", err)
|
||||
return
|
||||
}
|
||||
headers := map[string]string{}
|
||||
for _, line := range splitLines(r.FormValue("headers")) {
|
||||
key, value, ok := strings.Cut(line, ":")
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
headers[strings.TrimSpace(key)] = strings.TrimSpace(value)
|
||||
}
|
||||
_, err := s.store.CreateTargetSystem(r.Context(), basemap.CreateTargetSystemInput{
|
||||
Code: r.FormValue("code"),
|
||||
Name: r.FormValue("name"),
|
||||
CallbackURL: r.FormValue("callback_url"),
|
||||
CallbackMethod: r.FormValue("callback_method"),
|
||||
CallbackHeaders: headers,
|
||||
})
|
||||
s.redirectAdmin(w, r, "目标系统已保存", "", err)
|
||||
}
|
||||
|
||||
func (s *Server) handleCreateTokenForm(w http.ResponseWriter, r *http.Request) {
|
||||
input, err := decodeTokenInputFromForm(r)
|
||||
if err != nil {
|
||||
s.redirectAdmin(w, r, "", "", err)
|
||||
return
|
||||
}
|
||||
item, err := s.store.CreateToken(r.Context(), input)
|
||||
if err != nil {
|
||||
s.redirectAdmin(w, r, "", "", err)
|
||||
return
|
||||
}
|
||||
s.redirectAdmin(w, r, "访问令牌已创建", item.Token, nil)
|
||||
}
|
||||
|
||||
func (s *Server) handleDisableTokenForm(w http.ResponseWriter, r *http.Request) {
|
||||
err := s.store.DisableToken(r.Context(), r.PathValue("id"))
|
||||
s.redirectAdmin(w, r, "访问令牌已停用", "", err)
|
||||
}
|
||||
|
||||
func (s *Server) handlePushForm(w http.ResponseWriter, r *http.Request) {
|
||||
if err := r.ParseForm(); err != nil {
|
||||
s.redirectAdmin(w, r, "", "", err)
|
||||
return
|
||||
}
|
||||
_, err := s.store.PushBasemapVersion(r.Context(), r.FormValue("target_code"), r.FormValue("basemap_code"), r.FormValue("version"))
|
||||
s.redirectAdmin(w, r, "底图推送已完成", "", err)
|
||||
}
|
||||
|
||||
func (s *Server) handleVectorPushForm(w http.ResponseWriter, r *http.Request) {
|
||||
if err := r.ParseForm(); err != nil {
|
||||
s.redirectAdmin(w, r, "", "", err)
|
||||
return
|
||||
}
|
||||
_, err := s.store.PushVectorAsset(r.Context(), r.FormValue("target_code"), r.FormValue("vector_code"))
|
||||
s.redirectAdmin(w, r, "矢量推送已完成", "", err)
|
||||
}
|
||||
|
||||
func (s *Server) requireToken(w http.ResponseWriter, r *http.Request) (basemap.TokenAuth, bool) {
|
||||
rawToken := extractToken(r)
|
||||
auth, err := s.store.AuthorizeToken(r.Context(), rawToken)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusUnauthorized, err)
|
||||
return basemap.TokenAuth{}, false
|
||||
}
|
||||
return auth, true
|
||||
}
|
||||
|
||||
func extractToken(r *http.Request) string {
|
||||
if value := strings.TrimSpace(r.Header.Get("X-Access-Token")); value != "" {
|
||||
return value
|
||||
}
|
||||
if header := strings.TrimSpace(r.Header.Get("Authorization")); header != "" {
|
||||
if strings.HasPrefix(strings.ToLower(header), "bearer ") {
|
||||
return strings.TrimSpace(header[7:])
|
||||
}
|
||||
}
|
||||
for _, key := range []string{"token", "access_token"} {
|
||||
if value := strings.TrimSpace(r.URL.Query().Get(key)); value != "" {
|
||||
return value
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func decodeTokenInput(r *http.Request) (basemap.CreateTokenInput, error) {
|
||||
var payload struct {
|
||||
Name string `json:"name"`
|
||||
BasemapCodes []string `json:"basemap_codes"`
|
||||
VectorCodes []string `json:"vector_codes"`
|
||||
ExpiresAt string `json:"expires_at"`
|
||||
}
|
||||
if err := json.NewDecoder(r.Body).Decode(&payload); err != nil {
|
||||
return basemap.CreateTokenInput{}, err
|
||||
}
|
||||
input := basemap.CreateTokenInput{
|
||||
Name: payload.Name,
|
||||
BasemapCodes: payload.BasemapCodes,
|
||||
VectorCodes: payload.VectorCodes,
|
||||
}
|
||||
if strings.TrimSpace(payload.ExpiresAt) != "" {
|
||||
parsed, err := time.Parse(time.RFC3339, payload.ExpiresAt)
|
||||
if err != nil {
|
||||
return basemap.CreateTokenInput{}, fmt.Errorf("parse expires_at: %w", err)
|
||||
}
|
||||
input.ExpiresAt = &parsed
|
||||
}
|
||||
return input, nil
|
||||
}
|
||||
|
||||
func decodeTokenInputFromForm(r *http.Request) (basemap.CreateTokenInput, error) {
|
||||
if err := r.ParseForm(); err != nil {
|
||||
return basemap.CreateTokenInput{}, err
|
||||
}
|
||||
input := basemap.CreateTokenInput{
|
||||
Name: r.FormValue("name"),
|
||||
BasemapCodes: splitCSV(r.FormValue("basemap_codes")),
|
||||
VectorCodes: splitCSV(r.FormValue("vector_codes")),
|
||||
}
|
||||
if value := strings.TrimSpace(r.FormValue("expires_at")); value != "" {
|
||||
if minutes, err := strconv.Atoi(value); err == nil && minutes > 0 {
|
||||
expiresAt := time.Now().UTC().Add(time.Duration(minutes) * time.Minute)
|
||||
input.ExpiresAt = &expiresAt
|
||||
}
|
||||
}
|
||||
return input, nil
|
||||
}
|
||||
|
||||
func splitCSV(value string) []string {
|
||||
fields := strings.FieldsFunc(value, func(r rune) bool {
|
||||
return r == ',' || r == '\n' || r == '\r' || r == '\t' || r == ' '
|
||||
})
|
||||
result := make([]string, 0, len(fields))
|
||||
for _, field := range fields {
|
||||
field = strings.TrimSpace(field)
|
||||
if field != "" {
|
||||
result = append(result, field)
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
func splitLines(value string) []string {
|
||||
raw := strings.Split(strings.ReplaceAll(value, "\r\n", "\n"), "\n")
|
||||
lines := make([]string, 0, len(raw))
|
||||
for _, line := range raw {
|
||||
line = strings.TrimSpace(line)
|
||||
if line != "" {
|
||||
lines = append(lines, line)
|
||||
}
|
||||
}
|
||||
return lines
|
||||
}
|
||||
|
||||
func isTileZoom(value string) bool {
|
||||
if value == "" {
|
||||
return false
|
||||
}
|
||||
for _, r := range value {
|
||||
if r < '0' || r > '9' {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func statusText(value string) string {
|
||||
switch strings.ToLower(strings.TrimSpace(value)) {
|
||||
case "":
|
||||
return "-"
|
||||
case "active", "enabled":
|
||||
return "启用"
|
||||
case "disabled":
|
||||
return "停用"
|
||||
case "ready":
|
||||
return "就绪"
|
||||
case "succeeded":
|
||||
return "成功"
|
||||
case "failed":
|
||||
return "失败"
|
||||
case "pending":
|
||||
return "待处理"
|
||||
case "running":
|
||||
return "运行中"
|
||||
case "processing":
|
||||
return "处理中"
|
||||
case "current":
|
||||
return "当前"
|
||||
case "error":
|
||||
return "异常"
|
||||
default:
|
||||
return value
|
||||
}
|
||||
}
|
||||
|
||||
func statusTone(value string) string {
|
||||
switch strings.ToLower(strings.TrimSpace(value)) {
|
||||
case "active", "enabled", "ready", "succeeded", "current":
|
||||
return "success"
|
||||
case "pending", "running", "processing":
|
||||
return "warning"
|
||||
case "disabled", "failed", "error", "expired", "invalid":
|
||||
return "danger"
|
||||
default:
|
||||
return "neutral"
|
||||
}
|
||||
}
|
||||
|
||||
func formatBytes(size int64) string {
|
||||
if size < 0 {
|
||||
return "-"
|
||||
}
|
||||
units := []string{"B", "KB", "MB", "GB", "TB"}
|
||||
value := float64(size)
|
||||
unit := 0
|
||||
for value >= 1024 && unit < len(units)-1 {
|
||||
value /= 1024
|
||||
unit++
|
||||
}
|
||||
if unit == 0 {
|
||||
return fmt.Sprintf("%d %s", size, units[unit])
|
||||
}
|
||||
return fmt.Sprintf("%.1f %s", value, units[unit])
|
||||
}
|
||||
|
||||
func localizeAdminError(err error) string {
|
||||
if err == nil {
|
||||
return ""
|
||||
}
|
||||
msg := strings.TrimSpace(err.Error())
|
||||
quoted := quotedSegments(msg)
|
||||
|
||||
switch {
|
||||
case msg == "scan source code is required":
|
||||
return "扫描源编码不能为空"
|
||||
case msg == "scan source root path is required":
|
||||
return "扫描根目录不能为空"
|
||||
case strings.HasPrefix(msg, "resolve root path:"):
|
||||
return "扫描根目录解析失败:" + strings.TrimSpace(strings.TrimPrefix(msg, "resolve root path:"))
|
||||
case len(quoted) == 1 && strings.HasPrefix(msg, `scan source "`) && strings.HasSuffix(msg, `" not found`):
|
||||
return fmt.Sprintf("未找到扫描源:%s", quoted[0])
|
||||
case len(quoted) == 1 && strings.HasPrefix(msg, `scan source "`) && strings.HasSuffix(msg, `" is disabled`):
|
||||
return fmt.Sprintf("扫描源已停用:%s", quoted[0])
|
||||
case strings.HasPrefix(msg, "read scan root:"):
|
||||
return "读取扫描目录失败:" + strings.TrimSpace(strings.TrimPrefix(msg, "read scan root:"))
|
||||
case strings.HasPrefix(msg, "scan root ") && strings.HasSuffix(msg, " is not a directory"):
|
||||
return "配置的扫描根路径不是目录:" + strings.TrimSuffix(strings.TrimPrefix(msg, "scan root "), " is not a directory")
|
||||
case msg == "target system code is required":
|
||||
return "目标系统编码不能为空"
|
||||
case msg == "callback url is required":
|
||||
return "回调地址不能为空"
|
||||
case len(quoted) == 1 && strings.HasPrefix(msg, `target system "`) && strings.HasSuffix(msg, `" not found`):
|
||||
return fmt.Sprintf("未找到目标系统:%s", quoted[0])
|
||||
case len(quoted) == 1 && strings.HasPrefix(msg, `target system "`) && strings.HasSuffix(msg, `" is disabled`):
|
||||
return fmt.Sprintf("目标系统已停用:%s", quoted[0])
|
||||
case msg == "token name is required":
|
||||
return "令牌名称不能为空"
|
||||
case msg == "at least one basemap code or vector code is required":
|
||||
return "至少填写一个底图编码或矢量资源编码/路径"
|
||||
case msg == "no valid basemap codes provided":
|
||||
return "未提供有效的底图编码"
|
||||
case len(quoted) == 1 && strings.HasPrefix(msg, `token "`) && strings.HasSuffix(msg, `" not found`):
|
||||
return fmt.Sprintf("未找到访问令牌:%s", quoted[0])
|
||||
case msg == "basemap code and version are required":
|
||||
return "底图编码和版本不能为空"
|
||||
case len(quoted) == 1 && strings.HasPrefix(msg, `basemap "`) && strings.HasSuffix(msg, `" not found`):
|
||||
return fmt.Sprintf("未找到底图资源:%s", quoted[0])
|
||||
case len(quoted) == 2 && strings.HasPrefix(msg, `version "`) && strings.Contains(msg, `" not found for basemap "`):
|
||||
return fmt.Sprintf("底图资源 %s 未找到版本 %s", quoted[1], quoted[0])
|
||||
case len(quoted) == 1 && strings.HasPrefix(msg, `vector asset "`) && strings.HasSuffix(msg, `" not found`):
|
||||
return fmt.Sprintf("未找到矢量资源:%s", quoted[0])
|
||||
case len(quoted) == 1 && strings.HasPrefix(msg, `vector asset "`) && strings.HasSuffix(msg, `" is not available in requested format`):
|
||||
return fmt.Sprintf("矢量资源当前不可按所选格式提供:%s", quoted[0])
|
||||
default:
|
||||
return msg
|
||||
}
|
||||
}
|
||||
|
||||
func quotedSegments(value string) []string {
|
||||
parts := strings.Split(value, `"`)
|
||||
result := make([]string, 0, len(parts)/2)
|
||||
for i := 1; i < len(parts); i += 2 {
|
||||
result = append(result, parts[i])
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
func (s *Server) redirectAdmin(w http.ResponseWriter, r *http.Request, flash string, token string, err error) {
|
||||
if err != nil {
|
||||
flash = "操作失败:" + localizeAdminError(err)
|
||||
}
|
||||
values := url.Values{}
|
||||
if flash != "" {
|
||||
values.Set("flash", flash)
|
||||
}
|
||||
if token != "" {
|
||||
values.Set("token", token)
|
||||
}
|
||||
location := "/admin/"
|
||||
if encoded := values.Encode(); encoded != "" {
|
||||
location += "?" + encoded
|
||||
}
|
||||
http.Redirect(w, r, location, http.StatusSeeOther)
|
||||
}
|
||||
|
||||
func writeJSON(w http.ResponseWriter, status int, value any) {
|
||||
w.Header().Set("Content-Type", "application/json; charset=utf-8")
|
||||
w.WriteHeader(status)
|
||||
_ = json.NewEncoder(w).Encode(value)
|
||||
}
|
||||
|
||||
func writeError(w http.ResponseWriter, status int, err error) {
|
||||
writeJSON(w, status, map[string]any{
|
||||
"error": err.Error(),
|
||||
})
|
||||
}
|
||||
|
||||
func runStartupScan(ctx context.Context, store *basemap.Store) error {
|
||||
if !store.AutoScanEnabled() || strings.TrimSpace(store.ScanRoot()) == "" {
|
||||
return nil
|
||||
}
|
||||
_, err := store.RunScan(ctx, "local-basemaps")
|
||||
return err
|
||||
}
|
||||
@@ -0,0 +1,108 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
)
|
||||
|
||||
type Config struct {
|
||||
Host string
|
||||
Port int
|
||||
APIBaseURL string
|
||||
TilePublicBaseURL string
|
||||
VectorPublicBaseURL string
|
||||
SQLitePath string
|
||||
BasemapScanRoot string
|
||||
VectorScanRoot string
|
||||
AutoScanOnStart bool
|
||||
Env string
|
||||
ServiceName string
|
||||
ServiceVersion string
|
||||
Commit string
|
||||
}
|
||||
|
||||
func Load() (Config, error) {
|
||||
if err := LoadEnvDefaults(); err != nil {
|
||||
return Config{}, err
|
||||
}
|
||||
|
||||
port, err := intEnv("API_PORT", 8910)
|
||||
if err != nil {
|
||||
return Config{}, fmt.Errorf("parse API_PORT: %w", err)
|
||||
}
|
||||
autoScanOnStart, err := boolEnv("AUTO_SCAN_ON_START", true)
|
||||
if err != nil {
|
||||
return Config{}, fmt.Errorf("parse AUTO_SCAN_ON_START: %w", err)
|
||||
}
|
||||
|
||||
cfg := Config{
|
||||
Host: stringEnv("API_HOST", "127.0.0.1"),
|
||||
Port: port,
|
||||
APIBaseURL: stringEnv("API_BASE_URL", ""),
|
||||
TilePublicBaseURL: stringEnv("TILE_PUBLIC_BASE_URL", ""),
|
||||
VectorPublicBaseURL: stringEnv("VECTOR_PUBLIC_BASE_URL", ""),
|
||||
SQLitePath: stringEnv("SQLITE_PATH", filepath.Clean(filepath.Join("data", "map-asset-gateway.db"))),
|
||||
BasemapScanRoot: stringEnv("BASEMAP_SCAN_ROOT", filepath.Clean(filepath.Join("data", "tiles"))),
|
||||
VectorScanRoot: stringEnv("VECTOR_SCAN_ROOT", filepath.Clean(filepath.Join("data", "geojson"))),
|
||||
AutoScanOnStart: autoScanOnStart,
|
||||
Env: stringEnv("APP_ENV", "dev"),
|
||||
ServiceName: stringEnv("SERVICE_NAME", "map-asset-gateway"),
|
||||
ServiceVersion: stringEnv("SERVICE_VERSION", "0.2.0"),
|
||||
Commit: stringEnv("SERVICE_COMMIT", "local"),
|
||||
}
|
||||
if cfg.APIBaseURL == "" {
|
||||
cfg.APIBaseURL = deriveBaseURL(cfg.Host, cfg.Port)
|
||||
}
|
||||
if cfg.TilePublicBaseURL == "" {
|
||||
cfg.TilePublicBaseURL = cfg.APIBaseURL
|
||||
}
|
||||
if cfg.VectorPublicBaseURL == "" {
|
||||
cfg.VectorPublicBaseURL = cfg.APIBaseURL
|
||||
}
|
||||
return cfg, nil
|
||||
}
|
||||
|
||||
func (c Config) ListenAddr() string {
|
||||
return fmt.Sprintf("%s:%d", c.Host, c.Port)
|
||||
}
|
||||
|
||||
func deriveBaseURL(host string, port int) string {
|
||||
if host == "" || host == "0.0.0.0" {
|
||||
host = "127.0.0.1"
|
||||
}
|
||||
return fmt.Sprintf("http://%s:%d", host, port)
|
||||
}
|
||||
|
||||
func stringEnv(key, fallback string) string {
|
||||
value, ok := os.LookupEnv(key)
|
||||
if ok && value != "" {
|
||||
return value
|
||||
}
|
||||
return fallback
|
||||
}
|
||||
|
||||
func intEnv(key string, fallback int) (int, error) {
|
||||
value, ok := os.LookupEnv(key)
|
||||
if ok && value != "" {
|
||||
parsed, err := strconv.Atoi(value)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return parsed, nil
|
||||
}
|
||||
return fallback, nil
|
||||
}
|
||||
|
||||
func boolEnv(key string, fallback bool) (bool, error) {
|
||||
value, ok := os.LookupEnv(key)
|
||||
if ok && value != "" {
|
||||
parsed, err := strconv.ParseBool(value)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
return parsed, nil
|
||||
}
|
||||
return fallback, nil
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
)
|
||||
|
||||
func LoadEnvDefaults() error {
|
||||
if explicit := strings.TrimSpace(os.Getenv("APP_ENV_FILE")); explicit != "" {
|
||||
if err := loadEnvFile(explicit); err != nil {
|
||||
return fmt.Errorf("load APP_ENV_FILE %s: %w", explicit, err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
for _, candidate := range []string{
|
||||
filepath.Join("config", "service.env"),
|
||||
filepath.Join("..", "..", "config", "service.env"),
|
||||
} {
|
||||
if _, err := os.Stat(candidate); err == nil {
|
||||
if err := loadEnvFile(candidate); err != nil {
|
||||
return fmt.Errorf("load env file %s: %w", candidate, err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func loadEnvFile(path string) error {
|
||||
file, err := os.Open(path)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer file.Close()
|
||||
|
||||
scanner := bufio.NewScanner(file)
|
||||
lineNo := 0
|
||||
for scanner.Scan() {
|
||||
lineNo++
|
||||
line := strings.TrimSpace(scanner.Text())
|
||||
if line == "" || strings.HasPrefix(line, "#") {
|
||||
continue
|
||||
}
|
||||
if strings.HasPrefix(line, "export ") {
|
||||
line = strings.TrimSpace(strings.TrimPrefix(line, "export "))
|
||||
}
|
||||
|
||||
key, value, found := strings.Cut(line, "=")
|
||||
if !found {
|
||||
return fmt.Errorf("line %d: missing '='", lineNo)
|
||||
}
|
||||
key = strings.TrimSpace(key)
|
||||
value = strings.TrimSpace(value)
|
||||
value = strings.Trim(value, `"'`)
|
||||
if key == "" {
|
||||
return fmt.Errorf("line %d: empty key", lineNo)
|
||||
}
|
||||
if _, exists := os.LookupEnv(key); exists {
|
||||
continue
|
||||
}
|
||||
if err := os.Setenv(key, value); err != nil {
|
||||
return fmt.Errorf("line %d: set %s: %w", lineNo, key, err)
|
||||
}
|
||||
}
|
||||
if err := scanner.Err(); err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
package uid
|
||||
|
||||
import (
|
||||
"crypto/sha1"
|
||||
"fmt"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// Deterministic returns a stable UUID-like identifier derived from the input.
|
||||
func Deterministic(parts ...string) string {
|
||||
sum := sha1.Sum([]byte(strings.Join(parts, "::")))
|
||||
b := sum[:16]
|
||||
|
||||
b[6] = (b[6] & 0x0f) | 0x50
|
||||
b[8] = (b[8] & 0x3f) | 0x80
|
||||
|
||||
return fmt.Sprintf(
|
||||
"%08x-%04x-%04x-%04x-%012x",
|
||||
b[0:4],
|
||||
b[4:6],
|
||||
b[6:8],
|
||||
b[8:10],
|
||||
b[10:16],
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
package uid
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"fmt"
|
||||
)
|
||||
|
||||
func New() string {
|
||||
var b [16]byte
|
||||
if _, err := rand.Read(b[:]); err != nil {
|
||||
panic(err)
|
||||
}
|
||||
|
||||
b[6] = (b[6] & 0x0f) | 0x40
|
||||
b[8] = (b[8] & 0x3f) | 0x80
|
||||
|
||||
return fmt.Sprintf(
|
||||
"%08x-%04x-%04x-%04x-%012x",
|
||||
b[0:4],
|
||||
b[4:6],
|
||||
b[6:8],
|
||||
b[8:10],
|
||||
b[10:16],
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user