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';
10 import { promisify } from 'util';
12 const HTML_ROOT = join(__dirname, '..', 'html');
13 const app = express();
14 const session: Map<string, string> = new Map<string, string>();
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}`);
23 type WrappedApiHandler = (req: Request, res: Response) => Promise<any>;
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) => {
32 if(!req.headers['hx-current-url']) {
33 throw new Error('Invald user');
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');
40 throw new Error('Invalid user');
42 if(!session.get(token) || session.get(token) !== id) {
43 throw new Error('Invalid user');
47 const output = await fn(req, res);
48 if(req.headers['content-type'] === 'application/json') {
54 const viewOutput = view(output);
55 if(viewOutput.length) {
66 return res.json(output);
78 app.get('/', (req, res) => {
79 res.sendFile(join(HTML_ROOT, 'index.html'));
81 app.get('/home.css', (req, res) => {
82 res.sendFile(join(HTML_ROOT, 'home.css'));
84 app.get('/style.css', (req, res) => {
85 res.sendFile(join(HTML_ROOT, 'style.css'));
88 function apiGet(endpoint: string, options: WrapOptions, fn: WrappedApiHandler, view?: (arr: any) => string): void {
89 apiWrapper('get', endpoint, options, fn, view);
92 function apiPost(endpoint: string, options: WrapOptions, fn: WrappedApiHandler, view?: (arr: any) => string): void {
93 apiWrapper('post', endpoint, options, fn, view);
95 function apiDelete(endpoint: string, options: WrapOptions, fn: WrappedApiHandler, view?: (arr: any) => string): void {
96 apiWrapper('delete', endpoint, options, fn, view);
99 apiPost('/login', {auth: false}, async (req, res): Promise<any> => {
100 const email = req.body.email;
101 const loginCode = uuidv4().substr(0, 6).toUpperCase();
103 const account = query.createLoginCode(email, loginCode);
104 const token = uuidv4();
106 const login_link = `/app?code=${account.login_code}&id=${account.id}`
107 console.log('login link:', login_link);
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.`;
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();
121 console.log('validating', id, code);
122 if(!query.validateLoginCode(id, code)) {
123 throw new Error('Invalid login');
125 let token = uuidv4();
127 while(session.has(token) && i < 10) {
133 throw new Error('Please login again');
136 session.set(token, id);
137 res.redirect(`/app?id=${id}&token=${token}`);
143 if(!session.has(token) || session.get(token) !== id) {
147 const data = await promisify(fs.readFile)(join(HTML_ROOT, 'app.html'), 'utf-8');
161 return data.html.replace(/{ACCOUNT_ID}/g, data.account_id);
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;
173 let parser: BaseParser;
175 // based on the url, we should figure out if this is a reddit or rss feed
176 parser = new RSSParser();
178 const feedData = await parser.parse(url);
180 const title = feedData.title;
184 const feed = query.addFeed(title, url, account_id);
186 await ingestSingle(feed.id);
188 res.setHeader('HX-Trigger', 'newFeed');
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);
201 const unread_count = query.getUnreadCountForAll(account_id);
203 const feedsWithUnread = feeds.map(feed => {
204 const unread = unread_count.filter(i => i.feed_id === feed.id);
209 unread: (unread && unread.length) ? unread[0].unread : 0
214 feeds: feedsWithUnread,
215 account_id: account_id
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;
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>
226 }).join("\n")}</ul>`;
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}`;
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 => {
240 <b>Feed:</b> ${feed.title}<br>
241 <b>Link:</b> ${feed.link}<br>
245 apiGet('/accounts/:account_id/feeds/:feed_id/items',{auth: true}, async (req, res): Promise<any> => {
246 const { account_id, feed_id } = req.params;
249 items: query.getFeedsFor.all(feed_id, 0),
250 info: query.getFeedInfo.get(feed_id),
253 }, (feedData: any): string => {
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>
260 <b>Feed: </b>${feedData.info.title}<br>
263 <table><thead><tr><th style="width: 80%">Title</th><th>Publish Date</th></head></table>
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;
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>
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');
284 query.readAllItems(feed_id);
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;
291 if(!query.isFeedOwnedBy(account_id, feed_id)) {
292 throw new Error('Invalid feed');
295 query.readItem.run(Date.now(), item_id);
296 return query.getFeedItemInfo.get(item_id, feed_id);
298 }, (output: any): string => {
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)}
306 <div class="scrollable" id="feed-content">${output.content}</div>
310 apiDelete('/accounts/:account_id/feeds/:feed_id',{auth: true}, async (req, res) => {
311 const { feed_id, account_id } = req.params;
312 if(!query.isFeedOwnedBy(account_id, feed_id)) {
313 throw new Error('Invalid feed');
316 query.deleteFeed.run(feed_id);
317 console.log(`Deleting feed ${feed_id}`);
318 res.setHeader('HX-Trigger', 'newFeed');
322 async function periodicIngest() {
324 setTimeout(periodicIngest, 1000 * 60 * 10);
329 app.listen(process.env.PORT || 8000, () => {
330 console.log('Listening on port', process.env.PORT || 8000);