Skip to content
Merged
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
11 changes: 11 additions & 0 deletions API_DOCUMENTATION.md
Original file line number Diff line number Diff line change
Expand Up @@ -589,6 +589,9 @@ Errors:
### List Books
- `GET /api/books/`
- Public
- Optional query params:
- `page` (default `1`)
- `limit` (default `20`)

Response `200`:
```json
Expand All @@ -598,6 +601,14 @@ Response `200`:
"id": "algorithms",
"title": "Algorithms",
"count": 10,
"pagination": {
"totalItems": 10,
"totalPages": 1,
"currentPage": 1,
"hasNextPage": false,
"hasPreviousPage": false,
"limit": 20
},
"items": [{"id": "...", "name": "...", "size": 1234, "url": "..."}]
}
],
Expand Down
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -126,7 +126,7 @@ Before getting started, ensure you have the following installed:
#### 1️⃣ Clone the Repository

```bash
git clone https://github.com/yourusername/PrepPilot.git
git clone https://github.com/Canopus-Labs/PrepPilot.git
cd PrepPilot
```

Expand Down
79 changes: 64 additions & 15 deletions backend/routes/booksRoutes.js
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ const router = express.Router();
const GITHUB_OWNER = "KaranUnique";
const GITHUB_REPO = "Free-programming-books";
const BRANCH = "main";
const ALLOWED_DOWNLOAD_HOSTS = new Set(["raw.githubusercontent.com"]);
const GITHUB_TOKEN = process.env.GITHUB_TOKEN || process.env.GH_TOKEN;

async function fetchJson(url) {
Expand All @@ -24,9 +25,14 @@ function buildRawUrl(path) {
return `https://raw.githubusercontent.com/${GITHUB_OWNER}/${GITHUB_REPO}/${BRANCH}/${encodeURI(path)}`;
}

async function listFilesRecursive(prefix) {
async function listFilesRecursive(prefix, page, limit) {
const files = [];
const queue = [prefix];
const requestedPage = Number.isFinite(page) && page >= 1 ? page : 1;
const itemsPerPage = Number.isFinite(limit) && limit >= 1 ? limit : 20;
const startIndex = (requestedPage - 1) * itemsPerPage;
const endIndex = requestedPage * itemsPerPage;
let totalItems = 0;

while (queue.length) {
const current = queue.shift();
Expand All @@ -36,30 +42,43 @@ async function listFilesRecursive(prefix) {
if (entry.type === "dir") {
queue.push(`${current}/${entry.name}`);
} else if (entry.type === "file") {
files.push({
path: `${current}/${entry.name}`,
name: entry.name,
size: entry.size,
url: entry.download_url || buildRawUrl(`${current}/${entry.name}`),
});
if (totalItems >= startIndex && totalItems < endIndex) {
files.push({
path: `${current}/${entry.name}`,
name: entry.name,
size: entry.size,
url: entry.download_url || buildRawUrl(`${current}/${entry.name}`),
});
}
totalItems += 1;
}
}
}

return files;
return {
totalItems,
items: files,
currentPage: requestedPage,
pageSize: itemsPerPage,
totalPages: Math.max(1, Math.ceil(totalItems / itemsPerPage)),
hasNextPage: requestedPage * itemsPerPage < totalItems,
hasPreviousPage: requestedPage > 1,
};
}

/**
* List programming book categories and files sourced from the GitHub repository.
* @route GET /api/books/
* @query page optional page number, default 1
* @query limit optional page size, default 20
* @param {import('express').Request} _req
* @param {import('express').Response} res
* @returns {Promise<void>}
* @throws {Error} When the GitHub API cannot be reached.
* @example
* GET /api/books/
* GET /api/books/?page=2&limit=20
* @example
* 200 {"categories": [{"id":"...","title":"...","items":[...]}], "warnings": []}
* 200 {"categories": [{"id":"...","title":"...","pagination":{"totalItems":100,...},"items":[...]}], "warnings": []}
*/
router.get("/", async (_req, res) => {
try {
Expand All @@ -71,15 +90,26 @@ router.get("/", async (_req, res) => {
);
const warnings = [];

const page = Number.parseInt(_req.query.page, 10);
const limit = Number.parseInt(_req.query.limit, 10);

const categories = await Promise.all(
categoryDirs.map(async (dir) => {
try {
const files = await listFilesRecursive(dir.name);
const result = await listFilesRecursive(dir.name, page, limit);
return {
id: dir.name.toLowerCase().replace(/\s+/g, "-"),
title: dir.name,
count: files.length,
items: files.map((f) => ({
count: result.totalItems,
pagination: {
totalItems: result.totalItems,
totalPages: result.totalPages,
currentPage: result.currentPage,
hasNextPage: result.hasNextPage,
hasPreviousPage: result.hasPreviousPage,
limit: result.pageSize,
},
items: result.items.map((f) => ({
id: `${dir.name}-${f.path}`,
name: f.path.slice(dir.name.length + 1),
size: f.size,
Expand Down Expand Up @@ -122,8 +152,27 @@ router.get("/", async (_req, res) => {
*/
router.get("/download", (req, res) => {
const { url } = req.query;
if (!url) return res.status(400).json({ message: "url query is required" });
return res.redirect(url);
if (typeof url !== "string" || !url.trim()) {
return res.status(400).json({ message: "url query is required" });
}

let parsedUrl;
try {
parsedUrl = new URL(url);
} catch {
return res.status(400).json({ message: "url query must be a valid URL" });
}

if (parsedUrl.protocol !== "https:") {
return res.status(403).json({ message: "download URL must use https" });
}

if (!ALLOWED_DOWNLOAD_HOSTS.has(parsedUrl.hostname)) {
return res.status(403).json({ message: "download URL host is not allowed" });
}

return res.redirect(parsedUrl.toString());
});

module.exports = router;
module.exports.listFilesRecursive = listFilesRecursive;
Loading