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