account based feeds
[rss-reader.git] / src / server.ts
index 4e1404e5385254aff7098fa56191ee991e2f3cf7..7add8509c2cbeb6b322eef587862f09fe159d502 100644 (file)
@@ -6,23 +6,44 @@ import { ingest, ingestSingle } from './ingester';
 import {RSSParser} from './parsers/rss';
 import bodyParser from 'body-parser';
 import {BaseParser} from './parsers/base';
+import fs from 'fs';
+import { promisify } from 'util';
 
-
+const HTML_ROOT = join(__dirname, '..', 'html');
 const app = express();
+const session: Map<string, string> = new Map<string, string>();
 
 app.use(bodyParser.json());
 app.use(bodyParser.urlencoded({ extended: true}));
-app.use(express.static(join(__dirname, '..', 'html')));
 app.use((req, res, next) => {
   console.log(`${req.method} ${req.path}`);
   next();
 });
 
 type WrappedApiHandler = (req: Request, res: Response) => Promise<any>;
+type WrapOptions = {
+  auth?: boolean;
+}
 
-function apiWrapper(method: 'get' | 'post' | 'delete', endpoint: string, fn: WrappedApiHandler, view?: (args: any) => string) : void {
+function apiWrapper(method: 'get' | 'post' | 'delete', endpoint: string, options: WrapOptions, fn: WrappedApiHandler, view?: (args: any) => string) : void {
   app[method](endpoint, async(req, res) => {
     try {
+      if(options.auth) {
+        if(!req.headers['hx-current-url']) {
+          throw new Error('Invald user');
+        }
+        const query = new URLSearchParams(req.headers['hx-current-url'].toString().split('?')[1]);
+        const token = query.get('token');
+        const id = query.get('id');
+
+        if(!token || !id) {
+          throw new Error('Invalid user');
+        }
+        if(!session.get(token) || session.get(token) !== id) {
+          throw new Error('Invalid user');
+        }
+      }
+
       const output = await fn(req, res);
       if(req.headers['content-type'] === 'application/json') {
         res.json(output);
@@ -37,7 +58,12 @@ function apiWrapper(method: 'get' | 'post' | 'delete', endpoint: string, fn: Wra
         }
       }
 
-      res.status(204);
+      if(!output) {
+        res.status(204);
+        return;
+      }
+
+      return res.json(output);
     }
     catch(e) {
       console.error(e);
@@ -49,23 +75,97 @@ function apiWrapper(method: 'get' | 'post' | 'delete', endpoint: string, fn: Wra
   });
 }
 
-function apiGet(endpoint: string, fn: WrappedApiHandler, view?: (arr: any) => string): void {
-  apiWrapper('get', endpoint, fn, view);
+app.get('/', (req, res) => {
+  res.sendFile(join(HTML_ROOT, 'index.html'));
+});
+app.get('/home.css', (req, res) => {
+  res.sendFile(join(HTML_ROOT, 'home.css'));
+});
+app.get('/style.css', (req, res) => {
+  res.sendFile(join(HTML_ROOT, 'style.css'));
+});
+
+function apiGet(endpoint: string, options: WrapOptions, fn: WrappedApiHandler, view?: (arr: any) => string): void {
+  apiWrapper('get', endpoint, options, fn, view);
 }
 
-function apiPost(endpoint: string, fn: WrappedApiHandler, view?: (arr: any) => string): void {
-  apiWrapper('post', endpoint, fn, view);
+function apiPost(endpoint: string, options: WrapOptions, fn: WrappedApiHandler, view?: (arr: any) => string): void {
+  apiWrapper('post', endpoint, options, fn, view);
 }
-function apiDelete(endpoint: string, fn: WrappedApiHandler, view?: (arr: any) => string): void {
-  apiWrapper('delete', endpoint, fn, view);
+function apiDelete(endpoint: string, options: WrapOptions, fn: WrappedApiHandler, view?: (arr: any) => string): void {
+  apiWrapper('delete', endpoint, options, fn, view);
 }
 
-apiPost('/login', async (req, res): Promise<any> => {
+apiPost('/login', {auth: false}, async (req, res): Promise<any> => {
+  const email = req.body.email;
+  const loginCode = uuidv4().substr(0, 6).toUpperCase();
+
+  const account = query.createLoginCode(email, loginCode);
+  const token = uuidv4();
+
+  const login_link = `/app?code=${account.login_code}&id=${account.id}`
+  console.log('login link:', login_link);
+
+  // this should actually just email the link and return some text 
+  // about what a great person you are.
+  return {
+    login: login_link
+  }
 });
 
-apiPost('/feeds', async (req, res): Promise<any> => {
+apiGet('/app', {auth: false}, async (req, res) => {
+  const id = req.query.id?.toString();
+  const token = req.query.token?.toString();
+  const code = req.query.code?.toString();
+
+
+  if(code && id) {
+    console.log('validating', id, code);
+    if(!query.validateLoginCode(id, code)) {
+      throw new Error('Invalid login');
+    }
+    let token = uuidv4();
+    let i = 0;
+    while(session.has(token) && i < 10) {
+      token = uuidv4();
+      ++i;
+    }
+
+    if(i >= 10) {
+      throw new Error('Please login again');
+    }
+
+    session.set(token, id);
+    res.redirect(`/app?id=${id}&token=${token}`);
+    return;
+  }
+
+  if(token && id) {
+    // validate it.
+    if(!session.has(token) || session.get(token) !== id) {
+      res.redirect('/');
+      return;
+    }
+    const data = await promisify(fs.readFile)(join(HTML_ROOT, 'app.html'), 'utf-8');
+
+    return {
+      html: data,
+      account_id: id,
+      token: token
+    };
+  }
+
+  res.redirect('/');
+  return;
+
+}, data => {
+  return data.html.replace(/{ACCOUNT_ID}/g, data.account_id);
+});
+
+apiPost('/accounts/:account_id/feeds', {auth: true}, async (req, res): Promise<any> => {
   // get info about the feed
   const url = req.body.link;
+  const account_id = req.params.account_id;
 
   let parser: BaseParser;
 
@@ -75,27 +175,27 @@ apiPost('/feeds', async (req, res): Promise<any> => {
   const feedData = await parser.parse(url);
 
   const title = feedData.title;
-  const id = uuidv4();
 
   // ingest teh feed, 
 
-  query.addFeed.run(id, title, url);
+  const feed = query.addFeed(title, url, account_id);
 
-  await ingestSingle(id);
+  await ingestSingle(feed.id);
 
   res.setHeader('HX-Trigger', 'newFeed');
 
   return {
-    id,
+    id: feed.id,
     title,
     url
   }
 });
 
-apiGet('/feeds', async (req, res): Promise<any> => {
-  const feeds = query.getFeedList.all();
+apiGet('/accounts/:account_id/feeds', {auth: true}, async (req, res): Promise<any> => {
+  const account_id = req.params.account_id;
+  const feeds = query.getFeedList(account_id);
   // get unread counts
-  const unread_count = query.getUnreadCountForAll.all();
+  const unread_count = query.getUnreadCountForAll(account_id);
 
   const feedsWithUnread = feeds.map(feed => {
     const unread = unread_count.filter(i => i.feed_id === feed.id);
@@ -108,7 +208,8 @@ apiGet('/feeds', async (req, res): Promise<any> => {
   });
 
   return { 
-    feeds: feedsWithUnread
+    feeds: feedsWithUnread,
+    account_id: account_id
   }
 }, (output: any): string => {
   const feeds = output.feeds;
@@ -116,7 +217,7 @@ apiGet('/feeds', async (req, res): Promise<any> => {
     const display = feed.unread ? `(${feed.unread})` : '';
     const first = idx === 0;
 
-    return `<li><a href="#" class="${first ? 'active' : ''} ${feed.unread ? 'unread' : ''}" data-actions="activate" hx-get="/feeds/${feed.id}/items" hx-trigger="${first ? 'load,' : ''}click" hx-target="#list-pane" data-feed-id="${feed.id}">${feed.title} 
+    return `<li><a href="#" class="${first ? 'active' : ''} ${feed.unread ? 'unread' : ''}" data-actions="activate" hx-get="/accounts/${output.account_id}/feeds/${feed.id}/items" hx-trigger="${first ? 'load,' : ''}click" hx-target="#list-pane" data-feed-id="${feed.id}">${feed.title} 
     <span class="unread-count">${display}</span>
     </a></li>`
   }).join("\n")}</ul>`;
@@ -128,7 +229,7 @@ function reasonable(date: Date): string {
   return `${date.getFullYear()}-${month}-${day}`;
 }
 
-apiGet('/feeds/:feed_id', async (req, res): Promise<any> => {
+apiGet('/accounts/:account_id/feeds/:feed_id',{auth: true},  async (req, res): Promise<any> => {
   const id = req.params.feed_id;
   return query.getFeedInfo.get(id);
 }, (feed: any): string => {
@@ -138,18 +239,20 @@ apiGet('/feeds/:feed_id', async (req, res): Promise<any> => {
   `;
 });
 
-apiGet('/feeds/:feed_id/items', async (req, res): Promise<any> => {
-  const id = req.params.feed_id;
+apiGet('/accounts/:account_id/feeds/:feed_id/items',{auth: true},  async (req, res): Promise<any> => {
+  const { account_id, feed_id } = req.params;
+
   return {
-    items: query.getFeedsFor.all(id, 0),
-    info: query.getFeedInfo.get(id)
+    items: query.getFeedsFor.all(feed_id, 0),
+    info: query.getFeedInfo.get(feed_id),
+    account_id
   }
 }, (feedData: any): string => {
   return `
       <div id="feed-info">
       <div id="feed-actions">
-        <a href="#" class="btn" hx-post="/feeds/${feedData.info.id}/items/markAsRead" hx-trigger="click">Mark all as Read</a>
-        <a href="#" class="btn" hx-delete="/feeds/${feedData.info.id}" hx-trigger="click" hx-confirm="Are you sure you want to delete this feed?">Delete Feed</a>
+        <a href="#" class="btn" hx-post="/accounts/${feedData.account_id}/feeds/${feedData.info.id}/items/markAsRead" hx-trigger="click">Mark all as Read</a>
+        <a href="#" class="btn" hx-delete="/accounts/${feedData.account_id}/feeds/${feedData.info.id}" hx-trigger="click" hx-confirm="Are you sure you want to delete this feed?">Delete Feed</a>
       </div>
       <b>Feed: </b>${feedData.info.title}<br>
       </div>
@@ -161,7 +264,7 @@ apiGet('/feeds/:feed_id/items', async (req, res): Promise<any> => {
     const read = !!item.read_at;
     const first = index === 0;
     return `<li>
-      <a href="#" class="${first ? 'active': ''} ${read ? '': 'unread'}" data-actions="activate" hx-get="/feeds/${item.feed_id}/items/${item.id}" hx-trigger="click" hx-target="#reading-pane" data-feed-item-id="${item.id}" data-feed-id="${item.feed_id}">${item.title}
+      <a href="#" class="${first ? 'active': ''} ${read ? '': 'unread'}" data-actions="activate" hx-get="/accounts/${feedData.account_id}/feeds/${item.feed_id}/items/${item.id}" hx-trigger="click" hx-target="#reading-pane" data-feed-item-id="${item.id}" data-feed-id="${item.feed_id}">${item.title}
       <span class="date">${reasonable(new Date(item.pub_date * 1000))}</span>
       </a>
     </li>
@@ -170,15 +273,21 @@ apiGet('/feeds/:feed_id/items', async (req, res): Promise<any> => {
   `;
 });
 
-apiPost('/feeds/:feed_id/items/markAsRead', async (req, res): Promise<any> => {
-  const id = req.params.feed_id;
-  query.readAllItems.run(id);
+apiPost('/accounts/:account_id/feeds/:feed_id/items/markAsRead',{auth: true},  async (req, res): Promise<any> => {
+  const {account_id, feed_id} = req.params;
+  if(!query.isFeedOwnedBy(account_id, feed_id)) {
+    throw new Error('Invalid feed');
+  }
+  query.readAllItems(feed_id);
   return;
 });
 
-apiGet('/feeds/:feed_id/items/:item_id', async (req, res) => {
-  const feed_id = req.params.feed_id;
-  const item_id = req.params.item_id;
+apiGet('/accounts/:account_id/feeds/:feed_id/items/:item_id',{auth: true},  async (req, res) => {
+  const  {account_id, feed_id, item_id} = req.params;
+
+  if(!query.isFeedOwnedBy(account_id, feed_id)) {
+    throw new Error('Invalid feed');
+  }
 
   query.readItem.run(Date.now(), item_id);
   return query.getFeedItemInfo.get(item_id, feed_id);
@@ -192,11 +301,10 @@ apiGet('/feeds/:feed_id/items/:item_id', async (req, res) => {
   <b>Read: </b> ${new Date(output.read_at || undefined)}
   </div>
   <div class="scrollable" id="feed-content">${output.content}</div>
-  `
-  return output.content;
+  `;
 });
 
-apiDelete('/feeds/:feed_id', async (req, res) => {
+apiDelete('/feeds/:feed_id',{auth: true},  async (req, res) => {
   const id = req.params.feed_id;
 
   query.deleteFeed.run(id);