import type FileSystem from '@oddjs/odd/fs/index'; import * as odd from '@oddjs/odd'; import type { PrecisionLevel } from './types'; /** * Location data stored in the filesystem */ export interface LocationData { id: string; userId: string; latitude: number; longitude: number; accuracy: number; timestamp: number; expiresAt: number | null; precision: PrecisionLevel; } /** * Location share metadata */ export interface LocationShare { id: string; locationId: string; shareToken: string; createdAt: number; expiresAt: number | null; maxViews: number | null; viewCount: number; precision: PrecisionLevel; } /** * Location storage service * Handles storing and retrieving locations from the ODD.js filesystem */ export class LocationStorageService { private fs: FileSystem; private locationsPath: string[]; private sharesPath: string[]; private publicSharesPath: string[]; constructor(fs: FileSystem) { this.fs = fs; // Private storage paths this.locationsPath = ['private', 'locations']; this.sharesPath = ['private', 'location-shares']; // Public reference path for share validation this.publicSharesPath = ['public', 'location-shares']; } /** * Initialize directories */ async initialize(): Promise { // Ensure private directories exist await this.ensureDirectory(this.locationsPath); await this.ensureDirectory(this.sharesPath); // Ensure public directory for share references await this.ensureDirectory(this.publicSharesPath); } /** * Ensure a directory exists */ private async ensureDirectory(path: string[]): Promise { try { const dirPath = odd.path.directory(...path); const fs = this.fs as any; const exists = await fs.exists(dirPath); if (!exists) { await fs.mkdir(dirPath); } } catch (error) { console.error('Error ensuring directory:', error); throw error; } } /** * Save a location to the filesystem */ async saveLocation(location: LocationData): Promise { try { const filePath = (odd.path as any).file(...this.locationsPath, `${location.id}.json`); const content = new TextEncoder().encode(JSON.stringify(location, null, 2)); const fs = this.fs as any; await fs.write(filePath, content); await fs.publish(); } catch (error) { console.error('Error saving location:', error); throw error; } } /** * Get a location by ID */ async getLocation(locationId: string): Promise { try { const filePath = (odd.path as any).file(...this.locationsPath, `${locationId}.json`); const fs = this.fs as any; const exists = await fs.exists(filePath); if (!exists) { return null; } const content = await fs.read(filePath); const text = new TextDecoder().decode(content as Uint8Array); return JSON.parse(text) as LocationData; } catch (error) { console.error('Error reading location:', error); return null; } } /** * Create a location share */ async createShare(share: LocationShare): Promise { try { // Save share metadata in private directory const sharePath = (odd.path as any).file(...this.sharesPath, `${share.id}.json`); const shareContent = new TextEncoder().encode(JSON.stringify(share, null, 2)); const fs = this.fs as any; await fs.write(sharePath, shareContent); // Create public reference file for share validation (only token, not full data) const publicSharePath = (odd.path as any).file(...this.publicSharesPath, `${share.shareToken}.json`); const publicShareRef = { shareToken: share.shareToken, shareId: share.id, createdAt: share.createdAt, expiresAt: share.expiresAt, }; const publicContent = new TextEncoder().encode(JSON.stringify(publicShareRef, null, 2)); await fs.write(publicSharePath, publicContent); await fs.publish(); } catch (error) { console.error('Error creating share:', error); throw error; } } /** * Get a share by token */ async getShareByToken(shareToken: string): Promise { try { // First check public reference const publicSharePath = (odd.path as any).file(...this.publicSharesPath, `${shareToken}.json`); const fs = this.fs as any; const publicExists = await fs.exists(publicSharePath); if (!publicExists) { return null; } const publicContent = await fs.read(publicSharePath); const publicText = new TextDecoder().decode(publicContent as Uint8Array); const publicRef = JSON.parse(publicText); // Now get full share from private directory const sharePath = (odd.path as any).file(...this.sharesPath, `${publicRef.shareId}.json`); const shareExists = await fs.exists(sharePath); if (!shareExists) { return null; } const shareContent = await fs.read(sharePath); const shareText = new TextDecoder().decode(shareContent as Uint8Array); return JSON.parse(shareText) as LocationShare; } catch (error) { console.error('Error reading share:', error); return null; } } /** * Get all shares for the current user */ async getAllShares(): Promise { try { const dirPath = odd.path.directory(...this.sharesPath); const fs = this.fs as any; const exists = await fs.exists(dirPath); if (!exists) { return []; } const files = await fs.ls(dirPath); const shares: LocationShare[] = []; for (const fileName of Object.keys(files)) { if (fileName.endsWith('.json')) { const shareId = fileName.replace('.json', ''); const share = await this.getShareById(shareId); if (share) { shares.push(share); } } } return shares; } catch (error) { console.error('Error listing shares:', error); return []; } } /** * Get a share by ID */ private async getShareById(shareId: string): Promise { try { const sharePath = (odd.path as any).file(...this.sharesPath, `${shareId}.json`); const fs = this.fs as any; const exists = await fs.exists(sharePath); if (!exists) { return null; } const content = await fs.read(sharePath); const text = new TextDecoder().decode(content as Uint8Array); return JSON.parse(text) as LocationShare; } catch (error) { console.error('Error reading share:', error); return null; } } /** * Increment view count for a share */ async incrementShareViews(shareId: string): Promise { try { const share = await this.getShareById(shareId); if (!share) { throw new Error('Share not found'); } share.viewCount += 1; await this.createShare(share); // Re-save the share } catch (error) { console.error('Error incrementing share views:', error); throw error; } } } /** * Obfuscate location based on precision level */ export function obfuscateLocation( lat: number, lng: number, precision: PrecisionLevel ): { lat: number; lng: number; radius: number } { let radius = 0; switch (precision) { case 'exact': radius = 0; break; case 'street': radius = 100; // ~100m radius break; case 'neighborhood': radius = 1000; // ~1km radius break; case 'city': radius = 10000; // ~10km radius break; } if (radius === 0) { return { lat, lng, radius: 0 }; } // Add random offset within the radius const angle = Math.random() * 2 * Math.PI; const distance = Math.random() * radius; // Convert distance to degrees (rough approximation: 1 degree ≈ 111km) const latOffset = (distance / 111000) * Math.cos(angle); const lngOffset = (distance / (111000 * Math.cos(lat * Math.PI / 180))) * Math.sin(angle); return { lat: lat + latOffset, lng: lng + lngOffset, radius, }; } /** * Generate a secure share token */ export function generateShareToken(): string { // Generate a cryptographically secure random token const array = new Uint8Array(32); crypto.getRandomValues(array); return Array.from(array, (byte) => byte.toString(16).padStart(2, '0')).join(''); }