Browse Source

Initial commit

mistress
Daniel Muckerman 4 years ago
commit
a5bba8e8ca
14 changed files with 782 additions and 0 deletions
  1. +18
    -0
      .gitignore
  2. +13
    -0
      Dockerfile
  3. +218
    -0
      app.py
  4. +4
    -0
      blurb.txt
  5. +1
    -0
      bookmarklet.txt
  6. BIN
      pocket/readitlater.db
  7. +8
    -0
      requirements.txt
  8. +197
    -0
      static/style.css
  9. +60
    -0
      templates/article.j2
  10. +3
    -0
      templates/close.j2
  11. +93
    -0
      templates/list.j2
  12. +37
    -0
      templates/login.j2
  13. +118
    -0
      templates/save.j2
  14. +12
    -0
      utils.py

+ 18
- 0
.gitignore View File

@ -0,0 +1,18 @@
### Python ###
# Byte-compiled / optimized / DLL files
__pycache__/
*.py[cod]
*$py.class
# Environments
.env
.venv
env/
venv/
ENV/
env.bak/
venv.bak/
.envrc
.DS_Store
.vscode/

+ 13
- 0
Dockerfile View File

@ -0,0 +1,13 @@
FROM python:3.7
LABEL maintainer="Dan Muckerman <danielmuckerman@me.com>"
WORKDIR /project
ADD . /project
RUN rm /project/.envrc
RUN rm -rf /project/env
RUN apt-get update && apt-get install -y python3-dev libldap2-dev libsasl2-dev libssl-dev
RUN pip install -r requirements.txt
CMD ["flask","run","--host=0.0.0.0"]

+ 218
- 0
app.py View File

@ -0,0 +1,218 @@
from utils import clean_articles
import ldap as l
from ldap3 import Server, Connection, ALL, MODIFY_REPLACE
from flask import Flask, g, request, session, redirect, url_for, render_template
from flask_simpleldap import LDAP
from flask_bootstrap import Bootstrap
from readability import Document
from readabilipy import simple_json_from_html_string
import os
import sqlite3
import requests
from requests.api import head
from utils import clean_articles
app = Flask(__name__)
Bootstrap(app)
app.secret_key = 'asdf'
app.debug = True
# Base
app.config['LDAP_REALM_NAME'] = 'OpenLDAP Authentication'
app.config['LDAP_HOST'] = os.environ.get('LDAP_HOST')
app.config['LDAP_BASE_DN'] = os.environ.get('LDAP_BASE_DN')
app.config['LDAP_USERNAME'] = os.environ.get('LDAP_USERNAME')
app.config['LDAP_PASSWORD'] = os.environ.get('LDAP_PASSWORD')
# OpenLDAP
app.config['LDAP_OBJECTS_DN'] = 'dn'
app.config['LDAP_OPENLDAP'] = True
app.config['LDAP_USER_OBJECT_FILTER'] = '(&(objectclass=posixAccount)(uid=%s))'
short_domain = os.environ.get('SHORT_DOMAIN')
ldap = LDAP(app)
server = Server(app.config['LDAP_HOST'])
conn = Connection(server, app.config['LDAP_USERNAME'], app.config['LDAP_PASSWORD'], auto_bind=True)
@app.before_request
def before_request():
g.user = None
if 'user_id' in session:
# This is where you'd query your database to get the user info.
g.user = {}
@app.route('/')
@ldap.login_required
def index():
user_dict = ldap.get_object_details(session['user_id'])
if 'user_id' in session:
user = {'dn': 'cn={},cn=usergroup,ou=users,dc=technicalincompetence,dc=club'.format(user_dict['cn'][0].decode('ascii')),
'firstName': user_dict['givenName'][0].decode('ascii'),
'lastName': user_dict['sn'][0].decode('ascii'),
'email': user_dict['mail'][0].decode('ascii'),
'userName': user_dict['uid'][0].decode('ascii'),
}
conn = sqlite3.connect('pocket/readitlater.db')
c = conn.cursor()
c.execute("SELECT article_id, url, title, byline FROM saved_articles INNER JOIN articles on saved_articles.article_id = articles.id WHERE user=? AND read=0 OR read IS NULL", (session['user_id'], ))
rows = c.fetchall()
conn.commit()
conn.close()
return render_template('list.j2', articles = clean_articles(rows))
@app.route('/archived')
@ldap.login_required
def archived():
conn = sqlite3.connect('pocket/readitlater.db')
c = conn.cursor()
c.execute("SELECT article_id, url, title, byline FROM saved_articles INNER JOIN articles on saved_articles.article_id = articles.id WHERE user=? AND read=1", (session['user_id'], ))
rows = c.fetchall()
print(rows)
conn.commit()
conn.close()
return render_template('list.j2', articles = clean_articles(rows))
@app.route('/save')
@ldap.login_required
def save():
return render_template('save.j2')
@app.route('/login', methods=['GET', 'POST'])
def login():
if g.user:
return redirect(url_for('index'))
if request.method == 'POST':
user = request.form['user']
passwd = request.form['passwd']
test = ldap.bind_user(user, passwd)
if test is None or passwd == '':
return render_template('login.j2', error='Invalid credentials')
else:
session['user_id'] = request.form['user']
session['passwd'] = request.form['passwd']
if session['next']:
next = session['next']
session['next'] = ''
return redirect(next)
return redirect('/')
return render_template('login.j2')
@ldap.login_required
@app.route('/article/<int:article_id>')
def read_article(article_id):
conn = sqlite3.connect('pocket/readitlater.db')
c = conn.cursor()
c.execute("SELECT * FROM articles where id=?", (article_id,))
rows = c.fetchall()
conn.commit()
conn.close()
if (len(rows) > 0):
return render_template('article.j2', article=rows[0])
return render_template('article.j2', article=())
@ldap.login_required
@app.route('/add', methods=['GET', 'POST'])
def add_url():
if not 'user_id' in session:
session['next'] = request.url
return redirect(url_for('login'))
if request.method == 'POST':
url = request.form['url']
close = None
else:
url = request.args.get('url')
close = request.args.get('close')
conn = sqlite3.connect('pocket/readitlater.db')
c = conn.cursor()
if url is not None and len(url) > 0:
headers = {'User-Agent': 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_11_5) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/50.0.2661.102 Safari/537.36'}
response = requests.get(url, headers=headers)
article = simple_json_from_html_string(response.text, use_readability=True)
c.execute("SELECT * FROM articles WHERE url=?", (url,))
rows = c.fetchall()
if (len(rows) == 0):
c.execute("INSERT INTO articles (url, content, title, byline) VALUES (?, ?, ?, ?)", (url, article['content'], article['title'], article['byline']))
c.execute("SELECT * FROM articles WHERE url=?", (url,))
rows = c.fetchall()
article_id = rows[0][0]
c.execute("SELECT * FROM saved_articles WHERE user=? AND article_id=?", (session['user_id'], article_id))
rows = c.fetchall()
if (len(rows) == 0):
c.execute("INSERT INTO saved_articles (user, article_id) VALUES (?, ?)", (session['user_id'], article_id))
conn.commit()
conn.close()
if close is not None and close == '1':
return render_template('close.j2')
return 'Saved'
conn.commit()
conn.close()
return 'Error'
@ldap.login_required
@app.route('/delete/<int:article_id>')
def delete_article(article_id):
conn = sqlite3.connect('pocket/readitlater.db')
c = conn.cursor()
c.execute("DELETE FROM saved_articles WHERE user=? AND article_id=?", (session['user_id'], article_id))
c.execute("SELECT * FROM saved_articles WHERE article_id=?", (article_id, ))
rows = c.fetchall()
if (len(rows) == 0):
c.execute("DELETE FROM articles WHERE id=?", (article_id,))
conn.commit()
conn.close()
return redirect(url_for('index'))
@ldap.login_required
@app.route('/archive/<int:article_id>')
def archive_article(article_id):
conn = sqlite3.connect('pocket/readitlater.db')
c = conn.cursor()
c.execute("UPDATE saved_articles SET read=1 WHERE user=? AND article_id=?", (session['user_id'], article_id))
conn.commit()
conn.close()
return redirect(url_for('index'))
@app.route('/logout')
def logout():
session.pop('user_id', None)
return redirect(url_for('index'))
if __name__ == '__main__':
app.run()

+ 4
- 0
blurb.txt View File

@ -0,0 +1,4 @@
Instapaper outdated
Pocket overrated
Long have we waited
Read TI Later _activated_

+ 1
- 0
bookmarklet.txt View File

@ -0,0 +1 @@
javascript:(function(){var%20url%20=%20location.href;var%20otherWindow=window.open('about:blank','_blank');otherWindow.opener=null;otherWindow.location='http://127.0.0.1:5000/add?close=1&url='+encodeURIComponent(url);})();

BIN
pocket/readitlater.db View File


+ 8
- 0
requirements.txt View File

@ -0,0 +1,8 @@
Flask==1.1.2
Flask-Bootstrap4==4.0.2
Flask-Login==0.5.0
Flask-SimpleLDAP==1.4.0
python-ldap==3.2.0
ldap3==2.7
readability-lxml==0.8.1
requests==2.24.0

+ 197
- 0
static/style.css View File

@ -0,0 +1,197 @@
.wrapper {
display: flex;
width: 100%;
align-items: stretch;
}
.toolbar-button {
padding-left: 2rem;
padding-top: .375rem;
line-height: 1.5;
color: white;
}
.back-chevron {
line-height: 1.5 !important;
vertical-align: middle !important;
margin-bottom: 4px !important;
}
#sidebar {
padding-left: 20px;
background-color: #ecedee;
height: 100vh;
padding-top: 20px;
border-right: 1px solid darkgray;
}
#sidebar ul > li {
padding-bottom: 10px;
}
#sidebar ul > li > a {
color: rgba(0,0,0,.5);
}
#sidebar ul > li.active > a {
color: black;
}
#content {
width: 100%;
padding: 20px;
}
@media (prefers-color-scheme: dark) {
#sidebar {
padding-left: 20px;
background-color: #1a1d20;
height: 100vh;
padding-top: 20px;
border-right: 1px solid black;
}
#sidebar ul > li {
padding-bottom: 10px;
}
#sidebar ul > li > a {
color: rgba(255,255,255,.5);
}
#sidebar ul > li.active > a {
color: white;
}
body {
background-color: #111 !important;
color: #eee;
}
.jumbotron {
background-color: #333 !important;
}
.modal-content {
background-color: #111 !important;
color: #eee;
}
.modal-header {
border-bottom: 1px solid #555 !important;
}
.modal-header .close {
color: #eee !important;
text-shadow: 0 1px 0 #555 !important;
}
.modal-footer {
border-top: 1px solid #555 !important;
}
.bg-light {
background-color: #333 !important;
}
.bg-white {
background-color: #000 !important;
}
.bg-black {
background-color: #eee !important;
}
.form-control {
display: block;
width: 100%;
height: calc(1.5em + 0.75rem + 2px);
padding: 0.375rem 0.75rem;
font-size: 1rem;
font-weight: 400;
line-height: 1.5;
color: #dee2e6;
background-color: #000;
background-clip: padding-box;
border: 1px solid #6c757d;
border-radius: 0.25rem;
transition: border-color 0.15s ease-in-out, box-shadow 0.15s ease-in-out;
}
@media (prefers-reduced-motion: reduce) {
.form-control {
transition: none;
}
}
.form-control::-ms-expand {
background-color: transparent;
border: 0;
}
.form-control:-moz-focusring {
color: transparent;
text-shadow: 0 0 0 #dee2e6;
}
.form-control:focus {
color: #dee2e6;
background-color: #191d21;
border-color: #b3d7ff;
outline: 0;
box-shadow: 0 0 0 0.2rem rgba(0, 123, 255, 0.25);
}
.form-control::-webkit-input-placeholder {
color: #6c757d;
opacity: 1;
}
.form-control::-moz-placeholder {
color: #6c757d;
opacity: 1;
}
.form-control::-ms-input-placeholder {
color: #6c757d;
opacity: 1;
}
.form-control::placeholder {
color: #6c757d;
opacity: 1;
}
.form-control:disabled,
.form-control[readonly] {
background-color: #343a40;
opacity: 1;
}
.card {
background-color: #000;
border: 1px solid #6c757d;
}
}
@media (max-width: 991.98px) {
#sidebar {
display: none !important;
}
}
@media (min-width: 991.98px) {
.navbar-nav {
display: none !important;
}
#sidebar {
min-width: 250px;
max-width: 250px;
display: block !important;
}
#sidebar.active {
margin-left: -250px;
}
}

+ 60
- 0
templates/article.j2 View File

@ -0,0 +1,60 @@
{% extends "bootstrap/base.html" %}
{% block title %}Read TI Later{% endblock %}
{% block styles %}
{{super()}}
<link rel="stylesheet" href="{{url_for('.static', filename='style.css')}}">
{% endblock %}
{% block navbar %}
<nav class="navbar navbar-expand-lg sticky-top navbar-dark bg-dark">
<a href="/"><div class="navbar-brand"><i data-feather="arrow-left" style="line-height: 1.5; vertical-align: middle; margin-bottom: 4px;"></i></div></a>
<form class="form-inline ml-auto">
{# <div class="custom-control custom-switch">
<input type="checkbox" class="custom-control-input" id="darkSwitch" />
<label class="custom-control-label" for="darkSwitch">Dark Mode</label>
</div>
<div class="navbar-text" style="margin-right: 20px; ">{{ user['userName'] }}</div> #}
<a class="toolbar-button" href="#"><i data-feather="type"></i></a>
<a class="toolbar-button" href="/archive/{{ article[0] }}"><i data-feather="archive"></i></a>
<a class="toolbar-button" href="/delete/{{ article[0] }}" style="padding-right: 1rem;"><i data-feather="trash"></i></a>
</form>
</nav>
{% endblock %}
{% block content %}
<div class="container" style="margin-top: 15px">
<div id="error-alert" class="alert alert-danger" role="alert" style="display: none;">
This is a danger alert—check it out!
</div>
<form>
<div class="row justify-content-center">
{# <div class="col-lg-6">
<label for="formGroupExampleInput4">URL</label>
<input id="link-form" type="text" class="form-control" placeholder="https://example.com">
<br>
<button type="button" class="btn btn-primary" onclick="addUrl();">Add</button>
<br>
<br>
</div> #}
<div class="col-lg-12">
<div style="padding: 10px;" id="article">
<h1 style="text-align: center;">{{ article[3] }}</h1>
<a href="{{ article[1] }}" class="text-info" target="_blank" style="display: block; text-align: center; margin-bottom: 20px;">View Original</a>
{{ article[2] | safe}}
</div>
</div>
</div>
</form>
</div>
{% endblock %}
{% block scripts %}
{{ super() }}
<script src="https://unpkg.com/feather-icons"></script>
<script>
$('#article img').css('max-width', '100%');
$('#article img').css('height', 'auto');
feather.replace();
</script>
{% endblock %}

+ 3
- 0
templates/close.j2 View File

@ -0,0 +1,3 @@
<script>
window.close();
</script>

+ 93
- 0
templates/list.j2 View File

@ -0,0 +1,93 @@
{% extends "bootstrap/base.html" %}
{% block title %}Read TI Later{% endblock %}
{% block styles %}
{{super()}}
<link rel="stylesheet" href="{{url_for('.static', filename='style.css')}}">
{% endblock %}
{% block navbar %}
<div id="navbar" class="navbar navbar-expand-lg sticky-top navbar-dark bg-dark">
<div class="navbar-brand">Read TI Later</div>
<button class="navbar-toggler" type="button" data-toggle="collapse" data-target="#navbarNavDropdown"
aria-controls="navbarNavDropdown" aria-expanded="false" aria-label="Toggle navigation">
<span class="navbar-toggler-icon"></span>
</button>
<div class="collapse navbar-collapse" id="navbarNavDropdown">
<ul class="navbar-nav">
{% if request.path == url_for('index') %}
<li class="nav-item active">
{% else %}
<li class="nav-item">
{% endif %}
<a class="nav-link" href="{{ url_for('index') }}">My List</a>
</li>
{% if request.path == url_for('archived') %}
<li class="nav-item active">
{% else %}
<li class="nav-item">
{% endif %}
<a class="nav-link" href="{{ url_for('archived') }}">Archived</a>
</li>
</ul>
<form class="form-inline ml-auto">
<a class="btn btn-primary" href="/save" role="button" style="margin-right: 20px;">Save</a>
<a class="btn btn-primary" href="/logout" role="button">Logout</a>
</form>
</div>
</div>
{% endblock %}
{% block content %}
<div class="wrapper">
<!-- Sidebar -->
<div id="sidebar">
<ul class="list-unstyled components">
{% if request.path == url_for('index') %}
<li class="nav-item active">
{% else %}
<li class="nav-item">
{% endif %}
<a href="{{ url_for('index') }}"><i data-feather="home" style="height: 1rem; vertical-align: middle; margin-bottom: 4px;"></i>My List</a>
</li>
{% if request.path == url_for('archived') %}
<li class="nav-item active">
{% else %}
<li class="nav-item">
{% endif %}
<a href="{{ url_for('archived') }}"><i data-feather="archive" style="height: 1rem; vertical-align: middle; margin-bottom: 4px;"></i>Archived</a>
</li>
</ul>
</div>
<!-- Page Content -->
<div id="content">
<form>
<div class="row justify-content-center">
<div class="col-lg-12">
{% for article in articles|reverse %}
<div style="color: black; padding: 10px; margin-bottom: 10px" class="card" id="article">
<a href="/article/{{article[0]}}">{{ article[2] }}</a>
<a href="{{ article[1] }}" target="_blank" style="color: darkgray">{{ article[4] }}</a>
<br>
<span>
<a href="/archive/{{article[0]}}" class='text-success'>Archive</a>
&nbsp;&nbsp;&nbsp;&nbsp;
<a href="/delete/{{article[0]}}" class='text-danger'>Delete</a>
</span>
</div>
{% endfor %}
</div>
</div>
</form>
</div>
</div>
{% endblock %}
{% block scripts %}
{{ super() }}
<script src="https://unpkg.com/feather-icons"></script>
<script>
feather.replace();
</script>
{% endblock %}

+ 37
- 0
templates/login.j2 View File

@ -0,0 +1,37 @@
{% extends "bootstrap/base.html" %}
{% block title %}Technical Incompetence Link Shortener{% endblock %}
{% block styles %}
{{super()}}
<link rel="stylesheet" href="{{url_for('.static', filename='style.css')}}">
{% endblock %}
{% block navbar %}
<nav class="navbar navbar-expand-lg sticky-top navbar-dark bg-dark">
<div class="navbar-brand">Technical Incompetence Link Shortener</div>
</nav>
{% endblock %}
{% block content %}
<div class="container" style="margin-top: 15px">
<div class="jumbotron">
<h1>Sign in for our awesome service</h1>
<p>Forgot your password? Too bad! We don't have emails working yet!</p>
</div>
<div class="col-md-12">
{% if error is defined %}
<div id="error-alert" class="alert alert-danger" role="alert">
{{ error }}
</div>
{% endif %}
<form action="" method="post">
<label for="exampleFormControlInput1">Username</label>
<input name="user" class="form-control"><br>
<label for="exampleFormControlInput2">Password</label>
<input type="password" name="passwd" class="form-control"><br>
<button type="submit" class="btn btn-primary">Log In</button>
</form>
</div>
{% endblock %}

+ 118
- 0
templates/save.j2 View File

@ -0,0 +1,118 @@
{% extends "bootstrap/base.html" %}
{% block title %}Read TI Later{% endblock %}
{% block styles %}
{{super()}}
<link rel="stylesheet" href="{{url_for('.static', filename='style.css')}}">
{% endblock %}
{% block navbar %}
<div id="navbar" class="navbar navbar-expand-lg sticky-top navbar-dark bg-dark">
<div class="navbar-brand">Read TI Later</div>
<button class="navbar-toggler" type="button" data-toggle="collapse" data-target="#navbarNavDropdown"
aria-controls="navbarNavDropdown" aria-expanded="false" aria-label="Toggle navigation">
<span class="navbar-toggler-icon"></span>
</button>
<div class="collapse navbar-collapse" id="navbarNavDropdown">
<ul class="navbar-nav">
<li class="nav-item">
<a class="nav-link" href="{{ url_for('index') }}">My List</a>
</li>
<li class="nav-item">
<a class="nav-link" href="{{ url_for('archived') }}">Archived</a>
</li>
</ul>
<form class="form-inline ml-auto">
<a class="btn btn-primary" href="/logout" role="button">Logout</a>
</form>
</div>
</div>
{% endblock %}
{% block content %}
<div class="wrapper">
<!-- Sidebar -->
<div id="sidebar">
<ul class="list-unstyled components">
<li>
<a href="/">My List</a>
</li>
<li>
<a href="/archived">Archived</a>
</li>
</ul>
</div>
<!-- Page Content -->
<div id="content">
<div id="error-alert" class="alert alert-danger" role="alert" style="display: none;">
This is a danger alert—check it out!
</div>
<form>
<div class="row justify-content-center">
<div class="col-lg-8">
<label for="formGroupExampleInput4">URL</label>
<input id="link-form" type="text" class="form-control" placeholder="https://example.com">
<br>
<button type="button" class="btn btn-primary" onclick="addUrl();">Add</button>
<br>
<br>
</div>
<div style="position:fixed; bottom: 20px;">
<p style="text-align: center;">Would you rather save on the go? Try our bookmarklet!</p>
<code>
javascript:(function(){var%20url%20=%20location.href;var%20otherWindow=window.open('about:blank','_blank');otherWindow.opener=null;otherWindow.location='{{ request.url_root }}add?close=1&url='+encodeURIComponent(url);})();
</code>
</div>
</div>
</form>
</div>
</div>
{% endblock %}
{% block scripts %}
{{ super() }}
<script>
function addUrl() {
url = $('#link-form').val();
if (url.trim().length === 0) {
showError('URL is required');
return false;
}
$.ajax({
url: '/add',
method: 'POST',
data: { "url": url },
success: function(data) {
if (data !== 'Error')
window.location.reload();
else
showError('URL cannot be empty');
}
});
}
function showError(error) {
hideSuccess();
$('#error-alert').text(error);
$('#error-alert').show();
}
function showArticle(message) {
hideError();
$('#article').html(message);
$('#article img').css('max-width', '100%')
$('#article').show();
}
function hideError(error) {
$('#error-alert').hide();
}
function hideArticle(error) {
$('#article').hide();
}
</script>
{% endblock %}

+ 12
- 0
utils.py View File

@ -0,0 +1,12 @@
from urllib.parse import urlparse
def clean_articles(rows):
#article_id, url, title, byline
out = []
for row in rows:
parsed_uri = urlparse(row[1])
result = '{uri.netloc}'.format(uri=parsed_uri)
out.append([row[0], row[1], row[2], row[3], result])
return out

Loading…
Cancel
Save