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 }