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
|
||||
}
|
||||
Reference in New Issue
Block a user