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.
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();
123 console.log('validating', id, code);
124 if(!query.validateLoginCode(id, code)) {
125 throw new Error('Invalid login');
127 let token = uuidv4();
129 while(session.has(token) && i < 10) {
135 throw new Error('Please login again');
138 session.set(token, id);
139 res.redirect(`/app?id=${id}&token=${token}`);
145 if(!session.has(token) || session.get(token) !== id) {
149 const data = await promisify(fs.readFile)(join(HTML_ROOT, 'app.html'), 'utf-8');
163 return data.html.replace(/{ACCOUNT_ID}/g, data.account_id);
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;
175 let parser: BaseParser;
177 // based on the url, we should figure out if this is a reddit or rss feed
178 parser = new RSSParser();
180 const feedData = await parser.parse(url);
182 const title = feedData.title;
186 const feed = query.addFeed(title, url, account_id);
188 await ingestSingle(feed.id);
190 res.setHeader('HX-Trigger', 'newFeed');
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);
203 const unread_count = query.getUnreadCountForAll(account_id);
205 const feedsWithUnread = feeds.map(feed => {
206 const unread = unread_count.filter(i => i.feed_id === feed.id);
211 unread: (unread && unread.length) ? unread[0].unread : 0
216 feeds: feedsWithUnread,
217 account_id: account_id
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;
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>
228 }).join("\n")}</ul>`;
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}`;
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 => {
242 <b>Feed:</b> ${feed.title}<br>
243 <b>Link:</b> ${feed.link}<br>
247 apiGet('/accounts/:account_id/feeds/:feed_id/items',{auth: true}, async (req, res): Promise<any> => {
248 const { account_id, feed_id } = req.params;
251 items: query.getFeedsFor.all(feed_id, 0),
252 info: query.getFeedInfo.get(feed_id),
255 }, (feedData: any): string => {
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>
262 <b>Feed: </b>${feedData.info.title}<br>
265 <table><thead><tr><th style="width: 80%">Title</th><th>Publish Date</th></head></table>
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;
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>
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');
286 query.readAllItems(feed_id);
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;
293 if(!query.isFeedOwnedBy(account_id, feed_id)) {
294 throw new Error('Invalid feed');
297 query.readItem.run(Date.now(), item_id);
298 return query.getFeedItemInfo.get(item_id, feed_id);
300 }, (output: any): string => {
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)}
308 <div class="scrollable" id="feed-content">${output.content}</div>
312 apiDelete('/feeds/:feed_id',{auth: true}, async (req, res) => {
313 const id = req.params.feed_id;
315 query.deleteFeed.run(id);
316 res.setHeader('HX-Trigger', 'newFeed');
320 async function periodicIngest() {
322 setTimeout(periodicIngest, 1000 * 60 * 10);
327 app.listen(process.env.PORT || 8000, () => {
328 console.log('Listening on port', process.env.PORT || 8000);