Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
151 changes: 89 additions & 62 deletions packages/appkit/src/app/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,22 +11,78 @@ interface RequestLike {

interface DevFileReader {
readFile(filePath: string, req: RequestLike): Promise<string>;
readdir(dirPath: string, req: RequestLike): Promise<string[]>;
}

interface QueryResult {
query: string;
isAsUser: boolean;
}

/**
* Abstraction for filesystem operations that works in both dev and production modes
*/
interface FileSystemAdapter {
readdir(dirPath: string): Promise<string[]>;
readFile(filePath: string): Promise<string>;
}

export class AppManager {
private readonly queriesDir = path.resolve(process.cwd(), "config/queries");

/**
* Validates that a file path is within the queries directory
*/
private validatePath(fileName: string): string | null {
const queryFilePath = path.join(this.queriesDir, fileName);
const resolvedPath = path.resolve(queryFilePath);
const resolvedQueriesDir = path.resolve(this.queriesDir);

if (!resolvedPath.startsWith(resolvedQueriesDir)) {
logger.error("Invalid query path: path traversal detected");
return null;
}

return resolvedPath;
}

/**
* Creates a filesystem adapter based on dev mode or production mode
*/
private createFsAdapter(
req?: RequestLike,
devFileReader?: DevFileReader,
): FileSystemAdapter {
const isDevMode = req?.query?.dev !== undefined;

if (isDevMode && devFileReader && req) {
// Dev mode: use WebSocket tunnel to read from local filesystem
return {
readdir: async (dirPath: string) => {
const relativePath = path.relative(process.cwd(), dirPath);
return devFileReader.readdir(relativePath, req);
},
readFile: async (filePath: string) => {
const relativePath = path.relative(process.cwd(), filePath);
return devFileReader.readFile(relativePath, req);
},
};
}

// Production mode: use server filesystem
return {
readdir: (dirPath: string) => fs.readdir(dirPath),
readFile: (filePath: string) => fs.readFile(filePath, "utf8"),
};
}

/**
* Retrieves a query file by key from the queries directory
* In dev mode with a request context, reads from local filesystem via WebSocket
* @param queryKey - The query file name (without extension)
* @param req - Optional request object to detect dev mode
* @param devFileReader - Optional DevFileReader instance to read files from local filesystem
* @returns The query content as a string
* @throws Error if query key is invalid or file not found
* @returns The query content and execution mode (as user or as service principal)
*/
async getAppQuery(
queryKey: string,
Expand All @@ -42,89 +98,60 @@ export class AppManager {
return null;
}

const queriesDir = path.resolve(process.cwd(), "config/queries");
// Create filesystem adapter for dev or production mode
const fsAdapter = this.createFsAdapter(req, devFileReader);

// priority order: .obo.sql first (asUser), then .sql (default)
// Priority order: .obo.sql first (as user), then .sql (as service principal)
const oboFileName = `${queryKey}.obo.sql`;
const defaultFileName = `${queryKey}.sql`;

let queryFileName: string | null = null;
let isAsUser: boolean = false;

// List directory to find which query file exists
let files: string[];
try {
const files = await fs.readdir(queriesDir);

// check for OBO query first
if (files.includes(oboFileName)) {
queryFileName = oboFileName;
isAsUser = true;

// check for both files and warn if both are present
if (files.includes(defaultFileName)) {
logger.warn(
`Both ${oboFileName} and ${defaultFileName} found for query ${queryKey}. Using ${oboFileName}.`,
);
}
// check for default query if OBO query is not present
} else if (files.includes(defaultFileName)) {
queryFileName = defaultFileName;
isAsUser = false;
}
files = await fsAdapter.readdir(this.queriesDir);
} catch (error) {
logger.error(
`Failed to read queries directory: ${(error as Error).message}`,
);
return null;
}

if (!queryFileName) {
logger.error(`Query file not found: ${queryKey}`);
return null;
}
// Determine which query file to use
let queryFileName: string | null = null;
let isAsUser = false;

const queryFilePath = path.join(queriesDir, queryFileName);
if (files.includes(oboFileName)) {
queryFileName = oboFileName;
isAsUser = true;

// security: validate resolved path is within queries directory
const resolvedPath = path.resolve(queryFilePath);
const resolvedQueriesDir = path.resolve(queriesDir);
// Warn if both variants exist
if (files.includes(defaultFileName)) {
logger.warn(
`Both ${oboFileName} and ${defaultFileName} found for query ${queryKey}. Using ${oboFileName}.`,
);
}
} else if (files.includes(defaultFileName)) {
queryFileName = defaultFileName;
isAsUser = false;
}

if (!resolvedPath.startsWith(resolvedQueriesDir)) {
logger.error(`Invalid query path: path traversal detected`);
if (!queryFileName) {
logger.error(`Query file not found: ${queryKey}`);
return null;
}

// check if we're in dev mode and should use WebSocket
const isDevMode = req?.query?.dev !== undefined;
if (isDevMode && devFileReader && req) {
try {
const relativePath = path.relative(process.cwd(), resolvedPath);
return {
query: await devFileReader.readFile(relativePath, req),
isAsUser,
};
} catch (error) {
logger.error(
`Failed to read query from dev tunnel: ${(error as Error).message}`,
);
return null;
}
// Validate and resolve the file path
const resolvedPath = this.validatePath(queryFileName);
if (!resolvedPath) {
return null;
}

// production mode: read from server filesystem
// Read the query file
try {
const query = await fs.readFile(resolvedPath, "utf8");
const query = await fsAdapter.readFile(resolvedPath);
return { query, isAsUser };
} catch (error) {
if ((error as NodeJS.ErrnoException).code === "ENOENT") {
logger.error(
`Failed to read query from server filesystem: ${(error as Error).message}`,
);
return null;
}

logger.error(
`Failed to read query from server filesystem: ${(error as Error).message}`,
);
logger.error(`Failed to read query file: ${(error as Error).message}`);
return null;
}
}
Expand Down
Loading