canvas-website/src/lib/location/locationStorage.ts

303 lines
8.3 KiB
TypeScript

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<void> {
// 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<void> {
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<void> {
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<LocationData | null> {
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<void> {
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<LocationShare | null> {
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<LocationShare[]> {
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<LocationShare | null> {
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<void> {
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('');
}