3b76b918032992c0e2c2e2914ab99cc04ebd49fd
[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 `Your login code has been emailed to you.`;
112 });
113
114 apiGet('/app', {auth: false}, async (req, res) => {
115   const id = req.query.id?.toString();
116   const token = req.query.token?.toString();
117   const code = req.query.code?.toString();
118
119
120   if(code && id) {
121     console.log('validating', id, code);
122     if(!query.validateLoginCode(id, code)) {
123       throw new Error('Invalid login');
124     }
125     let token = uuidv4();
126     let i = 0;
127     while(session.has(token) && i < 10) {
128       token = uuidv4();
129       ++i;
130     }
131
132     if(i >= 10) {
133       throw new Error('Please login again');
134     }
135
136     session.set(token, id);
137     res.redirect(`/app?id=${id}&token=${token}`);
138     return;
139   }
140
141   if(token && id) {
142     // validate it.
143     if(!session.has(token) || session.get(token) !== id) {
144       res.redirect('/');
145       return;
146     }
147     const data = await promisify(fs.readFile)(join(HTML_ROOT, 'app.html'), 'utf-8');
148
149     return {
150       html: data,
151       account_id: id,
152       token: token
153     };
154   }
155
156   res.redirect('/');
157   return;
158
159 }, data => {
160   if(data) {
161     return data.html.replace(/{ACCOUNT_ID}/g, data.account_id);
162   }
163   else {
164     return data;
165   }
166 });
167
168 apiPost('/accounts/:account_id/feeds', {auth: true}, async (req, res): Promise<any> => {
169   // get info about the feed
170   const url = req.body.link;
171   const account_id = req.params.account_id;
172
173   let parser: BaseParser;
174
175   // based on the url, we should figure out if this is a reddit or rss feed
176   parser = new RSSParser();
177
178   const feedData = await parser.parse(url);
179
180   const title = feedData.title;
181
182   // ingest teh feed, 
183
184   const feed = query.addFeed(title, url, account_id);
185
186   await ingestSingle(feed.id);
187
188   res.setHeader('HX-Trigger', 'newFeed');
189
190   return {
191     id: feed.id,
192     title,
193     url
194   }
195 });
196
197 apiGet('/accounts/:account_id/feeds', {auth: true}, async (req, res): Promise<any> => {
198   const account_id = req.params.account_id;
199   const feeds = query.getFeedList(account_id);
200   // get unread counts
201   const unread_count = query.getUnreadCountForAll(account_id);
202
203   const feedsWithUnread = feeds.map(feed => {
204     const unread = unread_count.filter(i => i.feed_id === feed.id);
205     return {
206       id: feed.id,
207       title: feed.title,
208       link: feed.link,
209       unread: (unread && unread.length) ? unread[0].unread : 0
210     }
211   });
212
213   return { 
214     feeds: feedsWithUnread,
215     account_id: account_id
216   }
217 }, (output: any): string => {
218   const feeds = output.feeds;
219   return `<ul class="list">${feeds.map((feed: any, idx: number) =>{ 
220     const display = feed.unread ? `(${feed.unread})` : '';
221     const first = idx === 0;
222
223     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} 
224     <span class="unread-count">${display}</span>
225     </a></li>`
226   }).join("\n")}</ul>`;
227 });
228
229 function reasonable(date: Date): string {
230   const month = date.getMonth() < 10 ? `0${date.getMonth()}` : date.getMonth();
231   const day = date.getDate() < 10 ? `0${date.getDate()}` : date.getDate();
232   return `${date.getFullYear()}-${month}-${day}`;
233 }
234
235 apiGet('/accounts/:account_id/feeds/:feed_id',{auth: true},  async (req, res): Promise<any> => {
236   const id = req.params.feed_id;
237   return query.getFeedInfo.get(id);
238 }, (feed: any): string => {
239   return `
240   <b>Feed:</b> ${feed.title}<br>
241   <b>Link:</b> ${feed.link}<br>
242   `;
243 });
244
245 apiGet('/accounts/:account_id/feeds/:feed_id/items',{auth: true},  async (req, res): Promise<any> => {
246   const { account_id, feed_id } = req.params;
247
248   return {
249     items: query.getFeedsFor.all(feed_id, 0),
250     info: query.getFeedInfo.get(feed_id),
251     account_id
252   }
253 }, (feedData: any): string => {
254   return `
255       <div id="feed-info">
256       <div id="feed-actions">
257         <a href="#" class="btn" hx-post="/accounts/${feedData.account_id}/feeds/${feedData.info.id}/items/markAsRead" hx-trigger="click">Mark all as Read</a>
258         <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>
259       </div>
260       <b>Feed: </b>${feedData.info.title}<br>
261       </div>
262       <div>
263         <table><thead><tr><th style="width: 80%">Title</th><th>Publish Date</th></head></table>
264       </div>
265       <ul class="scrollable list" id="feed-item-list">
266   ${feedData.items.map((item: any, index: number) => {
267     const read = !!item.read_at;
268     const first = index === 0;
269     return `<li>
270       <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}
271       <span class="date">${reasonable(new Date(item.pub_date * 1000))}</span>
272       </a>
273     </li>
274   `}).join("\n")}
275   </ul>
276   `;
277 });
278
279 apiPost('/accounts/:account_id/feeds/:feed_id/items/markAsRead',{auth: true},  async (req, res): Promise<any> => {
280   const {account_id, feed_id} = req.params;
281   if(!query.isFeedOwnedBy(account_id, feed_id)) {
282     throw new Error('Invalid feed');
283   }
284   query.readAllItems(feed_id);
285   return;
286 });
287
288 apiGet('/accounts/:account_id/feeds/:feed_id/items/:item_id',{auth: true},  async (req, res) => {
289   const  {account_id, feed_id, item_id} = req.params;
290
291   if(!query.isFeedOwnedBy(account_id, feed_id)) {
292     throw new Error('Invalid feed');
293   }
294
295   query.readItem.run(Date.now(), item_id);
296   return query.getFeedItemInfo.get(item_id, feed_id);
297
298 }, (output: any): string => {
299   return `
300   <div id="meta">
301   <b>Title:</b> ${output.title}<br>
302   <b>Link:</b> <a href="${output.link}" target="_blank">${output.link}</a><br>
303   <b>Posted:</b> ${new Date(output.pub_date * 1000)}<brj
304   <b>Read: </b> ${new Date(output.read_at || undefined)}
305   </div>
306   <div class="scrollable" id="feed-content">${output.content}</div>
307   `;
308 });
309
310 apiDelete('/feeds/:feed_id',{auth: true},  async (req, res) => {
311   const id = req.params.feed_id;
312
313   query.deleteFeed.run(id);
314   res.setHeader('HX-Trigger', 'newFeed');
315   return;
316 });
317
318 async function periodicIngest() {
319   await ingest();
320   setTimeout(periodicIngest, 1000 * 60 * 10);
321 }
322
323 periodicIngest();
324
325 app.listen(process.env.PORT || 8000, () => {
326   console.log('Listening on port', process.env.PORT || 8000);
327 });