fix bug with empty string when logging in
[rss-reader.git] / src / server.ts
1 import express, {Request, Response} from 'express';
2 import { join } from 'path';
3 import { query } from './lib/db';
4 import { v4 as uuidv4 } from 'uuid';
5 import { ingest, ingestSingle } from './ingester';
6 import {RSSParser} from './parsers/rss';
7 import bodyParser from 'body-parser';
8 import {BaseParser} from './parsers/base';
9 import fs from 'fs';
10 import { promisify } from 'util';
11
12 const HTML_ROOT = join(__dirname, '..', 'html');
13 const app = express();
14 const session: Map<string, string> = new Map<string, string>();
15
16 app.use(bodyParser.json());
17 app.use(bodyParser.urlencoded({ extended: true}));
18 app.use((req, res, next) => {
19   console.log(`${req.method} ${req.path}`);
20   next();
21 });
22
23 type WrappedApiHandler = (req: Request, res: Response) => Promise<any>;
24 type WrapOptions = {
25   auth?: boolean;
26 }
27
28 function apiWrapper(method: 'get' | 'post' | 'delete', endpoint: string, options: WrapOptions, fn: WrappedApiHandler, view?: (args: any) => string) : void {
29   app[method](endpoint, async(req, res) => {
30     try {
31       if(options.auth) {
32         if(!req.headers['hx-current-url']) {
33           throw new Error('Invald user');
34         }
35         const query = new URLSearchParams(req.headers['hx-current-url'].toString().split('?')[1]);
36         const token = query.get('token');
37         const id = query.get('id');
38
39         if(!token || !id) {
40           throw new Error('Invalid user');
41         }
42         if(!session.get(token) || session.get(token) !== id) {
43           throw new Error('Invalid user');
44         }
45       }
46
47       const output = await fn(req, res);
48       if(req.headers['content-type'] === 'application/json') {
49         res.json(output);
50         return;
51       }
52
53       if(view) {
54         const viewOutput = view(output);
55         if(viewOutput.length) {
56           res.send(viewOutput);
57           return;
58         }
59       }
60
61       if(!output) {
62         res.status(204);
63         return;
64       }
65
66       return res.json(output);
67     }
68     catch(e) {
69       console.error(e);
70       res.status(500);
71     }
72     finally {
73       res.end();
74     }
75   });
76 }
77
78 app.get('/', (req, res) => {
79   res.sendFile(join(HTML_ROOT, 'index.html'));
80 });
81 app.get('/home.css', (req, res) => {
82   res.sendFile(join(HTML_ROOT, 'home.css'));
83 });
84 app.get('/style.css', (req, res) => {
85   res.sendFile(join(HTML_ROOT, 'style.css'));
86 });
87
88 function apiGet(endpoint: string, options: WrapOptions, fn: WrappedApiHandler, view?: (arr: any) => string): void {
89   apiWrapper('get', endpoint, options, fn, view);
90 }
91
92 function apiPost(endpoint: string, options: WrapOptions, fn: WrappedApiHandler, view?: (arr: any) => string): void {
93   apiWrapper('post', endpoint, options, fn, view);
94 }
95 function apiDelete(endpoint: string, options: WrapOptions, fn: WrappedApiHandler, view?: (arr: any) => string): void {
96   apiWrapper('delete', endpoint, options, fn, view);
97 }
98
99 apiPost('/login', {auth: false}, async (req, res): Promise<any> => {
100   const email = req.body.email;
101   const loginCode = uuidv4().substr(0, 6).toUpperCase();
102
103   const account = query.createLoginCode(email, loginCode);
104   const token = uuidv4();
105
106   const login_link = `/app?code=${account.login_code}&id=${account.id}`
107   console.log('login link:', login_link);
108
109   // this should actually just email the link and return some text 
110   // about what a great person you are.
111   return {
112     login: login_link
113   }
114 });
115
116 apiGet('/app', {auth: false}, async (req, res) => {
117   const id = req.query.id?.toString();
118   const token = req.query.token?.toString();
119   const code = req.query.code?.toString();
120
121
122   if(code && id) {
123     console.log('validating', id, code);
124     if(!query.validateLoginCode(id, code)) {
125       throw new Error('Invalid login');
126     }
127     let token = uuidv4();
128     let i = 0;
129     while(session.has(token) && i < 10) {
130       token = uuidv4();
131       ++i;
132     }
133
134     if(i >= 10) {
135       throw new Error('Please login again');
136     }
137
138     session.set(token, id);
139     res.redirect(`/app?id=${id}&token=${token}`);
140     return;
141   }
142
143   if(token && id) {
144     // validate it.
145     if(!session.has(token) || session.get(token) !== id) {
146       res.redirect('/');
147       return;
148     }
149     const data = await promisify(fs.readFile)(join(HTML_ROOT, 'app.html'), 'utf-8');
150
151     return {
152       html: data,
153       account_id: id,
154       token: token
155     };
156   }
157
158   res.redirect('/');
159   return;
160
161 }, data => {
162   if(data) {
163     return data.html.replace(/{ACCOUNT_ID}/g, data.account_id);
164   }
165   else {
166     return data;
167   }
168 });
169
170 apiPost('/accounts/:account_id/feeds', {auth: true}, async (req, res): Promise<any> => {
171   // get info about the feed
172   const url = req.body.link;
173   const account_id = req.params.account_id;
174
175   let parser: BaseParser;
176
177   // based on the url, we should figure out if this is a reddit or rss feed
178   parser = new RSSParser();
179
180   const feedData = await parser.parse(url);
181
182   const title = feedData.title;
183
184   // ingest teh feed, 
185
186   const feed = query.addFeed(title, url, account_id);
187
188   await ingestSingle(feed.id);
189
190   res.setHeader('HX-Trigger', 'newFeed');
191
192   return {
193     id: feed.id,
194     title,
195     url
196   }
197 });
198
199 apiGet('/accounts/:account_id/feeds', {auth: true}, async (req, res): Promise<any> => {
200   const account_id = req.params.account_id;
201   const feeds = query.getFeedList(account_id);
202   // get unread counts
203   const unread_count = query.getUnreadCountForAll(account_id);
204
205   const feedsWithUnread = feeds.map(feed => {
206     const unread = unread_count.filter(i => i.feed_id === feed.id);
207     return {
208       id: feed.id,
209       title: feed.title,
210       link: feed.link,
211       unread: (unread && unread.length) ? unread[0].unread : 0
212     }
213   });
214
215   return { 
216     feeds: feedsWithUnread,
217     account_id: account_id
218   }
219 }, (output: any): string => {
220   const feeds = output.feeds;
221   return `<ul class="list">${feeds.map((feed: any, idx: number) =>{ 
222     const display = feed.unread ? `(${feed.unread})` : '';
223     const first = idx === 0;
224
225     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} 
226     <span class="unread-count">${display}</span>
227     </a></li>`
228   }).join("\n")}</ul>`;
229 });
230
231 function reasonable(date: Date): string {
232   const month = date.getMonth() < 10 ? `0${date.getMonth()}` : date.getMonth();
233   const day = date.getDate() < 10 ? `0${date.getDate()}` : date.getDate();
234   return `${date.getFullYear()}-${month}-${day}`;
235 }
236
237 apiGet('/accounts/:account_id/feeds/:feed_id',{auth: true},  async (req, res): Promise<any> => {
238   const id = req.params.feed_id;
239   return query.getFeedInfo.get(id);
240 }, (feed: any): string => {
241   return `
242   <b>Feed:</b> ${feed.title}<br>
243   <b>Link:</b> ${feed.link}<br>
244   `;
245 });
246
247 apiGet('/accounts/:account_id/feeds/:feed_id/items',{auth: true},  async (req, res): Promise<any> => {
248   const { account_id, feed_id } = req.params;
249
250   return {
251     items: query.getFeedsFor.all(feed_id, 0),
252     info: query.getFeedInfo.get(feed_id),
253     account_id
254   }
255 }, (feedData: any): string => {
256   return `
257       <div id="feed-info">
258       <div id="feed-actions">
259         <a href="#" class="btn" hx-post="/accounts/${feedData.account_id}/feeds/${feedData.info.id}/items/markAsRead" hx-trigger="click">Mark all as Read</a>
260         <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>
261       </div>
262       <b>Feed: </b>${feedData.info.title}<br>
263       </div>
264       <div>
265         <table><thead><tr><th style="width: 80%">Title</th><th>Publish Date</th></head></table>
266       </div>
267       <ul class="scrollable list" id="feed-item-list">
268   ${feedData.items.map((item: any, index: number) => {
269     const read = !!item.read_at;
270     const first = index === 0;
271     return `<li>
272       <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}
273       <span class="date">${reasonable(new Date(item.pub_date * 1000))}</span>
274       </a>
275     </li>
276   `}).join("\n")}
277   </ul>
278   `;
279 });
280
281 apiPost('/accounts/:account_id/feeds/:feed_id/items/markAsRead',{auth: true},  async (req, res): Promise<any> => {
282   const {account_id, feed_id} = req.params;
283   if(!query.isFeedOwnedBy(account_id, feed_id)) {
284     throw new Error('Invalid feed');
285   }
286   query.readAllItems(feed_id);
287   return;
288 });
289
290 apiGet('/accounts/:account_id/feeds/:feed_id/items/:item_id',{auth: true},  async (req, res) => {
291   const  {account_id, feed_id, item_id} = req.params;
292
293   if(!query.isFeedOwnedBy(account_id, feed_id)) {
294     throw new Error('Invalid feed');
295   }
296
297   query.readItem.run(Date.now(), item_id);
298   return query.getFeedItemInfo.get(item_id, feed_id);
299
300 }, (output: any): string => {
301   return `
302   <div id="meta">
303   <b>Title:</b> ${output.title}<br>
304   <b>Link:</b> <a href="${output.link}" target="_blank">${output.link}</a><br>
305   <b>Posted:</b> ${new Date(output.pub_date * 1000)}<brj
306   <b>Read: </b> ${new Date(output.read_at || undefined)}
307   </div>
308   <div class="scrollable" id="feed-content">${output.content}</div>
309   `;
310 });
311
312 apiDelete('/feeds/:feed_id',{auth: true},  async (req, res) => {
313   const id = req.params.feed_id;
314
315   query.deleteFeed.run(id);
316   res.setHeader('HX-Trigger', 'newFeed');
317   return;
318 });
319
320 async function periodicIngest() {
321   await ingest();
322   setTimeout(periodicIngest, 1000 * 60 * 10);
323 }
324
325 periodicIngest();
326
327 app.listen(process.env.PORT || 8000, () => {
328   console.log('Listening on port', process.env.PORT || 8000);
329 });