├── .bowerrc
├── .gitignore
├── .travis.yml
├── LICENSE
├── MANIFEST.in
├── Pipfile
├── Pipfile.lock
├── README.md
├── Vagrantfile
├── bower.json
├── docker-compose.yml
├── docker
└── Dockerfile
├── provision.sh
├── realms-wiki
├── realms
├── __init__.py
├── commands.py
├── config
│ └── __init__.py
├── lib
│ ├── __init__.py
│ ├── flask_csrf_test_client.py
│ ├── hook.py
│ ├── model.py
│ ├── test.py
│ └── util.py
├── modules
│ ├── __init__.py
│ ├── auth
│ │ ├── __init__.py
│ │ ├── ldap
│ │ │ ├── __init__.py
│ │ │ ├── forms.py
│ │ │ ├── models.py
│ │ │ └── views.py
│ │ ├── local
│ │ │ ├── __init__.py
│ │ │ ├── commands.py
│ │ │ ├── forms.py
│ │ │ ├── hooks.py
│ │ │ ├── models.py
│ │ │ └── views.py
│ │ ├── models.py
│ │ ├── oauth
│ │ │ ├── __init__.py
│ │ │ ├── models.py
│ │ │ └── views.py
│ │ ├── proxy
│ │ │ ├── __init__.py
│ │ │ ├── hooks.py
│ │ │ └── models.py
│ │ ├── templates
│ │ │ └── auth
│ │ │ │ ├── ldap
│ │ │ │ └── login.html
│ │ │ │ ├── local
│ │ │ │ └── login.html
│ │ │ │ ├── login.html
│ │ │ │ ├── register.html
│ │ │ │ └── settings.html
│ │ ├── tests.py
│ │ └── views.py
│ ├── search
│ │ ├── __init__.py
│ │ ├── commands.py
│ │ ├── hooks.py
│ │ ├── models.py
│ │ ├── templates
│ │ │ └── search
│ │ │ │ └── search.html
│ │ └── views.py
│ └── wiki
│ │ ├── __init__.py
│ │ ├── assets.py
│ │ ├── hooks.py
│ │ ├── models.py
│ │ ├── static
│ │ └── js
│ │ │ ├── aced.js
│ │ │ ├── collaboration
│ │ │ ├── firepad.js
│ │ │ ├── main.js
│ │ │ └── togetherjs.js
│ │ │ └── editor.js
│ │ ├── templates
│ │ └── wiki
│ │ │ ├── compare.html
│ │ │ ├── edit.html
│ │ │ ├── history.html
│ │ │ ├── index.html
│ │ │ └── page.html
│ │ ├── tests.py
│ │ └── views.py
├── static
│ ├── css
│ │ └── style.css
│ ├── fonts
│ │ ├── FontAwesome.otf
│ │ ├── fontawesome-webfont.eot
│ │ ├── fontawesome-webfont.svg
│ │ ├── fontawesome-webfont.ttf
│ │ └── fontawesome-webfont.woff
│ ├── humans.txt
│ ├── img
│ │ └── favicon.ico
│ ├── js
│ │ ├── hbs-helpers.js
│ │ ├── html-css-sanitizer-minified.js
│ │ ├── html-sanitizer-minified.js
│ │ ├── html5shiv.js
│ │ ├── main.js
│ │ ├── mdr.js
│ │ └── respond.js
│ └── robots.txt
├── templates
│ ├── errors
│ │ ├── 404.html
│ │ └── error.html
│ ├── layout.html
│ └── macros.html
└── version.py
├── requirements-dev.txt
├── requirements.txt
└── setup.py
/.bowerrc:
--------------------------------------------------------------------------------
1 | {
2 | "directory": "realms/static/vendor"
3 | }
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | .vagrant
2 | .venv
3 | .venv-pypy
4 | .vscode
5 | .idea
6 | .webassets-cache
7 | *.pyc
8 | *.egg-info
9 | *.pid
10 | pidfile
11 | /dist
12 | /build
13 | config.py
14 | config.sls
15 | config.json
16 | realms-wiki.json
17 | realms/static/vendor
18 | realms/static/assets/*
19 | /wiki.db
20 | /wiki
21 | npm-debug.log
22 | realms-data
23 |
--------------------------------------------------------------------------------
/.travis.yml:
--------------------------------------------------------------------------------
1 | language: python
2 | python:
3 | - "2.7"
4 | - "3.4"
5 | - "3.5"
6 | - "3.6"
7 |
8 | addons:
9 | apt:
10 | packages:
11 | - libxml2-dev
12 | - libxslt1-dev
13 | - zlib1g-dev
14 | - libffi-dev
15 | - libyaml-dev
16 | - libssl-dev
17 |
18 | install:
19 | - pip install -U pipenv
20 | - pipenv install --dev
21 |
22 | script: pipenv run nosetests
23 |
24 | sudo: false
25 |
--------------------------------------------------------------------------------
/MANIFEST.in:
--------------------------------------------------------------------------------
1 | include requirements.txt LICENSE README.md
2 | graft realms/static
3 | graft realms/templates
4 | graft realms/modules/**/static
5 | graft realms/modules/**/templates
6 |
--------------------------------------------------------------------------------
/Pipfile:
--------------------------------------------------------------------------------
1 | [[source]]
2 | url = "https://pypi.python.org/simple"
3 | verify_ssl = true
4 |
5 | [dev-packages]
6 | Flask-Testing = "==0.7.1"
7 | nose = "==1.3.4"
8 | blinker = "==1.3"
9 | pipenv = "*"
10 |
11 | [packages]
12 | Flask = "==0.11.1"
13 | Flask-Assets = "==0.12"
14 | Flask-Cache = "==0.13.1"
15 | Flask-Elastic = "==0.2"
16 | Flask-Login = "==0.3.2"
17 | Flask-OAuthlib = "==0.9.4"
18 | Flask-SQLAlchemy = "==2.1"
19 | Flask-WTF = "==0.12"
20 | PyYAML = "==3.11"
21 | bcrypt = "==3.1.4"
22 | beautifulsoup4 = "==4.3.2"
23 | click = "==5.1"
24 | dulwich = "==0.18.2"
25 | gevent = "==1.0.2"
26 | ghdiff = "==0.4"
27 | gunicorn = "==19.5"
28 | itsdangerous = "==0.24"
29 | markdown2 = "==2.3.5"
30 | simplejson = "==3.6.3"
31 | six = "==1.10.0"
32 | ldap3 = "*"
33 |
--------------------------------------------------------------------------------
/Vagrantfile:
--------------------------------------------------------------------------------
1 | Vagrant.configure("2") do |config|
2 | config.vm.box = "ubuntu/xenial64"
3 |
4 | config.vm.provider :virtualbox do |vb|
5 | vb.name = "realms-wiki"
6 | vb.memory = 512
7 | vb.cpus = 2
8 | end
9 |
10 | config.vm.provision "shell", path: "provision.sh", privileged: false
11 | config.vm.network "forwarded_port", guest: 5000, host: 5000
12 | end
13 |
--------------------------------------------------------------------------------
/bower.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "realms",
3 | "version": "0.1.2",
4 | "dependencies": {
5 | "components-bootstrap": "~3.3.6",
6 | "jquery": "~2.2.3",
7 | "highlightjs": "~9.2.0",
8 | "handlebars": "~4.0.5",
9 | "keymaster": "madrobby/keymaster",
10 | "ace-builds": "~1.2.3",
11 | "parsleyjs": "~2.3.10",
12 | "markdown-it": "~7.0.0",
13 | "markdown-it-toc-and-anchor": "*",
14 | "js-yaml": "~3.6.0",
15 | "store-js": "~1.3.16",
16 | "bootswatch-dist": "3.3.6-flatly",
17 | "bootbox": "4.4.0",
18 | "datatables": "1.10.16",
19 | "datatables-plugins": "latest"
20 | }
21 | }
22 |
--------------------------------------------------------------------------------
/docker-compose.yml:
--------------------------------------------------------------------------------
1 | version: "3"
2 |
3 | services:
4 |
5 | realms:
6 | container_name: realms
7 | image: realms/realms-wiki:1.0.0
8 | build:
9 | context: .
10 | dockerfile: ./docker/Dockerfile
11 | volumes:
12 | - ./realms-data:/home/wiki/data
13 | ports:
14 | - "5001:5000"
15 | # set as default in Dockerfile. Change if using a different DB
16 | # environment:
17 | # - REALMS_DB_URI=sqlite:////home/wiki/data/wiki.db
--------------------------------------------------------------------------------
/docker/Dockerfile:
--------------------------------------------------------------------------------
1 | FROM ubuntu:xenial
2 |
3 | ENV DEBIAN_FRONTEND noninteractive
4 |
5 | # install prereqs
6 | RUN apt-get -qq update && \
7 | echo 'debconf debconf/frontend select Noninteractive' | debconf-set-selections && \
8 | apt-get install -y sudo git curl python-pip python-dev libxml2-dev libxslt1-dev zlib1g-dev libffi-dev libyaml-dev libssl-dev libsasl2-dev libldap2-dev
9 |
10 | # install pipenv
11 | RUN pip install -U pipenv
12 |
13 | # install node
14 | RUN curl -sL https://deb.nodesource.com/setup_8.x | bash -
15 | RUN apt-get install -y nodejs
16 | RUN npm install -g bower clean-css@3
17 |
18 | # clean up
19 | RUN apt-get clean && rm -rf /var/lib/apt/lists/* /tmp/* /var/tmp/*
20 |
21 | # create wiki user
22 | RUN useradd -ms /bin/bash wiki && gpasswd -a wiki sudo
23 |
24 | # copy the cloned repo recursively
25 | COPY ./ /home/wiki/realms-wiki/
26 | RUN chown -R wiki:wiki /home/wiki && \
27 | echo "wiki:wiki" | chpasswd && \
28 | echo "wiki ALL=(ALL:ALL) NOPASSWD: ALL" | tee -a /etc/sudoers
29 |
30 | USER wiki
31 |
32 | WORKDIR /home/wiki/realms-wiki/
33 |
34 | ENV WORKERS=3
35 | ENV GEVENT_RESOLVER=ares
36 |
37 | ENV REALMS_ENV=docker
38 | ENV REALMS_WIKI_PATH=/home/wiki/data/repo
39 | ENV REALMS_DB_URI='sqlite:////home/wiki/data/wiki.db'
40 | ENV PIPENV_VENV_IN_PROJECT=1
41 |
42 | RUN pipenv install --two --dev && \
43 | bower --config.interactive=false install && \
44 | mkdir /home/wiki/data
45 |
46 | RUN pipenv run python setup.py install
47 |
48 | VOLUME /home/wiki/data
49 |
50 | EXPOSE 5000
51 |
52 | RUN /home/wiki/realms-wiki/.venv/bin/realms-wiki create_db && \
53 | echo '#!/bin/bash \n\
54 | /home/wiki/realms-wiki/.venv/bin/realms-wiki $@ \n' >> /tmp/realms-wiki && \
55 | sudo mv /tmp/realms-wiki /usr/local/bin && \
56 | sudo chmod +x /usr/local/bin/realms-wiki
57 |
58 | CMD . /home/wiki/realms-wiki/.venv/bin/activate && \
59 | gunicorn \
60 | --name realms-wiki \
61 | --access-logfile - \
62 | --error-logfile - \
63 | --worker-class gevent \
64 | --workers ${WORKERS} \
65 | --bind 0.0.0.0:5000 \
66 | --chdir /home/wiki/realms-wiki \
67 | 'realms:create_app()'
68 |
--------------------------------------------------------------------------------
/provision.sh:
--------------------------------------------------------------------------------
1 | #!/bin/bash
2 |
3 | # Provision script for Ubuntu 16.04
4 | APP_DIR="$( cd "$( dirname "${BASH_SOURCE[0]}" )" && pwd )"
5 |
6 | if [ -d "/vagrant" ]; then
7 | # Control will enter here if $DIRECTORY exists.
8 | APP_DIR="/vagrant"
9 | fi
10 |
11 | echo "Provisioning..."
12 |
13 | sudo apt-get -qq update && sudo apt-get -y upgrade
14 |
15 | # install deps
16 | sudo apt-get install -y python-pip python-dev libxml2-dev libxslt1-dev zlib1g-dev libffi-dev libyaml-dev libssl-dev libsasl2-dev libldap2-dev
17 |
18 | # install pipenv
19 | sudo pip install -U pipenv
20 |
21 | # install node
22 | curl -sL https://deb.nodesource.com/setup_8.x | sudo -E bash -
23 | sudo apt-get install -y nodejs
24 | sudo npm install -g bower
25 |
26 | # Elastic Search
27 | # wget -qO - https://artifacts.elastic.co/GPG-KEY-elasticsearch | sudo apt-key add -
28 | # echo "deb https://artifacts.elastic.co/packages/5.x/apt stable main" | sudo tee /etc/apt/sources.list.d/elastic-5.x.list
29 | # sudo apt-get -qq update && sudo apt-get install -y elasticsearch
30 |
31 | # Default cache is memoization
32 |
33 | # Redis
34 | # sudo add-apt-repository -y chris-lea/redis-server && sudo apt-get -qq update && sudo apt-get install -y redis-server
35 |
36 | # Default DB is sqlite
37 |
38 | # Mysql
39 | # sudo apt-get install -y mysql-server mysql-client
40 |
41 | # MariaDB
42 | # sudo apt-get install -y mariadb-server mariadb-client
43 |
44 | # Postgres
45 | # sudo apt-get install -y postgresql postgresql-contrib
46 |
47 | cd ${APP_DIR}
48 |
49 | # install virtualenv and python deps
50 | pipenv install --two
51 |
52 | # install frontend deps
53 | bower --config.interactive=false install
54 |
--------------------------------------------------------------------------------
/realms-wiki:
--------------------------------------------------------------------------------
1 | #!/usr/bin/env python
2 |
3 | from realms.commands import cli
4 |
5 | if __name__ == '__main__':
6 | print()
7 | print("----------------------------------")
8 | print("This script is for development.\n" \
9 | "If you installed via pip, " \
10 | "you should have realms-wiki in your PATH that should be used instead.")
11 | print("----------------------------------")
12 | print()
13 |
14 | cli()
15 |
--------------------------------------------------------------------------------
/realms/__init__.py:
--------------------------------------------------------------------------------
1 | from __future__ import absolute_import
2 |
3 | import sys
4 |
5 | # Set default encoding to UTF-8
6 | try:
7 | reload(sys)
8 | # noinspection PyUnresolvedReferences
9 | sys.setdefaultencoding('utf-8')
10 | except NameError:
11 | # python3
12 | pass
13 |
14 | import functools
15 | import base64
16 | import time
17 | import json
18 | import traceback
19 | import six.moves.http_client as httplib
20 | from functools import update_wrapper
21 |
22 | import click
23 | from flask import Flask, request, render_template, url_for, redirect, g
24 | from flask_cache import Cache
25 | from flask_login import LoginManager, current_user
26 | from flask_sqlalchemy import SQLAlchemy
27 | from flask_assets import Environment, Bundle
28 | from flask_wtf import CsrfProtect
29 | from werkzeug.routing import BaseConverter
30 | from werkzeug.exceptions import HTTPException
31 | from sqlalchemy.ext.declarative import declarative_base
32 |
33 | from realms.modules.search.models import Search
34 | from realms.lib.util import to_canonical, remove_ext, mkdir_safe, gravatar_url, to_dict, is_su, in_virtualenv
35 | from realms.lib.hook import HookModelMeta, HookMixin
36 | from realms.version import __version__
37 |
38 | try:
39 | from mod_wsgi import version
40 | running_on_wsgi = True
41 | except:
42 | running_on_wsgi = False
43 |
44 |
45 | class Application(Flask):
46 |
47 | def __call__(self, environ, start_response):
48 | path_info = environ.get('PATH_INFO')
49 |
50 | if path_info and len(path_info) > 1 and path_info.endswith('/'):
51 | environ['PATH_INFO'] = path_info[:-1]
52 |
53 | scheme = environ.get('HTTP_X_SCHEME')
54 |
55 | if scheme:
56 | environ['wsgi.url_scheme'] = scheme
57 |
58 | real_ip = environ.get('HTTP_X_REAL_IP')
59 |
60 | if real_ip:
61 | environ['REMOTE_ADDR'] = real_ip
62 |
63 | return super(Application, self).__call__(environ, start_response)
64 |
65 | def discover(self):
66 | import_name = 'realms.modules'
67 | fromlist = (
68 | 'assets',
69 | 'commands',
70 | 'models',
71 | 'views',
72 | 'hooks'
73 | )
74 |
75 | start_time = time.time()
76 |
77 | __import__(import_name, fromlist=fromlist)
78 |
79 | for module_name in self.config['MODULES']:
80 | sources = __import__('{0}.{1}'.format(import_name, module_name), fromlist=fromlist)
81 |
82 | if hasattr(sources, 'init'):
83 | sources.init(self)
84 |
85 | # Blueprint
86 | if hasattr(sources, 'views'):
87 |
88 | if running_on_wsgi:
89 | self.register_blueprint(sources.views.blueprint, url_prefix='')
90 | else:
91 | self.register_blueprint(sources.views.blueprint, url_prefix=self.config['RELATIVE_PATH'])
92 |
93 | # Click
94 | if hasattr(sources, 'commands'):
95 | if sources.commands.cli.name == 'cli':
96 | sources.commands.cli.name = module_name
97 | cli.add_command(sources.commands.cli)
98 |
99 | # Hooks
100 | if hasattr(sources, 'hooks'):
101 | if hasattr(sources.hooks, 'before_request'):
102 | self.before_request(sources.hooks.before_request)
103 |
104 | if hasattr(sources.hooks, 'before_first_request'):
105 | self.before_first_request(sources.hooks.before_first_request)
106 |
107 | # print >> sys.stderr, ' * Ready in %.2fms' % (1000.0 * (time.time() - start_time))
108 |
109 | def make_response(self, rv):
110 | if rv is None:
111 | rv = '', httplib.NO_CONTENT
112 | elif not isinstance(rv, tuple):
113 | rv = rv,
114 |
115 | rv = list(rv)
116 |
117 | if isinstance(rv[0], (list, dict)):
118 | rv[0] = self.response_class(json.dumps(rv[0]), mimetype='application/json')
119 |
120 | return super(Application, self).make_response(tuple(rv))
121 |
122 |
123 | class Assets(Environment):
124 | default_filters = {'js': 'rjsmin', 'css': 'cleancss'}
125 | default_output = {'js': 'assets/%(version)s.js', 'css': 'assets/%(version)s.css'}
126 |
127 | def register(self, name, *args, **kwargs):
128 | ext = args[0].split('.')[-1]
129 | filters = kwargs.get('filters', self.default_filters[ext])
130 | output = kwargs.get('output', self.default_output[ext])
131 |
132 | return super(Assets, self).register(name, Bundle(*args, filters=filters, output=output))
133 |
134 |
135 | class RegexConverter(BaseConverter):
136 | """ Enables Regex matching on endpoints
137 | """
138 | def __init__(self, url_map, *items):
139 | super(RegexConverter, self).__init__(url_map)
140 | self.regex = items[0]
141 |
142 |
143 | def redirect_url(referrer=None):
144 | if not referrer:
145 | referrer = request.referrer
146 | return request.args.get('next') or referrer or url_for('index')
147 |
148 |
149 | def error_handler(e):
150 | try:
151 | if isinstance(e, HTTPException):
152 | status_code = e.code
153 | message = e.description if e.description != type(e).description else None
154 | tb = None
155 | else:
156 | status_code = httplib.INTERNAL_SERVER_ERROR
157 | message = None
158 | tb = traceback.format_exc() if current_user.admin else None
159 |
160 | if request.is_xhr or request.accept_mimetypes.best in ['application/json', 'text/javascript']:
161 | response = {
162 | 'message': message,
163 | 'traceback': tb
164 | }
165 | else:
166 | response = render_template('errors/error.html',
167 | title=httplib.responses[status_code],
168 | status_code=status_code,
169 | message=message,
170 | traceback=tb)
171 | except HTTPException as e2:
172 | return error_handler(e2)
173 |
174 | return response, status_code
175 |
176 |
177 | def create_app():
178 | app = Application(__name__)
179 | app.config.from_object('realms.config.conf')
180 | app.url_map.converters['regex'] = RegexConverter
181 | app.url_map.strict_slashes = False
182 |
183 | login_manager.init_app(app)
184 | db.init_app(app)
185 | cache.init_app(app)
186 | assets.init_app(app)
187 | search.init_app(app)
188 | csrf.init_app(app)
189 |
190 | db.Model = declarative_base(metaclass=HookModelMeta, cls=HookMixin)
191 |
192 | app.register_error_handler(HTTPException, error_handler)
193 |
194 | @app.before_request
195 | def init_g():
196 | g.assets = dict(css=['main.css'], js=['main.js'])
197 |
198 | @app.template_filter('datetime')
199 | def _jinja2_filter_datetime(ts, fmt=None):
200 | return time.strftime(
201 | fmt or app.config.get('DATETIME_FORMAT', '%b %d, %Y %I:%M %p'),
202 | time.localtime(ts)
203 | )
204 |
205 | @app.template_filter('b64encode')
206 | def _jinja2_filter_b64encode(s):
207 | return base64.urlsafe_b64encode(s.encode('utf-8')).rstrip(b"=").decode()
208 |
209 | @app.errorhandler(404)
210 | def page_not_found(e):
211 | return render_template('errors/404.html'), 404
212 |
213 | if not running_on_wsgi and app.config.get('RELATIVE_PATH'):
214 | @app.route("/")
215 | def root():
216 | return redirect(url_for(app.config.get('ROOT_ENDPOINT')))
217 |
218 | app.discover()
219 |
220 | # This will be removed at some point
221 | with app.app_context():
222 | if app.config.get('DB_URI'):
223 | db.metadata.create_all(db.get_engine(app))
224 |
225 | return app
226 |
227 | # Init plugins here if possible
228 | login_manager = LoginManager()
229 |
230 | db = SQLAlchemy()
231 | # Add a 5 second timeout, to also work with offline git + text editor commits
232 | cache = Cache(config={'CACHE_DEFAULT_TIMEOUT': 5.0})
233 | assets = Assets()
234 | search = Search()
235 | csrf = CsrfProtect()
236 |
237 | assets.register('main.js',
238 | 'vendor/jquery/dist/jquery.js',
239 | 'vendor/components-bootstrap/js/bootstrap.js',
240 | 'vendor/handlebars/handlebars.js',
241 | 'vendor/js-yaml/dist/js-yaml.js',
242 | 'vendor/markdown-it/dist/markdown-it.js',
243 | # 'vendor/markdown-it-anchor/index.0', # no ES5 version
244 | 'js/html-sanitizer-minified.js', # don't minify?
245 | 'vendor/highlightjs/highlight.pack.js',
246 | 'vendor/parsleyjs/dist/parsley.js',
247 | 'vendor/datatables/media/js/jquery.dataTables.js',
248 | 'vendor/datatables-plugins/integration/bootstrap/3/dataTables.bootstrap.js',
249 | 'js/hbs-helpers.js',
250 | 'js/mdr.js',
251 | 'js/main.js')
252 |
253 | assets.register('main.css',
254 | 'vendor/bootswatch-dist/css/bootstrap.css',
255 | 'vendor/highlightjs/styles/github.css',
256 | 'vendor/datatables-plugins/integration/bootstrap/3/dataTables.bootstrap.css',
257 | 'css/style.css')
258 |
259 |
260 | def with_appcontext(f):
261 | """Wraps a callback so that it's guaranteed to be executed with the
262 | script's application context. If callbacks are registered directly
263 | to the ``app.cli`` object then they are wrapped with this function
264 | by default unless it's disabled.
265 | """
266 | @click.pass_context
267 | def decorator(__ctx, *args, **kwargs):
268 | with create_app().app_context():
269 | return __ctx.invoke(f, *args, **kwargs)
270 | return update_wrapper(decorator, f)
271 |
272 |
273 | class AppGroup(click.Group):
274 | """This works similar to a regular click :class:`~click.Group` but it
275 | changes the behavior of the :meth:`command` decorator so that it
276 | automatically wraps the functions in :func:`with_appcontext`.
277 | Not to be confused with :class:`FlaskGroup`.
278 | """
279 |
280 | def command(self, *args, **kwargs):
281 | """This works exactly like the method of the same name on a regular
282 | :class:`click.Group` but it wraps callbacks in :func:`with_appcontext`
283 | unless it's disabled by passing ``with_appcontext=False``.
284 | """
285 | wrap_for_ctx = kwargs.pop('with_appcontext', True)
286 |
287 | def decorator(f):
288 | if wrap_for_ctx:
289 | f = with_appcontext(f)
290 | return click.Group.command(self, *args, **kwargs)(f)
291 | return decorator
292 |
293 | def group(self, *args, **kwargs):
294 | """This works exactly like the method of the same name on a regular
295 | :class:`click.Group` but it defaults the group class to
296 | :class:`AppGroup`.
297 | """
298 | kwargs.setdefault('cls', AppGroup)
299 | return click.Group.group(self, *args, **kwargs)
300 |
301 |
302 | cli = AppGroup()
303 |
304 | # Decorator to be used in modules instead of click.group
305 | cli_group = functools.partial(click.group, cls=AppGroup)
306 |
307 |
--------------------------------------------------------------------------------
/realms/commands.py:
--------------------------------------------------------------------------------
1 | from __future__ import absolute_import
2 |
3 | import json
4 | import sys
5 | import os
6 | import time
7 | from subprocess import call, Popen
8 | from multiprocessing import cpu_count
9 |
10 | import click
11 | import pip
12 |
13 | from realms import config, create_app, db, __version__, cli, cache
14 | from realms.lib.util import random_string, in_virtualenv, green, yellow, red
15 |
16 |
17 | config = config.conf
18 |
19 |
20 | # called to discover commands in modules
21 | app = create_app()
22 |
23 |
24 | def get_user():
25 | for name in ('SUDO_USER', 'LOGNAME', 'USER', 'LNAME', 'USERNAME'):
26 | user = os.environ.get(name)
27 | if user:
28 | return user
29 |
30 |
31 | def get_pid():
32 | try:
33 | with open(config.PIDFILE) as f:
34 | return f.read().strip()
35 | except IOError:
36 | return None
37 |
38 |
39 | def is_running(pid):
40 | if not pid:
41 | return False
42 |
43 | pid = int(pid)
44 |
45 | try:
46 | os.kill(pid, 0)
47 | except OSError:
48 | return False
49 |
50 | return True
51 |
52 |
53 | def module_exists(module_name):
54 | try:
55 | __import__(module_name)
56 | except ImportError:
57 | return False
58 | else:
59 | return True
60 |
61 |
62 | def prompt_and_invoke(ctx, fn):
63 | # This is a workaround for a bug in click.
64 | # See https://github.com/mitsuhiko/click/issues/429
65 | # This isn't perfect - we are ignoring some information (type mostly)
66 |
67 | kw = {}
68 |
69 | for p in fn.params:
70 | v = click.prompt(p.prompt, p.default, p.hide_input,
71 | p.confirmation_prompt, p.type)
72 | kw[p.name] = v
73 |
74 | ctx.invoke(fn, **kw)
75 |
76 |
77 | @cli.command()
78 | @click.option('--site-title',
79 | default=config.SITE_TITLE,
80 | prompt='Enter site title.')
81 | @click.option('--base_url',
82 | default=config.BASE_URL,
83 | prompt='Enter base URL.')
84 | @click.option('--port',
85 | default=config.PORT,
86 | prompt='Enter port number.')
87 | @click.option('--secret-key',
88 | default=config.SECRET_KEY if config.SECRET_KEY != "CHANGE_ME" else random_string(64),
89 | prompt='Enter secret key.')
90 | @click.option('--wiki-path',
91 | default=config.WIKI_PATH,
92 | prompt='Enter wiki data directory.',
93 | help='Wiki Directory (git repo)')
94 | @click.option('--allow-anon',
95 | default=config.ALLOW_ANON,
96 | is_flag=True,
97 | prompt='Allow anonymous edits?')
98 | @click.option('--registration-enabled',
99 | default=config.REGISTRATION_ENABLED,
100 | is_flag=True,
101 | prompt='Enable registration?')
102 | @click.option('--cache-type',
103 | default=config.CACHE_TYPE,
104 | type=click.Choice([None, 'simple', 'redis', 'memcached']),
105 | prompt='Cache type?')
106 | @click.option('--search-type',
107 | default=config.SEARCH_TYPE,
108 | type=click.Choice(['simple', 'whoosh', 'elasticsearch']),
109 | prompt='Search type?')
110 | @click.option('--db-uri',
111 | default=config.DB_URI,
112 | prompt='Database URI? Examples: http://goo.gl/RyW0cl')
113 | @click.pass_context
114 | def setup(ctx, **kw):
115 | """ Start setup wizard
116 | """
117 |
118 | try:
119 | os.mkdir('/etc/realms-wiki')
120 | except OSError:
121 | pass
122 |
123 | conf = {}
124 |
125 | for k, v in kw.items():
126 | conf[k.upper()] = v
127 |
128 | conf_path = config.update(conf)
129 |
130 | if conf['CACHE_TYPE'] == 'redis':
131 | prompt_and_invoke(ctx, setup_redis)
132 | elif conf['CACHE_TYPE'] == 'memcached':
133 | prompt_and_invoke(ctx, setup_memcached)
134 |
135 | if conf['SEARCH_TYPE'] == 'elasticsearch':
136 | prompt_and_invoke(ctx, setup_elasticsearch)
137 | elif conf['SEARCH_TYPE'] == 'whoosh':
138 | install_whoosh()
139 |
140 | green('Config saved to %s' % conf_path)
141 |
142 | if not conf_path.startswith('/etc/realms-wiki'):
143 | yellow('Note: You can move file to /etc/realms-wiki/realms-wiki.json')
144 | click.echo()
145 |
146 | yellow('Type "realms-wiki start" to start server')
147 | yellow('Type "realms-wiki dev" to start server in development mode')
148 | yellow('Full usage: realms-wiki --help')
149 |
150 |
151 | @click.command()
152 | @click.option('--cache-redis-host',
153 | default=getattr(config, 'CACHE_REDIS_HOST', "127.0.0.1"),
154 | prompt='Redis host')
155 | @click.option('--cache-redis-port',
156 | default=getattr(config, 'CACHE_REDIS_POST', 6379),
157 | prompt='Redis port',
158 | type=int)
159 | @click.option('--cache-redis-password',
160 | default=getattr(config, 'CACHE_REDIS_PASSWORD', None),
161 | prompt='Redis password')
162 | @click.option('--cache-redis-db',
163 | default=getattr(config, 'CACHE_REDIS_DB', 0),
164 | prompt='Redis db')
165 | @click.pass_context
166 | def setup_redis(ctx, **kw):
167 | conf = config.read()
168 |
169 | for k, v in kw.items():
170 | conf[k.upper()] = v
171 |
172 | config.update(conf)
173 | install_redis()
174 |
175 |
176 | @click.command()
177 | @click.option('--elasticsearch-url',
178 | default=getattr(config, 'ELASTICSEARCH_URL', 'http://127.0.0.1:9200'),
179 | prompt='Elasticsearch URL')
180 | def setup_elasticsearch(**kw):
181 | conf = config.read()
182 |
183 | for k, v in kw.items():
184 | conf[k.upper()] = v
185 |
186 | config.update(conf)
187 |
188 | cli.add_command(setup_redis)
189 | cli.add_command(setup_elasticsearch)
190 |
191 |
192 | def get_prefix():
193 | return sys.prefix
194 |
195 |
196 | @cli.command(name='pip')
197 | @click.argument('cmd', nargs=-1)
198 | def pip_(cmd):
199 | """ Execute pip commands, useful for virtualenvs
200 | """
201 | pip.main(cmd)
202 |
203 |
204 | def install_redis():
205 | pip.main(['install', 'redis'])
206 |
207 |
208 | def install_whoosh():
209 | pip.main(['install', 'Whoosh'])
210 |
211 |
212 | def install_mysql():
213 | pip.main(['install', 'MySQL-Python'])
214 |
215 |
216 | def install_postgres():
217 | pip.main(['install', 'psycopg2'])
218 |
219 |
220 | def install_crate():
221 | pip.main(['install', 'crate'])
222 |
223 |
224 | def install_memcached():
225 | pip.main(['install', 'python-memcached'])
226 |
227 |
228 | @click.command()
229 | @click.option('--cache-memcached-servers',
230 | default=getattr(config, 'CACHE_MEMCACHED_SERVERS', ["127.0.0.1:11211"]),
231 | type=click.STRING,
232 | prompt='Memcached servers, separate with a space')
233 | def setup_memcached(**kw):
234 | conf = {}
235 |
236 | for k, v in kw.items():
237 | conf[k.upper()] = v
238 |
239 | config.update(conf)
240 |
241 |
242 | @cli.command()
243 | @click.option('--user',
244 | default=get_user(),
245 | type=click.STRING,
246 | prompt='Run as which user? (it must exist)')
247 | @click.option('--port',
248 | default=config.PORT,
249 | type=click.INT,
250 | prompt='What port to listen on?')
251 | @click.option('--workers',
252 | default=cpu_count() * 2 + 1,
253 | type=click.INT,
254 | prompt="Number of workers? (defaults to ncpu*2+1)")
255 | def setup_upstart(**kwargs):
256 | """ Start upstart conf creation wizard
257 | """
258 | from realms.lib.util import upstart_script
259 |
260 | if in_virtualenv():
261 | app_dir = get_prefix()
262 | path = '/'.join(sys.executable.split('/')[:-1])
263 | else:
264 | # Assumed root install, not sure if this matters?
265 | app_dir = '/'
266 | path = None
267 |
268 | kwargs.update(dict(app_dir=app_dir, path=path))
269 |
270 | conf_file = '/etc/init/realms-wiki.conf'
271 | script = upstart_script(**kwargs)
272 |
273 | try:
274 | with open(conf_file, 'w') as f:
275 | f.write(script)
276 | green('Wrote file to %s' % conf_file)
277 | except IOError:
278 | with open('/tmp/realms-wiki.conf', 'w') as f:
279 | f.write(script)
280 | yellow("Wrote file to /tmp/realms-wiki.conf, to install type:")
281 | yellow("sudo mv /tmp/realms-wiki.conf /etc/init/realms-wiki.conf")
282 |
283 | click.echo()
284 | click.echo("Upstart usage:")
285 | green("sudo start realms-wiki")
286 | green("sudo stop realms-wiki")
287 | green("sudo restart realms-wiki")
288 | green("sudo status realms-wiki")
289 |
290 |
291 | @cli.command()
292 | @click.argument('json_string')
293 | def configure(json_string):
294 | """ Set config, expects JSON encoded string
295 | """
296 | try:
297 | config.update(json.loads(json_string))
298 | except ValueError as e:
299 | red('Config value should be valid JSON')
300 |
301 |
302 | @cli.command()
303 | @click.option('--port', default=config.PORT)
304 | @click.option('--host', default=config.HOST)
305 | def dev(port, host):
306 | """ Run development server
307 | """
308 | green("Starting development server")
309 |
310 | config_path = config.get_path()
311 | if config_path:
312 | green("Using config: %s" % config_path)
313 | else:
314 | yellow("Using default configuration")
315 |
316 | app.run(host=host, port=port, debug=True)
317 |
318 |
319 | def start_server():
320 | if is_running(get_pid()):
321 | yellow("Server is already running")
322 | return
323 |
324 | try:
325 | open(config.PIDFILE, 'w')
326 | except IOError:
327 | red("PID file not writeable (%s) " % config.PIDFILE)
328 | return
329 |
330 | flags = '--daemon --pid %s' % config.PIDFILE
331 |
332 | green("Server started. Port: %s" % config.PORT)
333 |
334 | config_path = config.get_path()
335 | if config_path:
336 | green("Using config: %s" % config_path)
337 | else:
338 | yellow("Using default configuration")
339 |
340 | prefix = ''
341 | if in_virtualenv():
342 | prefix = get_prefix() + "/bin/"
343 |
344 | Popen("%sgunicorn 'realms:create_app()' -b %s:%s -k gevent %s" %
345 | (prefix, config.HOST, config.PORT, flags), shell=True, executable='/bin/bash')
346 |
347 |
348 | def stop_server():
349 | pid = get_pid()
350 | if not is_running(pid):
351 | yellow("Server is not running")
352 | else:
353 | yellow("Shutting down server")
354 | call(['kill', pid])
355 | while is_running(pid):
356 | time.sleep(1)
357 |
358 |
359 | @cli.command()
360 | def run():
361 | """ Run production server (alias for start)
362 | """
363 | start_server()
364 |
365 |
366 | @cli.command()
367 | def start():
368 | """ Run server daemon
369 | """
370 | start_server()
371 |
372 |
373 | @cli.command()
374 | def stop():
375 | """ Stop server
376 | """
377 | stop_server()
378 |
379 |
380 | @cli.command()
381 | def restart():
382 | """ Restart server
383 | """
384 | stop_server()
385 | start_server()
386 |
387 |
388 | @cli.command()
389 | def status():
390 | """ Get server status
391 | """
392 | pid = get_pid()
393 | if not is_running(pid):
394 | yellow("Server is not running")
395 | else:
396 | green("Server is running PID: %s" % pid)
397 |
398 |
399 | @cli.command()
400 | def create_db():
401 | """ Creates DB tables
402 | """
403 | green("Creating all tables")
404 | if app.config.get('DB_URI'):
405 | with app.app_context():
406 | green('DB_URI: %s' % app.config.get('DB_URI'))
407 | db.metadata.create_all(db.get_engine(app))
408 |
409 |
410 | @cli.command()
411 | @click.confirmation_option(help='Are you sure you want to drop the db?')
412 | def drop_db():
413 | """ Drops DB tables
414 | """
415 | yellow("Dropping all tables")
416 | with app.app_context():
417 | db.metadata.drop_all(db.get_engine(app))
418 |
419 |
420 | @cli.command()
421 | def clear_cache():
422 | """ Clears cache
423 | """
424 | yellow("Clearing the cache")
425 | with app.app_context():
426 | cache.clear()
427 |
428 |
429 | @cli.command()
430 | def test():
431 | """ Run tests
432 | """
433 | for mod in [('flask_testing', 'Flask-Testing'), ('nose', 'nose'), ('blinker', 'blinker')]:
434 | if not module_exists(mod[0]):
435 | pip.main(['install', mod[1]])
436 |
437 | nosetests = get_prefix() + "/bin/nosetests" if in_virtualenv() else "nosetests"
438 |
439 | call([nosetests, 'realms'])
440 |
441 |
442 | @cli.command()
443 | def version():
444 | """ Output version
445 | """
446 | green(__version__)
447 |
448 |
449 | @cli.command(add_help_option=False)
450 | def deploy():
451 | """ Deploy to PyPI
452 | """
453 | call("python setup.py sdist upload", shell=True)
454 |
455 |
456 | if __name__ == '__main__':
457 | cli()
458 |
--------------------------------------------------------------------------------
/realms/config/__init__.py:
--------------------------------------------------------------------------------
1 | from __future__ import absolute_import
2 |
3 | import json
4 | import os
5 |
6 | # noinspection PyUnresolvedReferences
7 | from six.moves.urllib.parse import urlparse
8 |
9 | from realms.lib.util import in_vagrant
10 |
11 |
12 | class Config(object):
13 |
14 | urlparse = urlparse
15 |
16 | APP_PATH = os.path.abspath(os.path.dirname(__file__) + "/../..")
17 | USER_HOME = os.path.abspath(os.path.expanduser("~"))
18 |
19 | # Best to change to /var/run
20 | PIDFILE = "/tmp/realms-wiki.pid"
21 |
22 | ENV = 'DEV'
23 |
24 | HOST = "0.0.0.0"
25 | PORT = 5000
26 | BASE_URL = 'http://localhost'
27 | SITE_TITLE = "Realms"
28 |
29 | # http://flask-sqlalchemy.pocoo.org/config/#connection-uri-format
30 | DB_URI = 'sqlite:////tmp/wiki.db'
31 | # DB_URI = 'mysql://scott:tiger@localhost/mydatabase'
32 | # DB_URI = 'postgresql://scott:tiger@localhost/mydatabase'
33 | # DB_URI = 'oracle://scott:tiger@127.0.0.1:1521/sidname'
34 | # DB_URI = 'crate://'
35 |
36 | # LDAP = {
37 | # 'URI': '',
38 | #
39 | # # This BIND_DN/BIND_PASSWORD default to '', this is shown here for demonstrative purposes
40 | # # The values '' perform an anonymous bind so we may use search/bind method
41 | # 'BIND_DN': '',
42 | # 'BIND_AUTH': '',
43 | #
44 | # # Adding the USER_SEARCH field tells the flask-ldap-login that we are using
45 | # # the search/bind method
46 | # 'USER_SEARCH': {'base': 'dc=example,dc=com', 'filter': 'uid=%(username)s'},
47 | #
48 | # # Map ldap keys into application specific keys
49 | # 'KEY_MAP': {
50 | # 'name': 'cn',
51 | # 'company': 'o',
52 | # 'location': 'l',
53 | # 'email': 'mail',
54 | # }
55 | # }
56 |
57 | # OAUTH = {
58 | # 'twitter': {
59 | # 'key': '',
60 | # 'secret': ''
61 | # },
62 | # 'github': {
63 | # 'key': '',
64 | # 'secret': ''
65 | # }
66 | # }
67 |
68 | # Valid options: simple, redis, memcached
69 | CACHE_TYPE = 'simple'
70 |
71 | CACHE_REDIS_HOST = '127.0.0.1'
72 | CACHE_REDIS_PORT = 6379
73 | CACHE_REDIS_DB = '0'
74 |
75 | CACHE_MEMCACHED_SERVERS = ['127.0.0.1:11211']
76 |
77 | # Valid options: simple, elasticsearch, whoosh
78 | SEARCH_TYPE = 'simple'
79 |
80 | ELASTICSEARCH_URL = 'http://127.0.0.1:9200'
81 | ELASTICSEARCH_FIELDS = ["name"]
82 |
83 | WHOOSH_INDEX = '/tmp/whoosh'
84 | WHOOSH_LANGUAGE = 'en'
85 |
86 | # Get ReCaptcha Keys for your domain here:
87 | # https://www.google.com/recaptcha/admin#whyrecaptcha
88 | RECAPTCHA_ENABLE = False
89 | RECAPTCHA_USE_SSL = False
90 | RECAPTCHA_PUBLIC_KEY = "6LfYbPkSAAAAAB4a2lG2Y_Yjik7MG9l4TDzyKUao"
91 | RECAPTCHA_PRIVATE_KEY = "6LfYbPkSAAAAAG-KlkwjZ8JLWgwc9T0ytkN7lWRE"
92 | RECAPTCHA_OPTIONS = {}
93 |
94 | SECRET_KEY = 'CHANGE_ME'
95 |
96 | # Path on file system where wiki data will reside
97 | WIKI_PATH = '/tmp/wiki'
98 |
99 | # Name of page that will act as home
100 | WIKI_HOME = 'home'
101 |
102 | # Should we trust authentication set by a proxy
103 | AUTH_PROXY = False
104 | AUTH_PROXY_HEADER_NAME = "REMOTE_USER"
105 |
106 | AUTH_LOCAL_ENABLE = True
107 | _ALLOW_ANON = True
108 | REGISTRATION_ENABLED = True
109 | PRIVATE_WIKI = False
110 |
111 | # None, firepad, and/or togetherjs
112 | COLLABORATION = 'togetherjs'
113 |
114 | # Required for firepad
115 | FIREBASE_HOSTNAME = None
116 |
117 | # Page names that can't be modified
118 | WIKI_LOCKED_PAGES = []
119 |
120 | ROOT_ENDPOINT = 'wiki.page'
121 |
122 | @property
123 | def ALLOW_ANON(self):
124 | return not self.PRIVATE_WIKI and self._ALLOW_ANON
125 |
126 | @ALLOW_ANON.setter
127 | def ALLOW_ANON(self, value):
128 | self._ALLOW_ANON = value
129 |
130 | # Used by Flask-Login
131 | @property
132 | def LOGIN_DISABLED(self):
133 | return self.ALLOW_ANON
134 |
135 | # Depreciated variable name
136 | @property
137 | def LOCKED(self):
138 | return self.WIKI_LOCKED_PAGES[:]
139 |
140 | @property
141 | def SQLALCHEMY_DATABASE_URI(self):
142 | return self.DB_URI
143 |
144 | @property
145 | def _url(self):
146 | return urlparse(self.BASE_URL)
147 |
148 | @property
149 | def RELATIVE_PATH(self):
150 | return self._url.path
151 |
152 | USE_X_SENDFILE = False
153 |
154 | DEBUG = False
155 | ASSETS_DEBUG = False
156 | SQLALCHEMY_ECHO = False
157 | SQLALCHEMY_TRACK_MODIFICATIONS = False
158 |
159 | MODULES = ['wiki', 'search', 'auth']
160 |
161 | def __init__(self):
162 | for k, v in self.read().items():
163 | setattr(self, k, v)
164 | if getattr(self, 'AUTH_LOCAL_ENABLE', True):
165 | self.MODULES.append('auth.local')
166 | if hasattr(self, 'OAUTH'):
167 | self.MODULES.append('auth.oauth')
168 | if hasattr(self, 'LDAP'):
169 | self.MODULES.append('auth.ldap')
170 | if hasattr(self, "AUTH_PROXY"):
171 | self.MODULES.append('auth.proxy')
172 | if in_vagrant():
173 | self.USE_X_SENDFILE = False
174 | if self.ENV == "DEV":
175 | self.DEBUG = True
176 | self.ASSETS_DEBUG = True
177 | self.SQLALCHEMY_ECHO = True
178 | self.USE_X_SENDFILE = False
179 |
180 | def update(self, data):
181 | conf = self.read()
182 | conf.update(data)
183 | return self.save(data)
184 |
185 | def read(self):
186 | conf = dict()
187 |
188 | for k, v in os.environ.items():
189 | if k.startswith('REALMS_'):
190 | conf[k[7:]] = v
191 |
192 | loc = self.get_path()
193 |
194 | if loc:
195 | with open(loc) as f:
196 | conf.update(json.load(f))
197 |
198 | if 'BASE_URL' in conf and conf['BASE_URL'].endswith('/'):
199 | conf['BASE_URL'] = conf['BASE_URL'][:-1]
200 |
201 | for k in ['APP_PATH', 'USER_HOME']:
202 | if k in conf:
203 | del conf[k]
204 |
205 | return conf
206 |
207 | def save(self, conf):
208 | loc = self.get_path(check_write=True)
209 | with open(loc, 'w') as f:
210 | f.write(json.dumps(conf, sort_keys=True, indent=4, separators=(',', ': ')).strip() + '\n')
211 | return loc
212 |
213 | def get_path(self, check_write=False):
214 | """Find config path
215 | """
216 | for loc in os.curdir, os.path.expanduser("~"), "/etc/realms-wiki":
217 | if not loc:
218 | continue
219 | path = os.path.join(loc, "realms-wiki.json")
220 | if os.path.isfile(path):
221 | # file exists
222 | if not check_write:
223 | # Don't care if I can write
224 | return path
225 |
226 | if os.access(path, os.W_OK):
227 | # Has write access, ok!
228 | return path
229 | elif os.path.isdir(loc) and check_write:
230 | # dir exists file doesn't
231 | if os.access(loc, os.W_OK):
232 | # can write file
233 | return path
234 | return None
235 |
236 | conf = Config()
237 |
--------------------------------------------------------------------------------
/realms/lib/__init__.py:
--------------------------------------------------------------------------------
1 | from __future__ import absolute_import
2 |
3 |
--------------------------------------------------------------------------------
/realms/lib/flask_csrf_test_client.py:
--------------------------------------------------------------------------------
1 | # Source: https://gist.github.com/singingwolfboy/2fca1de64950d5dfed72
2 |
3 | # Want to run your Flask tests with CSRF protections turned on, to make sure
4 | # that CSRF works properly in production as well? Here's an excellent way
5 | # to do it!
6 |
7 | # First some imports. I'm assuming you're using Flask-WTF for CSRF protection.
8 | import flask
9 | from flask.testing import FlaskClient as BaseFlaskClient
10 | from flask_wtf.csrf import generate_csrf
11 |
12 | # Flask's assumptions about an incoming request don't quite match up with
13 | # what the test client provides in terms of manipulating cookies, and the
14 | # CSRF system depends on cookies working correctly. This little class is a
15 | # fake request that forwards along requests to the test client for setting
16 | # cookies.
17 | class RequestShim(object):
18 | """
19 | A fake request that proxies cookie-related methods to a Flask test client.
20 | """
21 | def __init__(self, client):
22 | self.client = client
23 |
24 | def set_cookie(self, key, value='', *args, **kwargs):
25 | "Set the cookie on the Flask test client."
26 | server_name = flask.current_app.config["SERVER_NAME"] or "localhost"
27 | return self.client.set_cookie(
28 | server_name, key=key, value=value, *args, **kwargs
29 | )
30 |
31 | def delete_cookie(self, key, *args, **kwargs):
32 | "Delete the cookie on the Flask test client."
33 | server_name = flask.current_app.config["SERVER_NAME"] or "localhost"
34 | return self.client.delete_cookie(
35 | server_name, key=key, *args, **kwargs
36 | )
37 |
38 | # We're going to extend Flask's built-in test client class, so that it knows
39 | # how to look up CSRF tokens for you!
40 | class FlaskClient(BaseFlaskClient):
41 | @property
42 | def csrf_token(self):
43 | # First, we'll wrap our request shim around the test client, so that
44 | # it will work correctly when Flask asks it to set a cookie.
45 | request = RequestShim(self)
46 | # Next, we need to look up any cookies that might already exist on
47 | # this test client, such as the secure cookie that powers `flask.session`,
48 | # and make a test request context that has those cookies in it.
49 | environ_overrides = {}
50 | self.cookie_jar.inject_wsgi(environ_overrides)
51 | with flask.current_app.test_request_context(
52 | "/login", environ_overrides=environ_overrides,
53 | ):
54 | # Now, we call Flask-WTF's method of generating a CSRF token...
55 | csrf_token = generate_csrf()
56 | # ...which also sets a value in `flask.session`, so we need to
57 | # ask Flask to save that value to the cookie jar in the test
58 | # client. This is where we actually use that request shim we made!
59 | flask.current_app.save_session(flask.session, request)
60 | # And finally, return that CSRF token we got from Flask-WTF.
61 | return csrf_token
62 |
--------------------------------------------------------------------------------
/realms/lib/hook.py:
--------------------------------------------------------------------------------
1 | from __future__ import absolute_import
2 | from six import with_metaclass
3 |
4 | from functools import wraps
5 |
6 | from flask_sqlalchemy import DeclarativeMeta
7 |
8 |
9 | def hook_func(name, fn):
10 | @wraps(fn)
11 | def wrapper(self, *args, **kwargs):
12 | for hook, a, kw in self.__class__._pre_hooks.get(name) or []:
13 | hook(self, *args, **kwargs)
14 |
15 | rv = fn(self, *args, **kwargs)
16 |
17 | # Attach return value for post hooks
18 | kwargs.update(dict(rv=rv))
19 |
20 | for hook, a, kw in self.__class__._post_hooks.get(name) or []:
21 | hook(self, *args, **kwargs)
22 |
23 | return rv
24 | return wrapper
25 |
26 |
27 | class HookMixinMeta(type):
28 | def __new__(cls, name, bases, attrs):
29 | hookable = []
30 | for key, value in attrs.items():
31 | # Disallow hooking methods which start with an underscore (allow __init__ etc. still)
32 | if key.startswith('_') and not key.startswith('__'):
33 | continue
34 | if callable(value):
35 | attrs[key] = hook_func(key, value)
36 | hookable.append(key)
37 | attrs['_hookable'] = hookable
38 |
39 | return super(HookMixinMeta, cls).__new__(cls, name, bases, attrs)
40 |
41 |
42 | class HookMixin(with_metaclass(HookMixinMeta, object)):
43 | _pre_hooks = {}
44 | _post_hooks = {}
45 | _hookable = []
46 |
47 | @classmethod
48 | def after(cls, method_name):
49 | assert method_name in cls._hookable, "'{0}' not a hookable method of '{1}'".format(method_name, cls.__name__)
50 |
51 | def outer(f, *args, **kwargs):
52 | cls._post_hooks.setdefault(method_name, []).append((f, args, kwargs))
53 | return f
54 | return outer
55 |
56 | @classmethod
57 | def before(cls, method_name):
58 | assert method_name in cls._hookable, "'{0}' not a hookable method of '{1}'".format(method_name, cls.__name__)
59 |
60 | def outer(f, *args, **kwargs):
61 | cls._pre_hooks.setdefault(method_name, []).append((f, args, kwargs))
62 | return f
63 | return outer
64 |
65 |
66 | class HookModelMeta(DeclarativeMeta, HookMixinMeta):
67 | pass
68 |
69 |
70 |
--------------------------------------------------------------------------------
/realms/lib/model.py:
--------------------------------------------------------------------------------
1 | from __future__ import absolute_import
2 |
3 | import json
4 | from datetime import datetime
5 |
6 | from sqlalchemy import not_, and_
7 |
8 | from realms import db
9 |
10 |
11 | class Model(db.Model):
12 | """Base SQLAlchemy Model for automatic serialization and
13 | deserialization of columns and nested relationships.
14 |
15 | Source: https://gist.github.com/alanhamlett/6604662
16 |
17 | Usage::
18 |
19 | >>> class User(Model):
20 | >>> id = db.Column(db.Integer(), primary_key=True)
21 | >>> email = db.Column(db.String(), index=True)
22 | >>> name = db.Column(db.String())
23 | >>> password = db.Column(db.String())
24 | >>> posts = db.relationship('Post', backref='user', lazy='dynamic')
25 | >>> ...
26 | >>> default_fields = ['email', 'name']
27 | >>> hidden_fields = ['password']
28 | >>> readonly_fields = ['email', 'password']
29 | >>>
30 | >>> class Post(Model):
31 | >>> id = db.Column(db.Integer(), primary_key=True)
32 | >>> user_id = db.Column(db.String(), db.ForeignKey('user.id'), nullable=False)
33 | >>> title = db.Column(db.String())
34 | >>> ...
35 | >>> default_fields = ['title']
36 | >>> readonly_fields = ['user_id']
37 | >>>
38 | >>> model = User(email='john@localhost')
39 | >>> db.session.add(model)
40 | >>> db.session.commit()
41 | >>>
42 | >>> # update name and create a new post
43 | >>> validated_input = {'name': 'John', 'posts': [{'title':'My First Post'}]}
44 | >>> model.set_columns(**validated_input)
45 | >>> db.session.commit()
46 | >>>
47 | >>> print(model.to_dict(show=['password', 'posts']))
48 | >>> {u'email': u'john@localhost', u'posts': [{u'id': 1, u'title': u'My First Post'}], u'name': u'John', u'id': 1}
49 | """
50 | __abstract__ = True
51 | # Stores changes made to this model's attributes. Can be retrieved
52 | # with model.changes
53 | _changes = {}
54 |
55 | def __init__(self, **kwargs):
56 | kwargs['_force'] = True
57 | self._set_columns(**kwargs)
58 |
59 | def filter_by(self, **kwargs):
60 | clauses = [key == value
61 | for key, value in kwargs.items()]
62 | return self.filter(and_(*clauses))
63 |
64 | def _set_columns(self, **kwargs):
65 | force = kwargs.get('_force')
66 |
67 | readonly = []
68 | if hasattr(self, 'readonly_fields'):
69 | readonly = self.readonly_fields
70 | if hasattr(self, 'hidden_fields'):
71 | readonly += self.hidden_fields
72 |
73 | readonly += [
74 | 'id',
75 | 'created',
76 | 'updated',
77 | 'modified',
78 | 'created_at',
79 | 'updated_at',
80 | 'modified_at',
81 | ]
82 |
83 | changes = {}
84 |
85 | columns = self.__table__.columns.keys()
86 | relationships = self.__mapper__.relationships.keys()
87 |
88 | for key in columns:
89 | allowed = True if force or key not in readonly else False
90 | exists = True if key in kwargs else False
91 | if allowed and exists:
92 | val = getattr(self, key)
93 | if val != kwargs[key]:
94 | changes[key] = {'old': val, 'new': kwargs[key]}
95 | setattr(self, key, kwargs[key])
96 |
97 | for rel in relationships:
98 | allowed = True if force or rel not in readonly else False
99 | exists = True if rel in kwargs else False
100 | if allowed and exists:
101 | is_list = self.__mapper__.relationships[rel].uselist
102 | if is_list:
103 | valid_ids = []
104 | query = getattr(self, rel)
105 | cls = self.__mapper__.relationships[rel].argument()
106 | for item in kwargs[rel]:
107 | if 'id' in item and query.filter_by(id=item['id']).limit(1).count() == 1:
108 | obj = cls.query.filter_by(id=item['id']).first()
109 | col_changes = obj.set_columns(**item)
110 | if col_changes:
111 | col_changes['id'] = str(item['id'])
112 | if rel in changes:
113 | changes[rel].append(col_changes)
114 | else:
115 | changes.update({rel: [col_changes]})
116 | valid_ids.append(str(item['id']))
117 | else:
118 | col = cls()
119 | col_changes = col.set_columns(**item)
120 | query.append(col)
121 | db.session.flush()
122 | if col_changes:
123 | col_changes['id'] = str(col.id)
124 | if rel in changes:
125 | changes[rel].append(col_changes)
126 | else:
127 | changes.update({rel: [col_changes]})
128 | valid_ids.append(str(col.id))
129 |
130 | # delete related rows that were not in kwargs[rel]
131 | for item in query.filter(not_(cls.id.in_(valid_ids))).all():
132 | col_changes = {
133 | 'id': str(item.id),
134 | 'deleted': True,
135 | }
136 | if rel in changes:
137 | changes[rel].append(col_changes)
138 | else:
139 | changes.update({rel: [col_changes]})
140 | db.session.delete(item)
141 |
142 | else:
143 | val = getattr(self, rel)
144 | if self.__mapper__.relationships[rel].query_class is not None:
145 | if val is not None:
146 | col_changes = val.set_columns(**kwargs[rel])
147 | if col_changes:
148 | changes.update({rel: col_changes})
149 | else:
150 | if val != kwargs[rel]:
151 | setattr(self, rel, kwargs[rel])
152 | changes[rel] = {'old': val, 'new': kwargs[rel]}
153 |
154 | return changes
155 |
156 | def set_columns(self, **kwargs):
157 | self._changes = self._set_columns(**kwargs)
158 | if 'modified' in self.__table__.columns:
159 | self.modified = datetime.utcnow()
160 | if 'updated' in self.__table__.columns:
161 | self.updated = datetime.utcnow()
162 | if 'modified_at' in self.__table__.columns:
163 | self.modified_at = datetime.utcnow()
164 | if 'updated_at' in self.__table__.columns:
165 | self.updated_at = datetime.utcnow()
166 | return self._changes
167 |
168 | def __repr__(self):
169 | if 'id' in self.__table__.columns.keys():
170 | return '{0}({1})'.format(self.__class__.__name__, self.id)
171 | data = {}
172 | for key in self.__table__.columns.keys():
173 | val = getattr(self, key)
174 | if type(val) is datetime:
175 | val = val.strftime('%Y-%m-%dT%H:%M:%SZ')
176 | data[key] = val
177 | return json.dumps(data, use_decimal=True)
178 |
179 | @property
180 | def changes(self):
181 | return self._changes
182 |
183 | def reset_changes(self):
184 | self._changes = {}
185 |
186 | def to_dict(self, show=None, hide=None, path=None, show_all=None):
187 | """ Return a dictionary representation of this model.
188 | """
189 |
190 | if not show:
191 | show = []
192 | if not hide:
193 | hide = []
194 | hidden = []
195 | if hasattr(self, 'hidden_fields'):
196 | hidden = self.hidden_fields
197 | default = []
198 | if hasattr(self, 'default_fields'):
199 | default = self.default_fields
200 |
201 | ret_data = {}
202 |
203 | if not path:
204 | path = self.__tablename__.lower()
205 | def prepend_path(item):
206 | item = item.lower()
207 | if item.split('.', 1)[0] == path:
208 | return item
209 | if len(item) == 0:
210 | return item
211 | if item[0] != '.':
212 | item = '.{0}'.format(item)
213 | item = '{0}{1}'.format(path, item)
214 | return item
215 | show[:] = [prepend_path(x) for x in show]
216 | hide[:] = [prepend_path(x) for x in hide]
217 |
218 | columns = self.__table__.columns.keys()
219 | relationships = self.__mapper__.relationships.keys()
220 | properties = dir(self)
221 |
222 | for key in columns:
223 | check = '{0}.{1}'.format(path, key)
224 | if check in hide or key in hidden:
225 | continue
226 | if show_all or key is 'id' or check in show or key in default:
227 | ret_data[key] = getattr(self, key)
228 |
229 | for key in relationships:
230 | check = '{0}.{1}'.format(path, key)
231 | if check in hide or key in hidden:
232 | continue
233 | if show_all or check in show or key in default:
234 | hide.append(check)
235 | is_list = self.__mapper__.relationships[key].uselist
236 | if is_list:
237 | ret_data[key] = []
238 | for item in getattr(self, key):
239 | ret_data[key].append(item.to_dict(
240 | show=show,
241 | hide=hide,
242 | path=('{0}.{1}'.format(path, key.lower())),
243 | show_all=show_all,
244 | ))
245 | else:
246 | if self.__mapper__.relationships[key].query_class is not None:
247 | ret_data[key] = getattr(self, key).to_dict(
248 | show=show,
249 | hide=hide,
250 | path=('{0}.{1}'.format(path, key.lower())),
251 | show_all=show_all,
252 | )
253 | else:
254 | ret_data[key] = getattr(self, key)
255 |
256 | for key in list(set(properties) - set(columns) - set(relationships)):
257 | if key.startswith('_'):
258 | continue
259 | check = '{0}.{1}'.format(path, key)
260 | if check in hide or key in hidden:
261 | continue
262 | if show_all or check in show or key in default:
263 | val = getattr(self, key)
264 | try:
265 | ret_data[key] = json.loads(json.dumps(val))
266 | except:
267 | pass
268 |
269 | return ret_data
270 |
271 | @classmethod
272 | def insert_or_update(cls, cond, data):
273 | obj = cls.query.filter_by(**cond).first()
274 | if obj:
275 | obj.set_columns(**data)
276 | else:
277 | data.update(cond)
278 | obj = cls(**data)
279 | db.session.add(obj)
280 | db.session.commit()
281 |
282 | def save(self):
283 | if self not in db.session:
284 | db.session.merge(self)
285 | db.session.commit()
286 |
287 | def delete(self):
288 | if self not in db.session:
289 | db.session.merge(self)
290 | db.session.delete(self)
291 | db.session.commit()
292 |
293 | @classmethod
294 | def query(cls):
295 | return db.session.query(cls)
296 |
297 | @classmethod
298 | def get_by_id(cls, id_):
299 | return cls.query().filter_by(id=id_).first()
300 |
--------------------------------------------------------------------------------
/realms/lib/test.py:
--------------------------------------------------------------------------------
1 | from __future__ import absolute_import
2 |
3 | import os
4 | import shutil
5 | import tempfile
6 |
7 | from flask_testing import TestCase
8 |
9 | from realms import create_app
10 | from realms.lib.util import random_string
11 | from realms.lib.flask_csrf_test_client import FlaskClient
12 |
13 |
14 | class BaseTest(TestCase):
15 |
16 | def create_app(self):
17 | self.tempdir = tempfile.mkdtemp()
18 | app = create_app()
19 | app.config['TESTING'] = True
20 | app.config['PRESERVE_CONTEXT_ON_EXCEPTION'] = False
21 | app.config['WIKI_PATH'] = os.path.join(self.tempdir, random_string(12))
22 | app.config['DB_URI'] = 'sqlite:///{0}/{1}.db'.format(self.tempdir, random_string(12))
23 | app.test_client_class = FlaskClient
24 | app.testing = True
25 | app.config.update(self.configure())
26 | return app
27 |
28 | def configure(self):
29 | return {}
30 |
31 | def tearDown(self):
32 | shutil.rmtree(self.tempdir)
33 |
--------------------------------------------------------------------------------
/realms/lib/util.py:
--------------------------------------------------------------------------------
1 | from __future__ import absolute_import
2 |
3 | import re
4 | import os
5 | import hashlib
6 | import json
7 | import string
8 | import random
9 | import sys
10 |
11 | import click
12 | from jinja2 import Template
13 |
14 |
15 | class AttrDict(dict):
16 | def __init__(self, *args, **kwargs):
17 | super(AttrDict, self).__init__(*args, **kwargs)
18 | self.__dict__ = self
19 |
20 |
21 | def random_string(size=6, chars=string.ascii_lowercase + string.ascii_uppercase + string.digits):
22 | return ''.join(random.choice(chars) for _ in range(size))
23 |
24 |
25 | def to_json(data):
26 | return json.dumps(to_dict(data), separators=(',', ':'))
27 |
28 |
29 | def to_dict(data):
30 |
31 | if not data:
32 | return AttrDict()
33 |
34 | def row2dict(row):
35 | d = AttrDict()
36 | for column in row.__table__.columns:
37 | d[column.name] = getattr(row, column.name)
38 |
39 | return d
40 |
41 | if isinstance(data, list):
42 | return [row2dict(x) for x in data]
43 | else:
44 | return row2dict(data)
45 |
46 |
47 | def mkdir_safe(path):
48 | if path and not(os.path.exists(path)):
49 | os.makedirs(path)
50 | return path
51 |
52 |
53 | def extract_path(file_path):
54 | if not file_path:
55 | return None
56 | last_slash = file_path.rindex("/")
57 | if last_slash:
58 | return file_path[0, last_slash]
59 |
60 |
61 | def clean_path(path):
62 | if path:
63 | if path[0] != '/':
64 | path.insert(0, '/')
65 | return re.sub(r"//+", '/', path)
66 |
67 |
68 | def extract_name(file_path):
69 | if file_path[-1] == "/":
70 | return None
71 | return os.path.basename(file_path)
72 |
73 |
74 | def remove_ext(path):
75 | return re.sub(r"\..*$", "", path)
76 |
77 |
78 | def clean_url(url):
79 | if not url:
80 | return url
81 |
82 | url = url.replace('%2F', '/')
83 | url = re.sub(r"^/+", "", url)
84 | return re.sub(r"//+", '/', url)
85 |
86 |
87 | def sanitize(s):
88 | """
89 | Remove leading/trailing whitespace (from all path components)
90 | Remove leading underscores and slashes "/"
91 | Convert spaces to dashes "-"
92 | Limit path components to 63 chars and total size to 436 chars
93 | """
94 | reserved_chars = "&$+,:;=?@#"
95 | unsafe_chars = "?<>[]{}|\^~%"
96 |
97 | # s = s.encode("utf8")
98 | s = re.sub(r"\s+", b" ", s)
99 | s = s.lstrip("_/ ")
100 | s = re.sub(r"[" + re.escape(reserved_chars) + "]", "", s)
101 | s = re.sub(r"[" + re.escape(unsafe_chars) + "]", "", s)
102 | return s
103 |
104 |
105 | def to_canonical(s):
106 | """
107 | Remove leading/trailing whitespace (from all path components)
108 | Remove leading underscores and slashes "/"
109 | Convert spaces to dashes "-"
110 | Limit path components to 63 chars and total size to 436 chars
111 | """
112 | s = sanitize(s)
113 | # Strip leading/trailing spaces from path components, replace internal spaces
114 | # with '-', and truncate to 63 characters.
115 | parts = (part.strip().replace(" ", "-")[:63] for part in s.split("/"))
116 |
117 | # Join any non-empty path components back together
118 | s = "/".join(filter(None, parts))
119 | s = s[:436]
120 | return s
121 |
122 |
123 | def cname_to_filename(cname):
124 | """ Convert canonical name to filename
125 |
126 | :param cname: Canonical name
127 | :return: str -- Filename
128 |
129 | """
130 | return sanitize(cname) + ".md"
131 |
132 |
133 | def filename_to_cname(filename):
134 | """Convert filename to canonical name.
135 |
136 | .. note::
137 |
138 | It's assumed filename is already canonical format
139 |
140 | """
141 | return os.path.splitext(filename)[0]
142 |
143 |
144 | def gravatar_url(email):
145 | email = hashlib.md5(email).hexdigest() if email else "default@realms.io"
146 | return "https://www.gravatar.com/avatar/" + email
147 |
148 |
149 | def in_virtualenv():
150 | return hasattr(sys, 'real_prefix')
151 |
152 |
153 | def in_vagrant():
154 | return os.path.isdir("/vagrant")
155 |
156 |
157 | def is_su():
158 | return os.geteuid() == 0
159 |
160 |
161 | def green(s):
162 | click.secho(s, fg='green')
163 |
164 |
165 | def yellow(s):
166 | click.secho(s, fg='yellow')
167 |
168 |
169 | def red(s):
170 | click.secho(s, fg='red')
171 |
172 |
173 | def upstart_script(user='root', app_dir=None, port=5000, workers=2, path=None):
174 | script = """
175 | limit nofile 65335 65335
176 |
177 | respawn
178 |
179 | description "Realms Wiki"
180 | author "scragg@gmail.com"
181 |
182 | chdir {{ app_dir }}
183 |
184 | {% if path %}
185 | env PATH={{ path }}:/usr/local/bin:/usr/bin:/bin:$PATH
186 | export PATH
187 | {% endif %}
188 |
189 | env LC_ALL=en_US.UTF-8
190 | env GEVENT_RESOLVER=ares
191 |
192 | export LC_ALL
193 | export GEVENT_RESOLVER
194 |
195 | setuid {{ user }}
196 | setgid {{ user }}
197 |
198 | start on runlevel [2345]
199 | stop on runlevel [!2345]
200 |
201 | respawn
202 |
203 | exec gunicorn \
204 | --name realms-wiki \
205 | --access-logfile - \
206 | --error-logfile - \
207 | --worker-class gevent \
208 | --workers {{ workers }} \
209 | --bind 0.0.0.0:{{ port }} \
210 | --user {{ user }} \
211 | --group {{ user }} \
212 | --chdir {{ app_dir }} \
213 | 'realms:create_app()'
214 |
215 | """
216 | template = Template(script)
217 | return template.render(user=user, app_dir=app_dir, port=port, workers=workers, path=path)
218 |
--------------------------------------------------------------------------------
/realms/modules/__init__.py:
--------------------------------------------------------------------------------
1 | from __future__ import absolute_import
2 |
--------------------------------------------------------------------------------
/realms/modules/auth/__init__.py:
--------------------------------------------------------------------------------
1 | from __future__ import absolute_import
2 |
3 | from flask import request, flash, redirect
4 | from flask_login import login_url
5 |
6 | from realms import login_manager
7 |
8 |
9 | modules = set()
10 |
11 |
12 | @login_manager.unauthorized_handler
13 | def unauthorized():
14 | if request.method == 'GET':
15 | flash('Please log in to access this page')
16 | return redirect(login_url('auth.login', request.url))
17 | else:
18 | return dict(error=True, message="Please log in for access."), 403
19 |
--------------------------------------------------------------------------------
/realms/modules/auth/ldap/__init__.py:
--------------------------------------------------------------------------------
1 | from __future__ import absolute_import
2 |
3 | import ssl
4 | import warnings
5 | from os.path import exists
6 |
7 | import ldap3.core.tls
8 |
9 | from realms.modules.auth.models import Auth
10 |
11 | Auth.register('ldap')
12 |
13 |
14 | VALIDATE_MAP = {
15 | 'NONE': ssl.CERT_NONE,
16 | 'REQUIRED': ssl.CERT_REQUIRED,
17 | 'OPTIONAL': ssl.CERT_OPTIONAL
18 | }
19 |
20 | VERSION_MAP = {
21 | 'TLS': 'TLS',
22 | 'SSLV23': 'SSLv23',
23 | 'TLSV1': 'TLSv1',
24 | 'TLSV1_1': 'TLSv1_1',
25 | 'TLSV1_2': 'TLSv1_2'
26 | }
27 |
28 |
29 | def init(app):
30 | try:
31 | ldap_config = app.config['LDAP']
32 | except KeyError:
33 | raise RuntimeError("Provide a LDAP configuration to enable LDAP authentication")
34 | if not isinstance(ldap_config, dict):
35 | raise RuntimeError("LDAP configuration must be a dict of options")
36 |
37 | if 'URI' not in ldap_config:
38 | raise RuntimeError("LDAP is required, but the LDAP URI has not been defined")
39 | if 'BIND_DN' not in ldap_config and 'USER_SEARCH' not in ldap_config:
40 | raise RuntimeError("For LDAP authentication, you need to provide BIND_DN and/or USER_SEARCH option")
41 |
42 | ldap_config['URI'] = ldap_config['URI'].lower()
43 |
44 | if ldap_config.get('USER_SEARCH', {}).get('filter'):
45 | if not ldap_config['USER_SEARCH']['filter'].startswith('('):
46 | ldap_config['USER_SEARCH']['filter'] = "({})".format(ldap_config['USER_SEARCH']['filter'])
47 |
48 | # compatibility with flask-ldap-login
49 | if ldap_config.get('OPTIONS', {}).get('OPT_PROTOCOL_VERSION') and not ldap_config.get('LDAP_PROTO_VERSION'):
50 | ldap_config['LDAP_PROTO_VERSION'] = ldap_config['OPTIONS']['OPT_PROTOCOL_VERSION']
51 |
52 | # default ldap protocol version = 3
53 | try:
54 | ldap_config['LDAP_PROTO_VERSION'] = int(ldap_config.get('LDAP_PROTO_VERSION', 3))
55 | except ValueError:
56 | raise RuntimeError("LDAP_PROTO_VERSION must be a integer")
57 |
58 | # Python < 2.7.9 has problems with TLS
59 | if ldap_config['START_TLS'] or ldap_config['URI'].startswith('ldaps://'):
60 | if not ldap3.core.tls.use_ssl_context:
61 | warnings.warn("The Python version is old and it does not perform TLS correctly. You should upgrade.",
62 | DeprecationWarning)
63 | if ldap_config.get('TLS_OPTIONS', {}).get('CLIENT_PRIVKEY_PASSWORD'):
64 | raise RuntimeError("The Python version is too old and does not support private keys with password")
65 |
66 | # check that TLS options, if provided, are really valid
67 | if ldap_config.get('TLS_OPTIONS', {}).get('VALIDATE'):
68 | try:
69 | ldap_config['TLS_OPTIONS']['VALIDATE'] = VALIDATE_MAP[ldap_config['TLS_OPTIONS']['VALIDATE'].upper()]
70 | except KeyError:
71 | raise RuntimeError("The 'VALIDATE' TLS option must be one of: {}".format(", ".join(VALIDATE_MAP.keys())))
72 |
73 | if ldap_config.get('TLS_OPTIONS', {}).get('VERSION'):
74 | try:
75 | ldap_config['TLS_OPTIONS']['VERSION'] = getattr(
76 | ssl,
77 | "PROTOCOL_{}".format(VERSION_MAP[ldap_config['TLS_OPTIONS']['VERSION'].upper()])
78 | )
79 | except KeyError:
80 | raise RuntimeError("The 'VERSION' TLS option must be one of: {}".format(", ".join(VERSION_MAP.keys())))
81 | except AttributeError:
82 | raise RuntimeError("The running Python does not support TLS protocol: {}".format(
83 | ldap_config['TLS_OPTIONS']['VERSION']
84 | ))
85 |
86 | if ldap_config.get('TLS_OPTIONS', {}).get('CA_CERTS_FILE'):
87 | if not exists(ldap_config['TLS_OPTIONS']['CA_CERTS_FILE']):
88 | raise RuntimeError("CA_CERTS_FILE '{}' does not exist".format(ldap_config['TLS_OPTIONS']['CA_CERTS_FILE']))
89 |
90 | if ldap_config.get('TLS_OPTIONS', {}).get('CLIENT_CERT_FILE'):
91 | if not exists(ldap_config['TLS_OPTIONS']['CLIENT_CERT_FILE']):
92 | raise RuntimeError(
93 | "CLIENT_CERT_FILE '{}' does not exist".format(ldap_config['TLS_OPTIONS']['CLIENT_CERT_FILE']))
94 |
95 | if ldap_config.get('TLS_OPTIONS', {}).get('CLIENT_PRIVKEY_FILE'):
96 | if not exists(ldap_config['TLS_OPTIONS']['CLIENT_PRIVKEY_FILE']):
97 | raise RuntimeError(
98 | "CLIENT_PRIVKEY_FILE '{}' does not exist".format(ldap_config['TLS_OPTIONS']['CLIENT_PRIVKEY_FILE']))
99 |
100 |
--------------------------------------------------------------------------------
/realms/modules/auth/ldap/forms.py:
--------------------------------------------------------------------------------
1 | from __future__ import absolute_import
2 |
3 | from flask_wtf import Form
4 | from wtforms import StringField, PasswordField, validators
5 |
6 |
7 | class LoginForm(Form):
8 | username = StringField('User ID', [validators.DataRequired()])
9 | password = PasswordField('Password', [validators.DataRequired()])
10 |
--------------------------------------------------------------------------------
/realms/modules/auth/ldap/models.py:
--------------------------------------------------------------------------------
1 | from __future__ import absolute_import
2 |
3 | import logging
4 | import ssl
5 |
6 | import ldap3
7 | from flask import render_template, current_app
8 | from flask_login import login_user, logout_user
9 |
10 | from realms.modules.auth.models import BaseUser
11 | from .forms import LoginForm
12 |
13 |
14 | class User(BaseUser):
15 | ldap_users = {}
16 | type = 'ldap'
17 |
18 | def __init__(self, userid, password, email=""):
19 | self.id = userid
20 | self.username = userid
21 | self.password = password
22 | self.email = email if email else userid
23 |
24 | def __repr__(self):
25 | return "User(userid='{}', username='{}',password='{}', email='{}')".format(
26 | self.id, self.username, self.password, self.email
27 | )
28 |
29 | @property
30 | def auth_token_id(self):
31 | return self.password
32 |
33 | @staticmethod
34 | def load_user(*args, **kwargs):
35 | return User.ldap_users.get(args[0])
36 |
37 | def save(self):
38 | self.ldap_users[self.id] = self
39 |
40 | @classmethod
41 | def get_by_userid(cls, userid):
42 | return cls.ldap_users.get(userid)
43 |
44 | @staticmethod
45 | def auth(userid, password):
46 | ldap_attrs = LdapConn(current_app.config['LDAP'], userid, password).check()
47 | if ldap_attrs is None:
48 | # something failed: authentication, connection, binding... check the logs
49 | return False
50 | user = User(userid, password)
51 | # add the required LDAP attributes to the user object
52 | for attr_name, attr_value in ldap_attrs.items():
53 | user.__setattr__(attr_name, attr_value)
54 |
55 | logging.getLogger("realms.auth.ldap").debug("Logged in: {}".format(repr(user)))
56 |
57 | user.save()
58 | login_user(user, remember=False)
59 | return user
60 |
61 | @classmethod
62 | def logout(cls):
63 | logout_user()
64 |
65 | @staticmethod
66 | def login_form():
67 | form = LoginForm()
68 | return render_template('auth/ldap/login.html', form=form)
69 |
70 |
71 | class LdapConn(object):
72 | def __init__(self, config, userid, password):
73 | self.config = config
74 | self.tls = None
75 | self.setup_tls_options()
76 | self.server = ldap3.Server(self.config['URI'], tls=self.tls)
77 | self.userid = userid
78 | self.password = password
79 | self.version = int(self.config['LDAP_PROTO_VERSION'])
80 | self.conn = None
81 |
82 | def check(self):
83 | if 'USER_SEARCH' in self.config:
84 | return self.bind_search()
85 | else:
86 | return self.direct_bind()
87 |
88 | def close(self):
89 | if self.conn:
90 | if self.conn.bound:
91 | self.conn.unbind()
92 |
93 | def setup_tls_options(self):
94 | if self.config['START_TLS'] or self.config['URI'].startswith('ldaps://'):
95 | # noinspection PyUnresolvedReferences
96 | self.tls = ldap3.Tls(
97 | local_certificate_file=self.config.get('TLS_OPTIONS', {}).get('CLIENT_CERT_FILE'),
98 | local_private_key_file=self.config.get('TLS_OPTIONS', {}).get('CLIENT_PRIVKEY_FILE'),
99 | local_private_key_password=self.config.get('TLS_OPTIONS', {}).get('CLIENT_PRIVKEY_PASSWORD'),
100 | validate=self.config.get('TLS_OPTIONS', {}).get('VALIDATE', ssl.CERT_REQUIRED),
101 | ca_certs_file=self.config.get('TLS_OPTIONS', {}).get('CA_CERTS_FILE'),
102 | version=self.config.get('TLS_OPTIONS', {}).get('VERSION', ssl.PROTOCOL_SSLv23)
103 | )
104 |
105 | def start_tls(self):
106 | assert(isinstance(self.conn, ldap3.Connection))
107 | if self.config['START_TLS']:
108 | logger = logging.getLogger("realms.auth.ldap")
109 | try:
110 | self.conn.open()
111 | except ldap3.LDAPSocketOpenError as ex:
112 | logger.exception("Error when connecting to LDAP server")
113 | return False
114 | try:
115 | return self.conn.start_tls()
116 | except ldap3.LDAPStartTLSError as ex:
117 | logger.exception("START_TLS error")
118 | return False
119 | except Exception as ex:
120 | logger.exception("START_TLS unexpectedly failed")
121 | return False
122 | return True
123 |
124 | def direct_bind(self):
125 | logger = logging.getLogger("realms.auth.ldap")
126 | bind_dn = self.config['BIND_DN'] % {'username': self.userid}
127 | self.conn = ldap3.Connection(
128 | self.server,
129 | user=bind_dn,
130 | password=self.password,
131 | version=self.version
132 | )
133 | if not self.start_tls():
134 | # START_TLS was required but it failed
135 | return None
136 | if not self.conn.bind():
137 | logger.info("Invalid credentials for '{}'".format(self.userid))
138 | return None
139 |
140 | logger.debug("Successfull BIND for '{}'".format(bind_dn))
141 |
142 | try:
143 | attrs = {}
144 | if self.conn.search(
145 | bind_dn, # base: the user DN
146 | "({})".format(bind_dn.split(",", 1)[0]), # filter: (uid=...)
147 | attributes=ldap3.ALL_ATTRIBUTES,
148 | search_scope=ldap3.BASE
149 | ):
150 | attrs = self._get_attributes(self.conn.response)
151 | return attrs
152 | finally:
153 | self.close()
154 |
155 | def bind_search(self):
156 | logger = logging.getLogger("realms.auth.ldap")
157 | bind_dn = self.config.get('BIND_DN') or None
158 | base_dn = self.config['USER_SEARCH']['base']
159 | filtr = self.config['USER_SEARCH']['filter'] % {'username': self.userid}
160 | scope = self.config['USER_SEARCH'].get('scope', 'subtree').lower().strip()
161 | if scope == "level":
162 | scope = ldap3.LEVEL
163 | elif scope == "base":
164 | scope = ldap3.BASE
165 | else:
166 | scope = ldap3.SUBTREE
167 |
168 | self.conn = ldap3.Connection(
169 | self.server,
170 | user=bind_dn,
171 | password=self.config.get('BIND_AUTH') or None,
172 | version=self.version
173 | )
174 |
175 | if not self.start_tls():
176 | return None
177 |
178 | if not self.conn.bind():
179 | logger.error("Can't bind to the LDAP server with provided credentials ({})'".format(bind_dn))
180 | return None
181 |
182 | logger.debug("Successfull BIND for '{}'".format(bind_dn))
183 |
184 | try:
185 | if not self.conn.search(base_dn, filtr, attributes=ldap3.ALL_ATTRIBUTES, search_scope=scope):
186 | logger.info("User was not found in LDAP: '{}'".format(self.userid))
187 | return None
188 | user_dn = self.conn.response[0]['dn']
189 | attrs = self._get_attributes(self.conn.response)
190 | # the user was found in LDAP, now let's try a BIND to check the password
191 | return attrs if self.conn.rebind(user=user_dn, password=self.password) else None
192 | finally:
193 | self.close()
194 |
195 | def _get_attributes(self, resp):
196 | attrs = {}
197 | ldap_attrs = resp[0]['attributes']
198 | for attrname, ldap_attrname in self.config.get('KEY_MAP', {}).items():
199 | if ldap_attrs.get(ldap_attrname):
200 | # ldap attributes are multi-valued, we only return the first one
201 | attrs[attrname] = ldap_attrs.get(ldap_attrname)[0]
202 | return attrs
203 |
--------------------------------------------------------------------------------
/realms/modules/auth/ldap/views.py:
--------------------------------------------------------------------------------
1 | from __future__ import absolute_import
2 |
3 | from flask import current_app, request, redirect, Blueprint, flash, url_for, session
4 |
5 | from .models import User
6 | from .forms import LoginForm
7 |
8 |
9 | blueprint = Blueprint('auth.ldap', __name__)
10 |
11 |
12 | @blueprint.route("/login/ldap", methods=['POST'])
13 | def login():
14 | form = LoginForm()
15 |
16 | if not form.validate():
17 | flash('Form invalid', 'warning')
18 | return redirect(url_for('auth.login'))
19 |
20 | if User.auth(request.form['username'], request.form['password']):
21 | return redirect(request.args.get('next') or url_for(current_app.config['ROOT_ENDPOINT']))
22 | else:
23 | flash('User ID or password is incorrect', 'warning')
24 | return redirect(url_for('auth.login'))
25 |
--------------------------------------------------------------------------------
/realms/modules/auth/local/__init__.py:
--------------------------------------------------------------------------------
1 | from __future__ import absolute_import
2 |
3 | from realms.modules.auth.models import Auth
4 |
5 | Auth.register('local')
6 |
--------------------------------------------------------------------------------
/realms/modules/auth/local/commands.py:
--------------------------------------------------------------------------------
1 | from __future__ import absolute_import
2 |
3 | import click
4 |
5 | from realms import cli_group
6 | from realms.lib.util import random_string
7 | from realms.lib.util import green, red, yellow
8 | from .models import User
9 |
10 |
11 | @cli_group(short_help="Auth Module")
12 | def cli():
13 | pass
14 |
15 |
16 | @cli.command()
17 | @click.argument('username')
18 | @click.argument('email')
19 | @click.option('--password', help='Leave blank for random password')
20 | def create_user(username, email, password):
21 | """ Create a new user
22 | """
23 | show_pass = not password
24 |
25 | if not password:
26 | password = random_string(12)
27 |
28 | if User.get_by_username(username):
29 | red("Username %s already exists" % username)
30 | return
31 |
32 | if User.get_by_email(email):
33 | red("Email %s already exists" % email)
34 | return
35 |
36 | User.create(username, email, password)
37 | green("User %s created" % username)
38 |
39 | if show_pass:
40 | yellow("Password: %s" % password)
41 |
--------------------------------------------------------------------------------
/realms/modules/auth/local/forms.py:
--------------------------------------------------------------------------------
1 | from __future__ import absolute_import
2 |
3 | from flask_wtf import Form
4 | from wtforms import StringField, PasswordField, validators
5 |
6 |
7 | class RegistrationForm(Form):
8 | username = StringField('Username', [validators.Length(min=4, max=25)])
9 | email = StringField('Email Address', [validators.Length(min=6, max=35)])
10 | password = PasswordField('Password', [
11 | validators.DataRequired(),
12 | validators.EqualTo('confirm', message='Passwords must match')
13 | ])
14 | confirm = PasswordField('Repeat Password')
15 |
16 |
17 | class LoginForm(Form):
18 | email = StringField('Email', [validators.DataRequired()])
19 | password = PasswordField('Password', [validators.DataRequired()])
20 |
--------------------------------------------------------------------------------
/realms/modules/auth/local/hooks.py:
--------------------------------------------------------------------------------
1 | from __future__ import absolute_import
2 |
3 | from flask import current_app
4 | from flask_wtf import RecaptchaField
5 |
6 | from .forms import RegistrationForm
7 |
8 |
9 | def before_first_request():
10 | if current_app.config['RECAPTCHA_ENABLE']:
11 | setattr(RegistrationForm, 'recaptcha', RecaptchaField("You Human?"))
12 |
--------------------------------------------------------------------------------
/realms/modules/auth/local/models.py:
--------------------------------------------------------------------------------
1 | from __future__ import absolute_import
2 |
3 | from hashlib import sha256
4 |
5 | from flask import current_app, render_template
6 | from flask_login import logout_user, login_user
7 | from itsdangerous import URLSafeSerializer, BadSignature
8 |
9 | from realms import login_manager, db
10 | from realms.lib.model import Model
11 | from realms.modules.auth.models import BaseUser
12 | from .forms import LoginForm
13 |
14 |
15 | @login_manager.token_loader
16 | def load_token(token):
17 | # Load unsafe because payload is needed for sig
18 | sig_okay, payload = URLSafeSerializer(current_app.config['SECRET_KEY']).loads_unsafe(token)
19 |
20 | if not payload:
21 | return None
22 |
23 | # User key *could* be stored in payload to avoid user lookup in db
24 | user = User.get_by_id(payload.get('id'))
25 |
26 | if not user:
27 | return None
28 |
29 | try:
30 | if BaseUser.signer(sha256(user.password).hexdigest()).loads(token):
31 | return user
32 | else:
33 | return None
34 | except BadSignature:
35 | return None
36 |
37 |
38 | class User(Model, BaseUser):
39 | __tablename__ = 'users'
40 | type = 'local'
41 | id = db.Column(db.Integer, primary_key=True)
42 | username = db.Column(db.String(128), unique=True)
43 | email = db.Column(db.String(128), unique=True)
44 | password = db.Column(db.String(60))
45 | admin = False
46 |
47 | hidden_fields = ['password']
48 | readonly_fields = ['email', 'password']
49 |
50 | @property
51 | def auth_token_id(self):
52 | return self.password
53 |
54 | @staticmethod
55 | def load_user(*args, **kwargs):
56 | return User.get_by_id(args[0])
57 |
58 | @staticmethod
59 | def create(username, email, password):
60 | u = User()
61 | u.username = username
62 | u.email = email
63 | u.password = User.hash_password(password)
64 | u.save()
65 |
66 | @staticmethod
67 | def get_by_username(username):
68 | return User.query().filter_by(username=username).first()
69 |
70 | @staticmethod
71 | def get_by_email(email):
72 | return User.query().filter_by(email=email).first()
73 |
74 | @staticmethod
75 | def signer(salt):
76 | return URLSafeSerializer(current_app.config['SECRET_KEY'] + salt)
77 |
78 | @staticmethod
79 | def auth(email, password):
80 | if '@' in email:
81 | user = User.get_by_email(email)
82 | else:
83 | user = User.get_by_username(email)
84 |
85 | if not user:
86 | # User doesn't exist
87 | return False
88 |
89 | if User.check_password(password, user.password):
90 | # Password is good, log in user
91 | login_user(user, remember=True)
92 | return user
93 | else:
94 | # Password check failed
95 | return False
96 |
97 | @classmethod
98 | def logout(cls):
99 | logout_user()
100 |
101 | @staticmethod
102 | def login_form():
103 | form = LoginForm()
104 | return render_template('auth/local/login.html', form=form)
105 |
--------------------------------------------------------------------------------
/realms/modules/auth/local/views.py:
--------------------------------------------------------------------------------
1 | from __future__ import absolute_import
2 |
3 | from flask import current_app, render_template, request, redirect, Blueprint, flash, url_for, session
4 |
5 | from .models import User
6 | from .forms import LoginForm, RegistrationForm
7 |
8 |
9 | blueprint = Blueprint('auth.local', __name__)
10 |
11 |
12 | @blueprint.route("/login/local", methods=['POST'])
13 | def login():
14 | form = LoginForm()
15 |
16 | if not form.validate():
17 | flash('Form invalid', 'warning')
18 | return redirect(url_for('auth.login'))
19 |
20 | if User.auth(request.form['email'], request.form['password']):
21 | return redirect(request.args.get("next") or url_for(current_app.config['ROOT_ENDPOINT']))
22 | else:
23 | flash('Email or Password Incorrect', 'warning')
24 | return redirect(url_for('auth.login'))
25 |
26 |
27 | @blueprint.route("/register", methods=['GET', 'POST'])
28 | def register():
29 |
30 | if not current_app.config['REGISTRATION_ENABLED']:
31 | flash("Registration is disabled")
32 | return redirect(url_for(current_app.config['ROOT_ENDPOINT']))
33 |
34 | form = RegistrationForm()
35 |
36 | if request.method == "POST":
37 |
38 | if not form.validate():
39 | flash('Form invalid', 'warning')
40 | return redirect(url_for('auth.local.register'))
41 |
42 | if User.get_by_username(request.form['username']):
43 | flash('Username is taken', 'warning')
44 | return redirect(url_for('auth.local.register'))
45 |
46 | if User.get_by_email(request.form['email']):
47 | flash('Email is taken', 'warning')
48 | return redirect(url_for('auth.local.register'))
49 |
50 | User.create(request.form['username'], request.form['email'], request.form['password'])
51 | User.auth(request.form['email'], request.form['password'])
52 |
53 | return redirect(session.get("next_url") or url_for(current_app.config['ROOT_ENDPOINT']))
54 |
55 | return render_template("auth/register.html", form=form)
56 |
--------------------------------------------------------------------------------
/realms/modules/auth/models.py:
--------------------------------------------------------------------------------
1 | from __future__ import absolute_import
2 |
3 | import importlib
4 | from hashlib import sha256
5 |
6 | from flask import current_app
7 | from flask_login import UserMixin, logout_user, AnonymousUserMixin
8 | from itsdangerous import URLSafeSerializer
9 | import bcrypt
10 |
11 | from realms import login_manager
12 | from realms.lib.util import gravatar_url
13 | from . import modules
14 |
15 |
16 | @login_manager.user_loader
17 | def load_user(auth_id):
18 | return Auth.load_user(auth_id)
19 |
20 |
21 | auth_users = {}
22 |
23 |
24 | class Auth(object):
25 |
26 | @staticmethod
27 | def register(module):
28 | modules.add(module)
29 |
30 | @staticmethod
31 | def get_auth_user(auth_type):
32 | mod = importlib.import_module('realms.modules.auth.{0}.models'.format(auth_type))
33 | return mod.User
34 |
35 | @staticmethod
36 | def load_user(auth_id):
37 | auth_type, user_id = auth_id.split("/")
38 | return Auth.get_auth_user(auth_type).load_user(user_id)
39 |
40 | @staticmethod
41 | def login_forms():
42 | forms = []
43 | for t in modules:
44 | form = Auth.get_auth_user(t).login_form()
45 | if form:
46 | forms.append(form)
47 | return "
".join(forms)
48 |
49 |
50 | class AnonUser(AnonymousUserMixin):
51 | username = 'Anon'
52 | email = ''
53 | admin = False
54 |
55 |
56 | class BaseUser(UserMixin):
57 | id = None
58 | email = None
59 | username = None
60 | type = 'base'
61 |
62 | def get_id(self):
63 | return unicode("{0}/{1}".format(self.type, self.id))
64 |
65 | def get_auth_token(self):
66 | key = sha256(self.auth_token_id).hexdigest()
67 | return BaseUser.signer(key).dumps(dict(id=self.id))
68 |
69 | @property
70 | def auth_token_id(self):
71 | raise NotImplementedError
72 |
73 | @property
74 | def avatar(self):
75 | return gravatar_url(self.email)
76 |
77 | @staticmethod
78 | def load_user(*args, **kwargs):
79 | raise NotImplementedError
80 |
81 | @staticmethod
82 | def signer(salt):
83 | return URLSafeSerializer(current_app.config['SECRET_KEY'] + salt)
84 |
85 | @staticmethod
86 | def hash_password(password):
87 | return bcrypt.hashpw(password.encode('utf-8'), bcrypt.gensalt(12))
88 |
89 | @staticmethod
90 | def check_password(password, hashed):
91 | return bcrypt.hashpw(password.encode('utf-8'), hashed.encode('utf-8')) == hashed
92 |
93 | @classmethod
94 | def logout(cls):
95 | logout_user()
96 |
97 | @staticmethod
98 | def login_form():
99 | pass
100 |
101 | login_manager.anonymous_user = AnonUser
102 |
--------------------------------------------------------------------------------
/realms/modules/auth/oauth/__init__.py:
--------------------------------------------------------------------------------
1 | from __future__ import absolute_import
2 |
3 | from realms.modules.auth.models import Auth
4 |
5 | Auth.register('oauth')
6 |
--------------------------------------------------------------------------------
/realms/modules/auth/oauth/models.py:
--------------------------------------------------------------------------------
1 | from __future__ import absolute_import
2 |
3 | from flask import session
4 | from flask_login import login_user
5 | from flask_oauthlib.client import OAuth
6 |
7 | from realms import config
8 | from realms.modules.auth.models import BaseUser
9 |
10 | config = config.conf
11 |
12 | oauth = OAuth()
13 |
14 | users = {}
15 |
16 | providers = {
17 | 'twitter': {
18 | 'oauth': dict(
19 | base_url='https://api.twitter.com/1.1/',
20 | request_token_url='https://api.twitter.com/oauth/request_token',
21 | access_token_url='https://api.twitter.com/oauth/access_token',
22 | authorize_url='https://api.twitter.com/oauth/authenticate',
23 | access_token_method='GET'),
24 | 'button': '',
25 | 'profile': None,
26 | 'field_map': {
27 | 'id': 'user_id',
28 | 'username': 'screen_name'
29 | },
30 | 'token_name': 'oauth_token'
31 | },
32 | 'github': {
33 | 'oauth': dict(
34 | request_token_params={'scope': 'user:email'},
35 | base_url='https://api.github.com/',
36 | request_token_url=None,
37 | access_token_method='POST',
38 | access_token_url='https://github.com/login/oauth/access_token',
39 | authorize_url='https://github.com/login/oauth/authorize'),
40 | 'button': ' ',
41 | 'profile': 'user',
42 | 'field_map': {
43 | 'id': 'id',
44 | 'username': 'login',
45 | 'email': lambda(data): data.get('email') or data['login'] + '@users.noreply.github.com'
46 | },
47 | 'token_name': 'access_token'
48 | },
49 | 'facebook': {
50 | 'oauth': dict(
51 | request_token_params={'scope': 'email'},
52 | base_url='https://graph.facebook.com',
53 | request_token_url=None,
54 | access_token_url='/oauth/access_token',
55 | access_token_method='GET',
56 | authorize_url='https://www.facebook.com/dialog/oauth'
57 | ),
58 | 'button': ' ',
59 | 'profile': '/me',
60 | 'field_map': {
61 | 'id': 'id',
62 | 'username': 'name',
63 | 'email': 'email'
64 | },
65 | 'token_name': 'access_token'
66 | },
67 | 'google': {
68 | 'oauth': dict(
69 | request_token_params={
70 | 'scope': 'https://www.googleapis.com/auth/userinfo.email'
71 | },
72 | base_url='https://www.googleapis.com/oauth2/v1/',
73 | request_token_url=None,
74 | access_token_method='POST',
75 | access_token_url='https://accounts.google.com/o/oauth2/token',
76 | authorize_url='https://accounts.google.com/o/oauth2/auth',
77 | ),
78 | 'button': ' ',
79 | 'profile': 'userinfo',
80 | 'field_map': {
81 | 'id': 'id',
82 | 'username': 'name',
83 | 'email': 'email'
84 | },
85 | 'token_name': 'access_token'
86 | }
87 | }
88 |
89 |
90 | class User(BaseUser):
91 | type = 'oauth'
92 | provider = None
93 |
94 | def __init__(self, provider, user_id, username=None, token=None, email=None):
95 | self.provider = provider
96 | self.username = username
97 | self.email = email
98 | self.id = user_id
99 | self.token = token
100 | self.auth_id = "{0}-{1}".format(provider, username)
101 |
102 | @property
103 | def auth_token_id(self):
104 | return self.token
105 |
106 | @staticmethod
107 | def load_user(*args, **kwargs):
108 | return User.get_by_id(args[0])
109 |
110 | @staticmethod
111 | def get_by_id(user_id):
112 | return users.get(user_id)
113 |
114 | @staticmethod
115 | def auth(provider, data, oauth_token):
116 | field_map = providers.get(provider).get('field_map')
117 | if not field_map:
118 | raise NotImplementedError
119 |
120 | def get_value(d, key):
121 | if isinstance(key, basestring):
122 | return d.get(key)
123 | elif callable(key):
124 | return key(d)
125 | # key should be list here
126 | val = d.get(key.pop(0))
127 | if len(key) == 0:
128 | # if empty we have our value
129 | return val
130 | # keep digging
131 | return get_value(val, key)
132 |
133 | fields = {}
134 | for k, v in field_map.items():
135 | fields[k] = get_value(data, v)
136 |
137 | user = User(provider, fields['id'], username=fields.get('username'), email=fields.get('email'),
138 | token=User.hash_password(oauth_token))
139 | users[user.auth_id] = user
140 |
141 | if user:
142 | login_user(user, remember=True)
143 | return True
144 | else:
145 | return False
146 |
147 | @classmethod
148 | def get_app(cls, provider):
149 | if oauth.remote_apps.get(provider):
150 | return oauth.remote_apps.get(provider)
151 | app = oauth.remote_app(
152 | provider,
153 | consumer_key=config.OAUTH.get(provider, {}).get('key'),
154 | consumer_secret=config.OAUTH.get(provider, {}).get(
155 | 'secret'),
156 | **providers[provider]['oauth'])
157 | app.tokengetter(lambda: session.get(provider + "_token"))
158 | return app
159 |
160 | @classmethod
161 | def get_provider_value(cls, provider, key):
162 | return providers.get(provider, {}).get(key)
163 |
164 | @classmethod
165 | def get_token(cls, provider, resp):
166 | return resp.get(cls.get_provider_value(provider, 'token_name'))
167 |
168 | def get_id(self):
169 | return unicode("{0}/{1}".format(self.type, self.auth_id))
170 |
171 | @staticmethod
172 | def login_form():
173 | buttons = []
174 | for name, val in providers.items():
175 | if not config.OAUTH.get(name, {}).get('key') or not config.OAUTH.get(name, {}).get('secret'):
176 | continue
177 | buttons.append(val.get('button'))
178 |
179 | return "Social Login " + " ".join(buttons)
180 |
--------------------------------------------------------------------------------
/realms/modules/auth/oauth/views.py:
--------------------------------------------------------------------------------
1 | from __future__ import absolute_import
2 |
3 | from flask import Blueprint, url_for, request, flash, redirect, session, current_app
4 | from .models import User
5 | from realms import config
6 |
7 | blueprint = Blueprint('auth.oauth', __name__)
8 | config = config.conf
9 |
10 |
11 | def oauth_failed(next_url):
12 | flash('You denied the request to sign in.')
13 | return redirect(next_url)
14 |
15 |
16 | @blueprint.route("/login/oauth/")
17 | def login(provider):
18 | return User.get_app(provider).authorize(callback=url_for('auth.oauth.callback', provider=provider, _external=True))
19 |
20 |
21 | @blueprint.route('/login/oauth//callback')
22 | def callback(provider):
23 | next_url = session.get('next_url') or url_for(current_app.config['ROOT_ENDPOINT'])
24 | try:
25 | remote_app = User.get_app(provider)
26 | resp = remote_app.authorized_response()
27 | if resp is None:
28 | flash('You denied the request to sign in.', 'error')
29 | flash('Reason: ' + request.args['error_reason'] +
30 | ' ' + request.args['error_description'], 'error')
31 | return redirect(next_url)
32 | except Exception as e:
33 | flash('Access denied: %s' % e.message)
34 | return redirect(next_url)
35 |
36 | oauth_token = resp.get(User.get_provider_value(provider, 'token_name'))
37 | session[provider + "_token"] = (oauth_token, '')
38 | profile = User.get_provider_value(provider, 'profile')
39 | data = remote_app.get(profile).data if profile else resp
40 |
41 | # Adding check to verify domain restriction this is hacky but works.
42 | # A proper implementation should be in flask_oauthlib but its not there
43 | # so we do it a hacky way like this
44 |
45 | restricted_domain = config.OAUTH.get(provider, {}).get('domain', None)
46 |
47 | # If the domain restriction is in place then we verify the domain
48 | # provided in config and oauth and check if both are some,
49 | # if not same we do not authenticate in our system
50 | if restricted_domain:
51 | if data['hd'] == restricted_domain:
52 | User.auth(provider, data, oauth_token)
53 | return redirect(next_url)
54 | else:
55 | flash('You are not authorized to sign in.', 'error')
56 | flash('Reason: Domain restriction in place, domain:"%s" is not allowed to sign in' % data['hd'])
57 | return redirect(next_url)
58 | # If no domain restriction is in place, just authenticate
59 | # user and create user
60 | else:
61 | User.auth(provider, data, oauth_token)
62 | return redirect(next_url)
63 |
--------------------------------------------------------------------------------
/realms/modules/auth/proxy/__init__.py:
--------------------------------------------------------------------------------
1 | from __future__ import absolute_import
2 |
3 | from realms.modules.auth.models import Auth
4 |
5 | Auth.register('proxy')
6 |
--------------------------------------------------------------------------------
/realms/modules/auth/proxy/hooks.py:
--------------------------------------------------------------------------------
1 | from __future__ import absolute_import
2 |
3 | import logging
4 |
5 | from flask import request, current_app
6 | from flask_login import current_user, logout_user
7 |
8 | from .models import User as ProxyUser
9 |
10 |
11 | logger = logging.getLogger("realms.auth")
12 |
13 |
14 | def before_request():
15 | header_name = current_app.config["AUTH_PROXY_HEADER_NAME"]
16 | remote_user = request.headers.get(header_name)
17 | if remote_user:
18 | if current_user.is_authenticated:
19 | if current_user.id == remote_user:
20 | return
21 | logger.info("login in realms and login by proxy are different: '{}'/'{}'".format(
22 | current_user.id, remote_user))
23 | logout_user()
24 | logger.info("User logged in by proxy as '{}'".format(remote_user))
25 | ProxyUser.do_login(remote_user)
26 |
--------------------------------------------------------------------------------
/realms/modules/auth/proxy/models.py:
--------------------------------------------------------------------------------
1 | from __future__ import absolute_import
2 |
3 | from flask_login import login_user
4 |
5 | from realms.modules.auth.models import BaseUser
6 |
7 |
8 | users = {}
9 |
10 |
11 | class User(BaseUser):
12 | type = 'proxy'
13 |
14 | def __init__(self, username, email='null@localhost.local', password="dummypassword"):
15 | self.id = username
16 | self.username = username
17 | self.email = email
18 | self.password = password
19 |
20 | @property
21 | def auth_token_id(self):
22 | return self.password
23 |
24 | @staticmethod
25 | def load_user(*args, **kwargs):
26 | return User.get_by_id(args[0])
27 |
28 | @staticmethod
29 | def get_by_id(user_id):
30 | return users.get(user_id)
31 |
32 | @staticmethod
33 | def login_form():
34 | return None
35 |
36 | @staticmethod
37 | def do_login(user_id):
38 | user = User(user_id)
39 | users[user_id] = user
40 | login_user(user, remember=True)
41 | return True
42 |
43 |
--------------------------------------------------------------------------------
/realms/modules/auth/templates/auth/ldap/login.html:
--------------------------------------------------------------------------------
1 | {% from 'macros.html' import render_form, render_field %}
2 | {% if config.get('AUTH_LOCAL_ENABLE') %}
3 |
4 | Login with LDAP
5 |
6 |
7 |
8 |
9 |
10 |
14 |
15 | {% call render_form(form, action_url=url_for('auth.ldap.login'), action_text='Login', btn_class='btn btn-primary') %}
16 | {{ render_field(form.username, placeholder='Username', type='text', required=1) }}
17 | {{ render_field(form.password, placeholder='Password', type='password', required=1) }}
18 | {% endcall %}
19 |
20 |
21 |
22 |
23 | {% else %}
24 | LDAP Login
25 | {% call render_form(form, action_url=url_for('auth.ldap.login'), action_text='Login', btn_class='btn btn-primary') %}
26 | {{ render_field(form.username, placeholder='Username', type='text', required=1) }}
27 | {{ render_field(form.password, placeholder='Password', type='password', required=1) }}
28 | {% endcall %}
29 | {% endif %}
--------------------------------------------------------------------------------
/realms/modules/auth/templates/auth/local/login.html:
--------------------------------------------------------------------------------
1 | {% from 'macros.html' import render_form, render_field %}
2 | {% call render_form(form, action_url=url_for('auth.local.login'), action_text='Login', btn_class='btn btn-primary') %}
3 | {{ render_field(form.email, placeholder='Email', type='text', required=1) }}
4 | {{ render_field(form.password, placeholder='Password', type='password', required=1) }}
5 | {% endcall %}
6 |
--------------------------------------------------------------------------------
/realms/modules/auth/templates/auth/login.html:
--------------------------------------------------------------------------------
1 | {% extends 'layout.html' %}
2 | {% block body %}
3 | {{ forms|safe }}
4 | {% endblock %}
5 |
--------------------------------------------------------------------------------
/realms/modules/auth/templates/auth/register.html:
--------------------------------------------------------------------------------
1 | {% extends 'layout.html' %}
2 | {% from 'macros.html' import render_form, render_field %}
3 | {% block body %}
4 | {% call render_form(form, action_url=url_for('auth.local.register'), action_text='Register', btn_class='btn btn-primary') %}
5 | {{ render_field(form.username, placeholder='Username', type='username', **{"required": 1, "data-parsley-type": "alphanum"}) }}
6 | {{ render_field(form.email, placeholder='Email', type='email', required=1) }}
7 | {{ render_field(form.password, placeholder='Password', type='password', **{"required": 1, "data-parsley-minlength": "6"}) }}
8 | {{ render_field(form.confirm, placeholder='Confirm Password', type='password', **{"required": 1, "data-parsley-minlength": "6"}) }}
9 | {% if config.RECAPTCHA_ENABLE %}
10 | {{ render_field(form.recaptcha) }}
11 | {% endif %}
12 | {% endcall %}
13 | {% endblock %}
--------------------------------------------------------------------------------
/realms/modules/auth/templates/auth/settings.html:
--------------------------------------------------------------------------------
1 | {% extends 'layout.html' %}
2 | {% from 'macros.html' import render_form, render_field %}
3 | {% block body %}
4 |
5 | {% endblock %}
6 |
--------------------------------------------------------------------------------
/realms/modules/auth/tests.py:
--------------------------------------------------------------------------------
1 | from __future__ import absolute_import
2 |
--------------------------------------------------------------------------------
/realms/modules/auth/views.py:
--------------------------------------------------------------------------------
1 | from __future__ import absolute_import
2 |
3 | from flask import current_app, render_template, request, redirect, Blueprint, flash, url_for, session
4 | from flask_login import logout_user, current_user
5 |
6 | from .models import Auth
7 |
8 |
9 | blueprint = Blueprint('auth', __name__, template_folder='templates')
10 |
11 |
12 | @blueprint.route("/login", methods=['GET', 'POST'])
13 | def login():
14 | next_url = request.args.get('next') or url_for(current_app.config['ROOT_ENDPOINT'])
15 | if current_user.is_authenticated:
16 | return redirect(next_url)
17 | session['next_url'] = next_url
18 | return render_template("auth/login.html", forms=Auth.login_forms())
19 |
20 |
21 | @blueprint.route("/logout")
22 | def logout():
23 | logout_user()
24 | flash("You are now logged out")
25 | return redirect(url_for(current_app.config['ROOT_ENDPOINT']))
26 |
27 |
28 | @blueprint.route("/settings", methods=['GET', 'POST'])
29 | def settings():
30 | return render_template("auth/settings.html")
31 |
--------------------------------------------------------------------------------
/realms/modules/search/__init__.py:
--------------------------------------------------------------------------------
1 | from __future__ import absolute_import
2 |
--------------------------------------------------------------------------------
/realms/modules/search/commands.py:
--------------------------------------------------------------------------------
1 | from __future__ import absolute_import
2 |
3 | import click
4 | from flask import current_app
5 |
6 | from realms import search, cli_group
7 | from realms.modules.wiki.models import Wiki
8 |
9 |
10 | @cli_group(short_help="Search Module")
11 | def cli():
12 | pass
13 |
14 |
15 | @cli.command()
16 | def rebuild_index():
17 | """ Rebuild search index
18 | """
19 | if current_app.config.get('SEARCH_TYPE') == 'simple':
20 | click.echo("Search type is simple, try using elasticsearch.")
21 | return
22 |
23 | # Wiki
24 | search.delete_index('wiki')
25 | wiki = Wiki(current_app.config['WIKI_PATH'])
26 | for entry in wiki.get_index():
27 | page = wiki.get_page(entry['name'])
28 | if not page:
29 | # Some non-markdown files may have issues
30 | continue
31 | # TODO add email?
32 | # TODO I have concens about indexing the commit info from latest revision, see #148
33 | info = next(page.history)
34 | body = dict(name=page.name,
35 | content=page.data,
36 | message=info['message'],
37 | username=info['author'],
38 | updated_on=entry['mtime'],
39 | created_on=entry['ctime'])
40 | search.index_wiki(page.name, body)
41 |
--------------------------------------------------------------------------------
/realms/modules/search/hooks.py:
--------------------------------------------------------------------------------
1 | from __future__ import absolute_import
2 |
3 | from realms import search
4 | from realms.modules.wiki.models import WikiPage
5 |
6 |
7 | @WikiPage.after('write')
8 | def wiki_write_page(page, content, message=None, username=None, email=None, **kwargs):
9 |
10 | if not hasattr(search, 'index_wiki'):
11 | # using simple search or none
12 | return
13 |
14 | body = dict(name=page.name,
15 | content=content,
16 | message=message,
17 | email=email,
18 | username=username)
19 | return search.index_wiki(page.name, body)
20 |
21 |
22 | @WikiPage.before('rename')
23 | def wiki_rename_page_del(page, *args, **kwargs):
24 |
25 | if not hasattr(search, 'index_wiki'):
26 | return
27 |
28 | return search.delete_wiki(page.name)
29 |
30 |
31 | @WikiPage.after('rename')
32 | def wiki_rename_page_add(page, new_name, *args, **kwargs):
33 | wiki_write_page(page, page.data, *args, **kwargs)
34 |
35 |
36 | @WikiPage.after('delete')
37 | def wiki_delete_page(page, *args, **kwargs):
38 |
39 | if not hasattr(search, 'index_wiki'):
40 | return
41 |
42 | return search.delete_wiki(page.name)
43 |
--------------------------------------------------------------------------------
/realms/modules/search/models.py:
--------------------------------------------------------------------------------
1 | from __future__ import absolute_import
2 |
3 | import re
4 | import sys
5 |
6 | from flask import g, current_app
7 |
8 | from realms.lib.util import filename_to_cname
9 |
10 |
11 | def simple(app):
12 | return SimpleSearch()
13 |
14 |
15 | def whoosh(app):
16 | return WhooshSearch(app.config['WHOOSH_INDEX'], app.config['WHOOSH_LANGUAGE'])
17 |
18 |
19 | def elasticsearch(app):
20 | from flask_elastic import Elastic
21 | fields = app.config.get('ELASTICSEARCH_FIELDS')
22 | return ElasticSearch(Elastic(app), fields)
23 |
24 |
25 | class Search(object):
26 | def __init__(self, app=None):
27 | if app is not None:
28 | self.init_app(app)
29 |
30 | def init_app(self, app):
31 | search_obj = globals()[app.config['SEARCH_TYPE']]
32 | app.extensions['search'] = search_obj(app)
33 |
34 | def __getattr__(self, item):
35 | return getattr(current_app.extensions['search'], item)
36 |
37 |
38 | class BaseSearch():
39 | pass
40 |
41 |
42 | class SimpleSearch(BaseSearch):
43 | def wiki(self, query):
44 | res = []
45 | for entry in g.current_wiki.get_index():
46 | name = filename_to_cname(entry['name'])
47 | name = re.sub(r"//+", '/', name)
48 | if set(query.split()).intersection(name.replace('/', '-').split('-')):
49 | page = g.current_wiki.get_page(name)
50 |
51 | # this can be None, not sure how
52 | if page:
53 | res.append(dict(name=name, content=page.data))
54 | return res
55 |
56 | def users(self, query):
57 | pass
58 |
59 |
60 | class WhooshSearch(BaseSearch):
61 | def __init__(self, index_path, language):
62 | from whoosh import index as whoosh_index
63 | from whoosh.fields import Schema, TEXT, ID
64 | from whoosh import qparser
65 | from whoosh.highlight import UppercaseFormatter
66 | from whoosh.analysis import SimpleAnalyzer, LanguageAnalyzer
67 | from whoosh.lang import has_stemmer, has_stopwords
68 | import os
69 |
70 | if not has_stemmer(language) or not has_stopwords(language):
71 | # TODO Display a warning?
72 | analyzer = SimpleAnalyzer()
73 | else:
74 | analyzer = LanguageAnalyzer(language)
75 |
76 | self.schema = Schema(path=ID(unique=True, stored=True), body=TEXT(analyzer=analyzer))
77 | self.formatter = UppercaseFormatter()
78 |
79 | self.index_path = index_path
80 |
81 | if not os.path.exists(index_path):
82 | try:
83 | os.mkdir(index_path)
84 | except OSError as e:
85 | sys.exit("Error creating Whoosh index: %s" % e)
86 |
87 | if whoosh_index.exists_in(index_path):
88 | try:
89 | self.search_index = whoosh_index.open_dir(index_path)
90 | except whoosh_index.IndexError as e:
91 | sys.exit("Error opening whoosh index: {0}".format(e))
92 | else:
93 | self.search_index = whoosh_index.create_in(index_path, self.schema)
94 |
95 | self.query_parser = qparser.MultifieldParser(["body", "path"], schema=self.schema)
96 | self.query_parser.add_plugin(qparser.FuzzyTermPlugin())
97 |
98 | def index(self, index, doc_type, id_=None, body=None):
99 | writer = self.search_index.writer()
100 | writer.update_document(path=id_.decode("utf-8"), body=body["content"].decode("utf-8"))
101 | writer.commit()
102 |
103 | def delete(self, id_):
104 | with self.search_index.searcher() as s:
105 | doc_num = s.document_number(path=id_.decode("utf-8"))
106 | writer = self.search_index.writer()
107 | writer.delete_document(doc_num)
108 | writer.commit()
109 |
110 | def index_wiki(self, name, body):
111 | self.index('wiki', 'page', id_=name, body=body)
112 |
113 | def delete_wiki(self, name):
114 | self.delete(id_=name)
115 |
116 | def delete_index(self, index):
117 | from whoosh import index as whoosh_index
118 | self.search_index.close()
119 | self.search_index = whoosh_index.create_in(self.index_path, schema=self.schema)
120 |
121 | def wiki(self, query):
122 | if not query:
123 | return []
124 |
125 | q = self.query_parser.parse(query)
126 |
127 | with self.search_index.searcher() as s:
128 | results = s.search(q)
129 |
130 | results.formatter = self.formatter
131 |
132 | res = []
133 | for hit in results:
134 | name = hit["path"]
135 | page_data = g.current_wiki.get_page(name).data.decode("utf-8")
136 | content = hit.highlights('body', text=page_data)
137 |
138 | res.append(dict(name=name, content=content))
139 |
140 | return res
141 |
142 | def users(self, query):
143 | pass
144 |
145 |
146 | class ElasticSearch(BaseSearch):
147 | def __init__(self, elastic, fields):
148 | self.elastic = elastic
149 | self.fields = fields
150 |
151 | def index(self, index, doc_type, id_=None, body=None):
152 | return self.elastic.index(index=index, doc_type=doc_type, id=id_, body=body)
153 |
154 | def delete(self, index, doc_type, id_):
155 | return self.elastic.delete(index=index, doc_type=doc_type, id=id_)
156 |
157 | def index_wiki(self, name, body):
158 | self.index('wiki', 'page', id_=name, body=body)
159 |
160 | def delete_wiki(self, name):
161 | self.delete('wiki', 'page', id_=name)
162 |
163 | def delete_index(self, index):
164 | return self.elastic.indices.delete(index=index, ignore=[400, 404])
165 |
166 | def wiki(self, query):
167 | if not query:
168 | return []
169 |
170 | res = self.elastic.search(index='wiki', body={"query": {
171 | "multi_match": {
172 | "query": query,
173 | "fields": self.fields
174 | }}})
175 |
176 | return [hit["_source"] for hit in res['hits']['hits']]
177 |
178 | def users(self, query):
179 | pass
180 |
--------------------------------------------------------------------------------
/realms/modules/search/templates/search/search.html:
--------------------------------------------------------------------------------
1 | {% extends 'layout.html' %}
2 | {% block body %}
3 | {% if results %}
4 | Results for {{ request.args.get('q') }}
5 |
15 | {% else %}
16 | No results found for {{ request.args.get('q') }}
17 | {% endif %}
18 | {% endblock %}
19 |
--------------------------------------------------------------------------------
/realms/modules/search/views.py:
--------------------------------------------------------------------------------
1 | from __future__ import absolute_import
2 |
3 | from flask import render_template, request, Blueprint, current_app
4 | from flask_login import current_user
5 |
6 | from realms import search as search_engine
7 |
8 |
9 | blueprint = Blueprint('search', __name__, template_folder='templates')
10 |
11 |
12 | @blueprint.route('/_search')
13 | def search():
14 | if current_app.config.get('PRIVATE_WIKI') and current_user.is_anonymous:
15 | return current_app.login_manager.unauthorized()
16 |
17 | results = search_engine.wiki(request.args.get('q'))
18 | return render_template('search/search.html', results=results)
19 |
--------------------------------------------------------------------------------
/realms/modules/wiki/__init__.py:
--------------------------------------------------------------------------------
1 | from __future__ import absolute_import
2 |
3 | import os
4 | import sys
5 |
6 | from .models import Wiki
7 |
8 |
9 | def init(app):
10 | # Init Wiki
11 | Wiki(app.config['WIKI_PATH'])
12 |
13 | # Check paths
14 | for mode in [os.W_OK, os.R_OK]:
15 | for dir_ in [app.config['WIKI_PATH'], os.path.join(app.config['WIKI_PATH'], '.git')]:
16 | if not os.access(dir_, mode):
17 | sys.exit('Read and write access to WIKI_PATH is required (%s)' % dir_)
18 |
--------------------------------------------------------------------------------
/realms/modules/wiki/assets.py:
--------------------------------------------------------------------------------
1 | from __future__ import absolute_import
2 |
3 | from realms import assets
4 |
5 | assets.register('editor.js',
6 | 'vendor/store-js/store.js',
7 | 'vendor/bootbox/bootbox.js',
8 | 'vendor/ace-builds/src/ace.js',
9 | 'vendor/ace-builds/src/mode-markdown.js',
10 | 'vendor/ace-builds/src/ext-keybinding_menu.js',
11 | 'vendor/keymaster/keymaster.js',
12 | 'wiki/js/aced.js')
13 |
--------------------------------------------------------------------------------
/realms/modules/wiki/hooks.py:
--------------------------------------------------------------------------------
1 | from __future__ import absolute_import
2 |
3 | from flask import g, current_app
4 |
5 | from .models import Wiki
6 |
7 |
8 | def before_request():
9 | g.current_wiki = Wiki(current_app.config['WIKI_PATH'])
10 |
--------------------------------------------------------------------------------
/realms/modules/wiki/models.py:
--------------------------------------------------------------------------------
1 | from __future__ import absolute_import
2 |
3 | import os
4 | import posixpath
5 | import re
6 |
7 | import ghdiff
8 | import yaml
9 | from dulwich.object_store import tree_lookup_path
10 | from dulwich.repo import Repo, NotGitRepository
11 | from six import text_type
12 |
13 | from realms import cache
14 | from realms.lib.hook import HookMixin
15 | from realms.lib.util import cname_to_filename, filename_to_cname
16 |
17 |
18 | class PageNotFound(Exception):
19 | pass
20 |
21 |
22 | class Wiki(HookMixin):
23 | path = None
24 | base_path = '/'
25 | default_ref = 'master'
26 | default_committer_name = 'Anon'
27 | default_committer_email = 'anon@anon.anon'
28 | index_page = 'home'
29 | repo = None
30 |
31 | def __init__(self, path):
32 | try:
33 | self.repo = Repo(path)
34 | except NotGitRepository:
35 | self.repo = Repo.init(path, mkdir=True)
36 | # TODO add first commit here
37 |
38 | self.path = path
39 |
40 | def __repr__(self):
41 | return "Wiki: {0}".format(self.path)
42 |
43 | def commit(self, name, email, message, files):
44 | """Commit to the underlying git repo.
45 |
46 | :param name: Committer name
47 | :param email: Committer email
48 | :param message: Commit message
49 | :param files: list of file names that will be staged for commit
50 | :return:
51 | """
52 | if isinstance(name, text_type):
53 | name = name.encode('utf-8')
54 | if isinstance(email, text_type):
55 | email = email.encode('utf-8')
56 | if isinstance(message, text_type):
57 | message = message.encode('utf-8')
58 | author = committer = "{0} <{1}>".format(name, email).encode()
59 | self.repo.stage(files)
60 | return self.repo.do_commit(message=message,
61 | committer=committer,
62 | author=author)
63 |
64 | def get_page(self, name, sha='HEAD'):
65 | """Get page data, partials, commit info.
66 |
67 | :param name: Name of page.
68 | :param sha: Commit sha.
69 | :return: dict
70 |
71 | """
72 | return WikiPage(name, self, sha=sha)
73 |
74 | def get_index(self):
75 | """Get repo index of head.
76 |
77 | :return: list -- List of dicts
78 |
79 | """
80 | rv = []
81 | index = self.repo.open_index()
82 | for name in index:
83 | rv.append(dict(name=filename_to_cname(name),
84 | filename=name,
85 | ctime=index[name].ctime[0],
86 | mtime=index[name].mtime[0],
87 | sha=index[name].sha,
88 | size=index[name].size))
89 |
90 | return rv
91 |
92 |
93 | class WikiPage(HookMixin):
94 | def __init__(self, name, wiki, sha='HEAD'):
95 | self.name = name
96 | self.filename = cname_to_filename(name)
97 | self.sha = sha.encode('latin-1')
98 | self.wiki = wiki
99 |
100 | @property
101 | def data(self):
102 | cache_key = self._cache_key('data')
103 | cached = cache.get(cache_key)
104 | if cached:
105 | return cached
106 |
107 | mode, sha = tree_lookup_path(self.wiki.repo.get_object, self.wiki.repo[self.sha].tree, self.filename.encode())
108 | data = self.wiki.repo[sha].data
109 | cache.set(cache_key, data)
110 | return data
111 |
112 | @property
113 | def history(self):
114 | """Get page history.
115 |
116 | History can take a long time to generate for repositories with many commits.
117 | This returns an iterator to avoid having to load them all at once, and caches
118 | as it goes.
119 |
120 | :return: iter -- Iterator over dicts
121 |
122 | """
123 | cache_head = []
124 | cache_tail = cache.get(self._cache_key('history')) or [{'_cache_missing': True}]
125 | while True:
126 | if not cache_tail:
127 | return
128 | index = 0
129 | for index, cached_rev in enumerate(cache_tail):
130 | if cached_rev.get("_cache_missing"):
131 | break
132 | else:
133 | cache_head.append(cached_rev)
134 | yield cached_rev
135 | cache_tail = cache_tail[index+1:]
136 |
137 | start_sha = cached_rev.get('sha')
138 | end_sha = cache_tail[0].get('sha') if cache_tail else None
139 | for rev in self._iter_revs(start_sha=start_sha, end_sha=end_sha, filename=cached_rev.get('filename')):
140 | cache_head.append(rev)
141 | placeholder = {
142 | '_cache_missing': True,
143 | 'sha': rev['sha'],
144 | 'filename': rev['new_filename']
145 | }
146 | cache.set(self._cache_key('history'), cache_head + [placeholder] + cache_tail)
147 | yield rev
148 | cache.set(self._cache_key('history'), cache_head + cache_tail)
149 |
150 | def _iter_revs(self, start_sha=None, end_sha=None, filename=None):
151 | if end_sha:
152 | end_sha = [end_sha]
153 | if not len(self.wiki.repo.open_index()):
154 | # Index is empty, no commits
155 | return
156 | filename = filename or self.filename
157 | filename = filename.encode('utf-8')
158 | walker = iter(self.wiki.repo.get_walker(paths=[filename],
159 | include=start_sha,
160 | exclude=end_sha,
161 | follow=True))
162 | if start_sha:
163 | # If we are not starting from HEAD, we already have the start commit
164 | next(walker)
165 | filename = self.filename
166 | for entry in walker:
167 | change_type = None
168 | for change in entry.changes():
169 | if change.new.path == filename:
170 | filename = change.old.path
171 | change_type = change.type
172 | break
173 |
174 | author_name, author_email = entry.commit.author.rstrip(b'>').split(b'<')
175 | r = dict(author=author_name.strip(),
176 | author_email=author_email,
177 | time=entry.commit.author_time,
178 | message=entry.commit.message,
179 | sha=entry.commit.id,
180 | type=change_type,
181 | new_filename=change.new.path,
182 | old_filename=change.old.path)
183 | yield r
184 |
185 | @property
186 | def history_cache(self):
187 | """Get info about the history cache.
188 |
189 | :return: tuple -- (cached items, cache complete?)
190 | """
191 | cached_revs = cache.get(self._cache_key('history'))
192 | if not cached_revs:
193 | return 0, False
194 | elif any(rev.get('_cache_missing') for rev in cached_revs):
195 | return len(cached_revs) - 1, False
196 | return len(cached_revs), True
197 |
198 | @property
199 | def imports(self):
200 | """Names"""
201 | meta = self._get_meta(self.data) or {}
202 | return meta.get('import', [])
203 |
204 | @staticmethod
205 | def _get_meta(content):
206 | """Get metadata from page if any.
207 |
208 | :param content: Page content
209 | :return: dict
210 |
211 | """
212 | if not content.startswith(b"---"):
213 | return None
214 |
215 | meta_end = re.search("\n(\.{3}|\-{3})", content)
216 |
217 | if not meta_end:
218 | return None
219 |
220 | try:
221 | return yaml.safe_load(content[0:meta_end.start()])
222 | except Exception as e:
223 | return {'error': e.message}
224 |
225 | def _cache_key(self, property):
226 | return 'page/{0}[{1}].{2}'.format(self.name, self.sha, property)
227 |
228 | def _get_user(self, username, email):
229 | if not username:
230 | username = self.wiki.default_committer_name
231 |
232 | if not email:
233 | email = self.wiki.default_committer_email
234 |
235 | return username, email
236 |
237 | def _invalidate_cache(self, save_history=None):
238 | cache.delete(self._cache_key('data'))
239 | if save_history:
240 | if not save_history[0].get('_cache_missing'):
241 | save_history = [{'_cache_missing': True}] + save_history
242 | cache.set(self._cache_key('history'), save_history)
243 | else:
244 | cache.delete(self._cache_key('history'))
245 |
246 | def delete(self, username=None, email=None, message=None):
247 | """Delete page.
248 | :param username: Committer name
249 | :param email: Committer email
250 | :return: str -- Commit sha1
251 |
252 | """
253 | username, email = self._get_user(username, email)
254 |
255 | if not message:
256 | message = "Deleted %s" % self.name
257 |
258 | os.remove(os.path.join(self.wiki.path, self.filename))
259 | commit = self.wiki.commit(name=username,
260 | email=email,
261 | message=message,
262 | files=[self.filename])
263 | self._invalidate_cache()
264 | return commit
265 |
266 | def rename(self, new_name, username=None, email=None, message=None):
267 | """Rename page.
268 |
269 | :param new_name: New name of page.
270 | :param username: Committer name
271 | :param email: Committer email
272 | :return: str -- Commit sha1
273 |
274 | """
275 | assert self.sha == 'HEAD'
276 | old_filename, new_filename = self.filename, cname_to_filename(new_name)
277 | if old_filename not in self.wiki.repo.open_index():
278 | # old doesn't exist
279 | return None
280 | elif old_filename == new_filename:
281 | return None
282 | else:
283 | # file is being overwritten, but that is ok, it's git!
284 | pass
285 |
286 | username, email = self._get_user(username, email)
287 |
288 | if not message:
289 | message = "Moved {0} to {1}".format(self.name, new_name)
290 |
291 | os.rename(os.path.join(self.wiki.path, old_filename), os.path.join(self.wiki.path, new_filename))
292 | commit = self.wiki.commit(name=username,
293 | email=email,
294 | message=message,
295 | files=[old_filename, new_filename])
296 |
297 | old_history = cache.get(self._cache_key('history'))
298 | self._invalidate_cache()
299 | self.name = new_name
300 | self.filename = new_filename
301 | # We need to clear the cache for the new name as well as the old
302 | self._invalidate_cache(save_history=old_history)
303 |
304 | return commit
305 |
306 | def write(self, content, message=None, username=None, email=None):
307 | """Write page to git repo
308 |
309 | :param content: Content of page.
310 | :param message: Commit message.
311 | :param username: Commit Name.
312 | :param email: Commit Email.
313 | :return: Git commit sha1.
314 | """
315 | assert self.sha == b'HEAD'
316 | dirname = posixpath.join(self.wiki.path, posixpath.dirname(self.filename))
317 |
318 | if not os.path.exists(dirname):
319 | os.makedirs(dirname)
320 |
321 | with open(self.wiki.path + "/" + self.filename, 'w') as f:
322 | f.write(content)
323 |
324 | if not message:
325 | message = "Updated %s" % self.name
326 |
327 | username, email = self._get_user(username, email)
328 |
329 | ret = self.wiki.commit(name=username,
330 | email=email,
331 | message=message,
332 | files=[self.filename])
333 |
334 | old_history = cache.get(self._cache_key('history'))
335 | self._invalidate_cache(save_history=old_history)
336 | return ret
337 |
338 | def revert(self, commit_sha, message, username, email):
339 | """Revert page to passed commit sha1
340 |
341 | :param commit_sha: Commit Sha1 to revert to.
342 | :param message: Commit message.
343 | :param username: Committer name.
344 | :param email: Committer email.
345 | :return: Git commit sha1
346 |
347 | """
348 | assert self.sha == 'HEAD'
349 | new_page = self.wiki.get_page(self.name, commit_sha)
350 | if not new_page:
351 | raise PageNotFound('Commit not found')
352 |
353 | if not message:
354 | message = "Revert '{0}' to {1}".format(self.name, commit_sha[:7])
355 |
356 | return self.write(new_page.data, message=message, username=username, email=email)
357 |
358 | def compare(self, old_sha):
359 | """Compare two revisions of the same page.
360 |
361 | :param old_sha: Older sha.
362 | :return: str - Raw markup with styles
363 |
364 | """
365 |
366 | # TODO: This could be effectively done in the browser
367 | old = self.wiki.get_page(self.name, sha=old_sha)
368 | return ghdiff.diff(old.data, self.data)
369 |
370 | def __nonzero__(self):
371 | # Verify this file is in the tree for the given commit sha
372 | try:
373 | tree_lookup_path(self.wiki.repo.get_object, self.wiki.repo[self.sha].tree, self.filename)
374 | except KeyError:
375 | # We'll get a KeyError if self.sha isn't in the repo, or if self.filename isn't in the tree of our commit
376 | return False
377 | return True
378 |
--------------------------------------------------------------------------------
/realms/modules/wiki/static/js/aced.js:
--------------------------------------------------------------------------------
1 | function Aced(settings) {
2 | var id,
3 | options,
4 | editor,
5 | element,
6 | preview,
7 | previewWrapper,
8 | profile,
9 | autoInterval,
10 | themes,
11 | themeSelect,
12 | loadedThemes = {};
13 |
14 | settings = settings || {};
15 |
16 | options = {
17 | sanitize: true,
18 | preview: null,
19 | editor: null,
20 | theme: 'idle_fingers',
21 | themePath: '/static/vendor/ace-builds/src',
22 | mode: 'markdown',
23 | autoSave: true,
24 | autoSaveInterval: 5000,
25 | syncPreview: false,
26 | keyMaster: false,
27 | submit: function(data){ alert(data); },
28 | showButtonBar: false,
29 | themeSelect: null,
30 | submitBtn: null,
31 | renderer: null,
32 | info: null
33 | };
34 |
35 | themes = {
36 | chrome: "Chrome",
37 | clouds: "Clouds",
38 | clouds_midnight: "Clouds Midnight",
39 | cobalt: "Cobalt",
40 | crimson_editor: "Crimson Editor",
41 | dawn: "Dawn",
42 | dreamweaver: "Dreamweaver",
43 | eclipse: "Eclipse",
44 | idle_fingers: "idleFingers",
45 | kr_theme: "krTheme",
46 | merbivore: "Merbivore",
47 | merbivore_soft: "Merbivore Soft",
48 | mono_industrial: "Mono Industrial",
49 | monokai: "Monokai",
50 | pastel_on_dark: "Pastel on Dark",
51 | solarized_dark: "Solarized Dark",
52 | solarized_light: "Solarized Light",
53 | textmate: "TextMate",
54 | tomorrow: "Tomorrow",
55 | tomorrow_night: "Tomorrow Night",
56 | tomorrow_night_blue: "Tomorrow Night Blue",
57 | tomorrow_night_bright: "Tomorrow Night Bright",
58 | tomorrow_night_eighties: "Tomorrow Night 80s",
59 | twilight: "Twilight",
60 | vibrant_ink: "Vibrant Ink"
61 | };
62 |
63 | function editorId() {
64 | return "aced." + id;
65 | }
66 |
67 | function infoKey() {
68 | return editorId() + ".info";
69 | }
70 |
71 | function gc() {
72 | // Clean up localstorage
73 | store.forEach(function(key, val) {
74 | var re = new RegExp("aced\.(.*?)\.info");
75 | var info = re.exec(key);
76 | if (!info || !val.time) {
77 | return;
78 | }
79 |
80 | var id = info[1];
81 |
82 | // Remove week+ old stuff
83 | var now = new Date().getTime() / 1000;
84 |
85 | if (now > (val.time + 604800)) {
86 | store.remove(key);
87 | store.remove('aced.' + id);
88 | }
89 | });
90 | }
91 |
92 | function buildThemeSelect() {
93 | var $sel = $(" ");
94 | $sel.append(' ');
95 | $.each(themes, function(k, v) {
96 | $sel.append("" + v + " ");
97 | });
98 | return $("
").html($sel);
99 | }
100 |
101 | function toJquery(o) {
102 | return (typeof o == 'string') ? $("#" + o) : $(o);
103 | }
104 |
105 | function initProfile() {
106 | profile = {theme: ''};
107 |
108 | try {
109 | // Need to merge in any undefined/new properties from last release
110 | // Meaning, if we add new features they may not have them in profile
111 | profile = $.extend(true, profile, store.get('aced.profile'));
112 | } catch (e) { }
113 | }
114 |
115 | function updateProfile(obj) {
116 | profile = $.extend(null, profile, obj);
117 | store.set('profile', profile);
118 | }
119 |
120 | function render(content) {
121 | return (options.renderer) ? options.renderer(content) : content;
122 | }
123 |
124 | function bindKeyboard() {
125 | // CMD+s TO SAVE DOC
126 | key('command+s, ctrl+s', function (e) {
127 | submit();
128 | e.preventDefault();
129 | });
130 |
131 | var saveCommand = {
132 | name: "save",
133 | bindKey: {
134 | mac: "Command-S",
135 | win: "Ctrl-S"
136 | },
137 | exec: function () {
138 | submit();
139 | }
140 | };
141 | editor.commands.addCommand(saveCommand);
142 | }
143 |
144 | function info(info) {
145 | if (info) {
146 | store.set(infoKey(), info);
147 | }
148 | return store.get(infoKey());
149 | }
150 |
151 | function val(val) {
152 | // Alias func
153 | if (val) {
154 | editor.getSession().setValue(val);
155 | }
156 | return editor.getSession().getValue();
157 | }
158 |
159 | function discardDraft() {
160 | stopAutoSave();
161 | store.remove(editorId());
162 | store.remove(infoKey());
163 | location.reload();
164 | }
165 |
166 | function save() {
167 | store.set(editorId(), val());
168 | }
169 |
170 | function submit() {
171 | store.remove(editorId());
172 | store.remove(editorId() + ".info");
173 | options.submit(val());
174 | }
175 |
176 | function autoSave() {
177 | if (options.autoSave) {
178 | autoInterval = setInterval(function () {
179 | save();
180 | }, options.autoSaveInterval);
181 | } else {
182 | stopAutoSave();
183 | }
184 | }
185 |
186 | function stopAutoSave() {
187 | if (autoInterval){
188 | clearInterval(autoInterval)
189 | }
190 | }
191 |
192 | function renderPreview() {
193 | if (!preview) {
194 | return;
195 | }
196 | preview.html(render(val()));
197 | $('pre code', preview).each(function(i, e) {
198 | hljs.highlightBlock(e)
199 | });
200 | }
201 |
202 | function getScrollHeight($prevFrame) {
203 | // Different browsers attach the scrollHeight of a document to different
204 | // elements, so handle that here.
205 | if ($prevFrame[0].scrollHeight !== undefined) {
206 | return $prevFrame[0].scrollHeight;
207 | } else if ($prevFrame.find('html')[0].scrollHeight !== undefined &&
208 | $prevFrame.find('html')[0].scrollHeight !== 0) {
209 | return $prevFrame.find('html')[0].scrollHeight;
210 | } else {
211 | return $prevFrame.find('body')[0].scrollHeight;
212 | }
213 | }
214 |
215 | function getPreviewWrapper(obj) {
216 | // Attempts to get the wrapper for preview based on overflow prop
217 | if (!obj) {
218 | return;
219 | }
220 | if (obj.css('overflow') == 'auto' || obj.css('overflow') == 'scroll') {
221 | return obj;
222 | } else {
223 | return getPreviewWrapper(obj.parent());
224 | }
225 | }
226 |
227 | function syncPreview() {
228 |
229 | var editorScrollRange = (editor.getSession().getLength());
230 |
231 | var previewScrollRange = (getScrollHeight(preview));
232 |
233 | // Find how far along the editor is (0 means it is scrolled to the top, 1
234 | // means it is at the bottom).
235 | var scrollFactor = editor.getFirstVisibleRow() / editorScrollRange;
236 |
237 | // Set the scroll position of the preview pane to match. jQuery will
238 | // gracefully handle out-of-bounds values.
239 |
240 | previewWrapper.scrollTop(scrollFactor * previewScrollRange);
241 | }
242 |
243 | function asyncLoad(filename, cb) {
244 | (function (d, t) {
245 |
246 | var leScript = d.createElement(t)
247 | , scripts = d.getElementsByTagName(t)[0];
248 |
249 | leScript.async = 1;
250 | leScript.src = filename;
251 | scripts.parentNode.insertBefore(leScript, scripts);
252 |
253 | leScript.onload = function () {
254 | cb && cb();
255 | }
256 |
257 | }(document, 'script'));
258 | }
259 |
260 | function setTheme(theme) {
261 | var cb = function(theme) {
262 | editor.setTheme('ace/theme/'+theme);
263 | updateProfile({theme: theme});
264 | };
265 |
266 | if (loadedThemes[theme]) {
267 | cb(theme);
268 | } else {
269 | asyncLoad(options.themePath + "/theme-" + theme + ".js", function () {
270 | cb(theme);
271 | loadedThemes[theme] = true;
272 | });
273 | }
274 | }
275 |
276 | function initSyncPreview() {
277 | if (!preview || !options.syncPreview) return;
278 | previewWrapper = getPreviewWrapper(preview);
279 | window.onload = function () {
280 | /**
281 | * Bind synchronization of preview div to editor scroll and change
282 | * of editor cursor position.
283 | */
284 | editor.session.on('changeScrollTop', syncPreview);
285 | editor.session.selection.on('changeCursor', syncPreview);
286 | };
287 | }
288 |
289 | function initProps() {
290 | // Id of editor
291 | if (typeof settings == 'string') {
292 | settings = { editor: settings };
293 | }
294 |
295 | if ('theme' in profile && profile['theme']) {
296 | settings['theme'] = profile['theme'];
297 | }
298 |
299 | if (settings['preview'] && !settings.hasOwnProperty('syncPreview')) {
300 | settings['syncPreview'] = true;
301 | }
302 |
303 | $.extend(options, settings);
304 |
305 | if (options.editor) {
306 | element = toJquery(options.editor);
307 | }
308 |
309 | $.each(options, function(k, v){
310 | if (element.data(k.toLowerCase())) {
311 | options[k] = element.data(k.toLowerCase());
312 | }
313 | });
314 |
315 | if (options.themeSelect) {
316 | themeSelect = toJquery(options.themeSelect);
317 | }
318 |
319 | if (options.submitBtn) {
320 | var submitBtn = toJquery(options.submitBtn);
321 | submitBtn.click(function(){
322 | submit();
323 | });
324 | }
325 |
326 | if (options.preview) {
327 | preview = toJquery(options.preview);
328 |
329 | // Enable sync unless set otherwise
330 | if (!settings.hasOwnProperty('syncPreview')) {
331 | options['syncPreview'] = true;
332 | }
333 | }
334 |
335 | if (!element.attr('id')) {
336 | // No id, make one!
337 | id = Math.random().toString(36).substring(7);
338 | element.attr('id', id);
339 | } else {
340 | id = element.attr('id')
341 | }
342 | }
343 |
344 | function initEditor() {
345 | editor = ace.edit(id);
346 | setTheme(profile.theme || options.theme);
347 | editor.getSession().setMode('ace/mode/' + options.mode);
348 | if (store.get(editorId()) && store.get(editorId()) != val()) {
349 | editor.getSession().setValue(store.get(editorId()));
350 | }
351 | editor.getSession().setUseWrapMode(true);
352 | editor.getSession().setTabSize(2);
353 | editor.getSession().setUseSoftTabs(true);
354 | editor.setShowPrintMargin(false);
355 | editor.renderer.setShowInvisibles(true);
356 | editor.renderer.setShowGutter(false);
357 |
358 | if (options.showButtonBar) {
359 | var $btnBar = $('' + buildThemeSelect().html() + ' Save
')
360 | element.find('.ace_content').before($btnBar);
361 |
362 | $(".aced-save", $btnBar).click(function(){
363 | submit();
364 | });
365 |
366 | if ($.fn.chosen) {
367 | $('select', $btnBar).chosen().change(function(){
368 | setTheme($(this).val());
369 | });
370 | }
371 | }
372 |
373 | if (options.keyMaster) {
374 | bindKeyboard();
375 | }
376 |
377 | if (preview) {
378 | editor.getSession().on('change', function (e) {
379 | renderPreview();
380 | });
381 | renderPreview();
382 | }
383 |
384 | if (themeSelect) {
385 | themeSelect
386 | .find('li > a')
387 | .bind('click', function (e) {
388 | setTheme($(e.target).data('value'));
389 | $(e.target).blur();
390 | return false;
391 | });
392 | }
393 |
394 | if (options.info) {
395 | // If no info exists, save it to storage
396 | if (!store.get(infoKey())) {
397 | store.set(infoKey(), options.info);
398 | } else {
399 | // Check info in storage against one passed in
400 | // for possible changes in data that may have occurred
401 | var info = store.get(infoKey());
402 | if (info['sha'] != options.info['sha'] && !info['ignore']) {
403 | // Data has changed since start of draft
404 | $(document).trigger('shaMismatch');
405 | }
406 | }
407 | }
408 |
409 | $(this).trigger('ready');
410 | }
411 |
412 | function init() {
413 | gc();
414 | initProfile();
415 | initProps();
416 | initEditor();
417 | initSyncPreview();
418 | autoSave();
419 | }
420 |
421 | init();
422 |
423 | return {
424 | editor: editor,
425 | submit: submit,
426 | val: val,
427 | discard: discardDraft,
428 | info: info
429 | };
430 | }
--------------------------------------------------------------------------------
/realms/modules/wiki/static/js/collaboration/firepad.js:
--------------------------------------------------------------------------------
1 | // Helper to get hash from end of URL or generate a random one.
2 | function getExampleRef() {
3 | var ref = new Firebase('https://' + Config['FIREBASE_HOSTNAME']);
4 | var hash = window.location.hash.replace(/^#fp-/, '');
5 | if (hash) {
6 | ref = ref.child(hash);
7 | } else {
8 | ref = ref.push(); // generate unique location.
9 | window.location = window.location + '#fp-' + ref.name(); // add it as a hash to the URL.
10 | }
11 | return ref;
12 | }
13 |
14 | function initFirepad() {
15 | var new_ = true;
16 | if (window.location.hash.lastIndexOf('#fp-', 0) === 0) {
17 | new_ = false;
18 | }
19 | var firepadRef = getExampleRef();
20 | var session = aced.editor.session;
21 | var content;
22 |
23 | if (new_) {
24 | content = session.getValue();
25 | }
26 |
27 | // Firepad wants an empty editor
28 | session.setValue('');
29 |
30 | //// Create Firepad.
31 | var firepad = Firepad.fromACE(firepadRef, aced.editor, {
32 | defaultText: content
33 | });
34 |
35 | firepad.on('ready', function() {
36 | startCollaboration();
37 | });
38 |
39 | $(document).on('end-collaboration', function() {
40 | firepad.dispose();
41 | });
42 | }
43 |
44 | $(document).on('loading-collaboration', function() {
45 | initFirepad(true);
46 | });
47 |
48 | $(function(){
49 | if (window.location.hash.lastIndexOf('#fp-', 0) === 0) {
50 | loadingCollaboration();
51 | }
52 | });
--------------------------------------------------------------------------------
/realms/modules/wiki/static/js/collaboration/main.js:
--------------------------------------------------------------------------------
1 | var $startCollaborationBtn = $('#start-collaboration');
2 | var $endCollaborationBtn = $('#end-collaboration');
3 | var $loadingCollaborationBtn = $('#loading-collaboration');
4 |
5 | function loadingCollaboration() {
6 | $endCollaborationBtn.hide();
7 | $startCollaborationBtn.hide();
8 | $loadingCollaborationBtn.show();
9 | $(document).trigger('loading-collaboration');
10 | }
11 |
12 | function startCollaboration() {
13 | $loadingCollaborationBtn.hide();
14 | $startCollaborationBtn.hide();
15 | $endCollaborationBtn.show();
16 | $(document).trigger('start-collaboration');
17 | }
18 |
19 | function endCollaboration() {
20 | $loadingCollaborationBtn.hide();
21 | $endCollaborationBtn.hide();
22 | $startCollaborationBtn.show();
23 | $(document).trigger('end-collaboration');
24 | }
25 |
26 | $(function() {
27 | $startCollaborationBtn.click(function(e) {
28 | loadingCollaboration();
29 | e.preventDefault();
30 | });
31 | $endCollaborationBtn.click(function(e) {
32 | endCollaboration();
33 | e.preventDefault();
34 |
35 | });
36 | });
37 |
--------------------------------------------------------------------------------
/realms/modules/wiki/static/js/collaboration/togetherjs.js:
--------------------------------------------------------------------------------
1 | $(document).on('loading-collaboration', function() {
2 | TogetherJS();
3 | });
4 |
5 | $(document).on('end-collaboration', function() {
6 | TogetherJS();
7 | });
8 |
9 | TogetherJSConfig_toolName = "Collaboration";
10 | TogetherJSConfig_suppressJoinConfirmation = true;
11 |
12 | if (User.is_authenticated) {
13 | TogetherJSConfig_getUserName = function () {
14 | return User.username;
15 | };
16 |
17 | TogetherJSConfig_getUserAvatar = function () {
18 | return User.avatar;
19 | };
20 | }
21 |
22 | TogetherJSConfig_on_ready = function () {
23 | startCollaboration();
24 | };
25 |
26 | TogetherJSConfig_on_close = function () {
27 | //endCollaboration();
28 | };
--------------------------------------------------------------------------------
/realms/modules/wiki/static/js/editor.js:
--------------------------------------------------------------------------------
1 | var $entry_markdown_header = $("#entry-markdown-header");
2 | var $entry_preview_header = $("#entry-preview-header");
3 | var $entry_markdown = $(".entry-markdown");
4 | var $entry_preview = $(".entry-preview");
5 | var $page_name = $("#page-name");
6 | var $page_message = $("#page-message");
7 |
8 | // Tabs
9 | $entry_markdown_header.click(function(){
10 | $entry_markdown.addClass('active');
11 | $entry_preview.removeClass('active');
12 | });
13 |
14 | $entry_preview_header.click(function(){
15 | $entry_preview.addClass('active');
16 | $entry_markdown.removeClass('active');
17 | });
18 |
19 | $(document).on('shaMismatch', function() {
20 | bootbox.dialog({
21 | title: "Page has changed",
22 | message: "This page has changed and differs from your draft. What do you want to do?",
23 | buttons: {
24 | ignore: {
25 | label: "Ignore",
26 | className: "btn-default",
27 | callback: function() {
28 | var info = aced.info();
29 | info['ignore'] = true;
30 | aced.info(info);
31 | }
32 | },
33 | discard: {
34 | label: "Discard Draft",
35 | className: "btn-danger",
36 | callback: function() {
37 | aced.discard();
38 | }
39 | },
40 | changes: {
41 | label: "Show Diff",
42 | className: "btn-primary",
43 | callback: function() {
44 | bootbox.alert("Draft diff not done! Sorry");
45 | }
46 | }
47 | }
48 | })
49 | });
50 |
51 | $(function(){
52 | $("#discard-draft-btn").click(function() {
53 | aced.discard();
54 | });
55 |
56 | $(".entry-markdown .floatingheader").click(function(){
57 | aced.editor.focus();
58 | });
59 |
60 | $("#delete-page-btn").click(function() {
61 | bootbox.confirm('Are you sure you want to delete this page?', function(result) {
62 | if (result) {
63 | deletePage();
64 | }
65 | });
66 | });
67 | });
68 |
69 | var deletePage = function() {
70 | var pageName = $page_name.val();
71 | var path = Config['RELATIVE_PATH'] + '/' + pageName;
72 |
73 | $.ajax({
74 | type: 'DELETE',
75 | url: path,
76 | }).done(function(data) {
77 | var msg = 'Deleted page: ' + pageName;
78 | bootbox.alert(msg, function() {
79 | location.href = Config['RELATIVE_PATH'] + '/';
80 | });
81 | }).fail(function(data, status, error) {
82 | bootbox.alert('Error deleting page!');
83 | });
84 | };
85 | var last_imports = '';
86 | var partials = [];
87 | var aced = new Aced({
88 | editor: $('#entry-markdown-content').find('.editor').attr('id'),
89 | renderer: function(md) {
90 | var doc = metaMarked(md);
91 | if (doc.meta && 'import' in doc.meta) {
92 | // If the imports have changed, refresh them from the server
93 | if (doc.meta['import'].toString() != last_imports) {
94 | last_imports = doc.meta['import'].toString();
95 | $.getJSON('/_partials', {'imports': doc.meta['import']}, function (response) {
96 | partials = response['partials'];
97 | // TODO: Better way to force update of the preview here than this fake signal?
98 | aced.editor.session.doc._signal('change',
99 | {'action': 'insert', 'lines': [], 'start': {'row': 0}, 'end': {'row': 0}});
100 | });
101 | }
102 | }
103 | return MDR.convert(md, partials)
104 | },
105 | info: Commit.info,
106 | submit: function(content) {
107 | var data = {
108 | name: $page_name.val().replace(/^\/*/g, "").replace(/\/+/g, "/"),
109 | message: $page_message.val(),
110 | content: content
111 | };
112 |
113 | if (!data.name) {
114 | $page_name.addClass('parsley-error');
115 | bootbox.alert("Invalid Page Name ");
116 | return;
117 | }
118 |
119 | // If renaming an existing page, use the old page name for the URL to PUT to
120 | var subPath = (PAGE_NAME) ? PAGE_NAME : data['name'];
121 | var path = Config['RELATIVE_PATH'] + '/' + subPath;
122 | var newPath = Config['RELATIVE_PATH'] + '/' + data['name'];
123 |
124 | var type = (Commit.info['sha']) ? "PUT" : "POST";
125 | $.ajaxSetup({
126 | beforeSend: function(xhr, settings) {
127 | if (!/^(GET|HEAD|OPTIONS|TRACE)$/i.test(settings.type) && !this.crossDomain) {
128 | xhr.setRequestHeader("X-CSRFToken", csrf_token);
129 | }
130 | }
131 | });
132 |
133 | $.ajax({
134 | type: type,
135 | url: path,
136 | data: data,
137 | dataType: 'json'
138 | }).always(function(data, status, error) {
139 | var res = data['responseJSON'];
140 | if (res && res['error']) {
141 | $page_name.addClass('parsley-error');
142 | bootbox.alert("" + res['message'] + " ");
143 | } else {
144 | location.href = newPath;
145 | }
146 | });
147 | }
148 | });
149 |
--------------------------------------------------------------------------------
/realms/modules/wiki/templates/wiki/compare.html:
--------------------------------------------------------------------------------
1 | {% extends 'layout.html' %}
2 | {% block body %}
3 |
4 | History for {{ name }}
5 |
9 |
10 | Back to History
11 |
12 | {{ diff|safe }}
13 |
14 | Back to History
15 |
16 | {% endblock %}
--------------------------------------------------------------------------------
/realms/modules/wiki/templates/wiki/edit.html:
--------------------------------------------------------------------------------
1 | {% extends 'layout.html' %}
2 | {% block js %}
3 |
10 |
11 |
12 | {% if config.get('COLLABORATION') %}
13 |
14 | {% endif %}
15 |
16 | {% if config.get('COLLABORATION') == 'firepad' %}
17 |
20 |
21 |
22 |
23 | {% endif %}
24 |
25 | {% if config.get('COLLABORATION') == 'togetherjs' %}
26 |
27 |
28 | {% endif %}
29 |
30 | {% endblock %}
31 |
32 | {% block body %}
33 |
34 |
35 |
36 |
38 |
39 |
40 |
42 |
43 |
44 |
45 |
46 | {% if config.get('COLLABORATION') %}
47 |
48 |
49 |
50 | End Collaboration
51 |
52 |
53 |
54 |
55 |
56 | Loading
57 |
58 |
59 | {% endif %}
60 |
61 |
62 |
64 |
65 | Actions
66 |
67 |
68 |
82 |
83 |
84 |
85 |
87 |
88 | Theme
89 |
90 |
117 |
118 |
119 |
132 |
133 |
134 |
135 |
136 |
137 |
141 |
142 | {{ content }}
144 |
145 |
146 |
147 |
155 |
156 |
157 |
158 | {% endblock %}
159 |
--------------------------------------------------------------------------------
/realms/modules/wiki/templates/wiki/history.html:
--------------------------------------------------------------------------------
1 | {% extends 'layout.html' %}
2 | {% block body %}
3 |
4 | History for {{ name }}
5 |
6 | Compare Revisions
7 |
8 |
9 |
10 |
11 |
12 | Name
13 | Revision Message
14 | Date
15 |
16 |
17 |
18 | Loading file history...
19 |
20 |
21 |
22 | Compare Revisions
23 |
24 |
25 | {% endblock %}
26 |
27 | {% block css %}
28 |
41 | {% endblock %}
42 |
43 | {% block js %}
44 |
120 | {% endblock %}
121 |
--------------------------------------------------------------------------------
/realms/modules/wiki/templates/wiki/index.html:
--------------------------------------------------------------------------------
1 | {% extends 'layout.html' %}
2 |
3 | {% block js %}
4 |
9 | {% endblock %}
10 |
11 | {% block body %}
12 | Index of /
13 | {%- set parts = path.split('/') -%}
14 | {%- for dir in parts if dir -%}
15 | {{ dir }}/
16 | {%- endfor -%}
17 |
18 |
19 |
20 |
21 |
22 | Name
23 | Bytes
24 | Created
25 | Modified
26 |
27 |
28 | {% for file in index %}
29 |
30 | {% if file['dir'] %}
31 | Dir
32 | {{ file['name'][path|length:] }}
33 | {% else %}
34 | Page
35 | {{ file['name'][path|length:] }}
36 | {% endif %}
37 | {{ file['size'] }}
38 | {{ file['ctime']|datetime }}
39 | {{ file['mtime']|datetime }}
40 |
41 | {% endfor %}
42 |
43 | {% endblock %}
44 |
--------------------------------------------------------------------------------
/realms/modules/wiki/templates/wiki/page.html:
--------------------------------------------------------------------------------
1 | {% extends 'layout.html' %}
2 |
3 | {% block feed %}
4 |
5 | {% endblock %}
6 |
7 | {% block page_menu %}
8 |
12 | {% endblock %}
13 |
14 | {% block body %}
15 | {% if commit %}
16 |
17 |
23 |
24 | {% endif %}
25 |
26 |
27 | {% endblock %}
28 |
29 | {% block js %}
30 |
42 | {% endblock %}
43 |
--------------------------------------------------------------------------------
/realms/modules/wiki/tests.py:
--------------------------------------------------------------------------------
1 | from __future__ import absolute_import
2 |
3 | import json
4 |
5 | from nose.tools import *
6 | from flask import url_for
7 |
8 | from realms.lib.util import cname_to_filename, filename_to_cname
9 | from realms.lib.test import BaseTest
10 |
11 |
12 | class WikiBaseTest(BaseTest):
13 | def update_page(self, name, message=None, content=None):
14 | return self.client.post(url_for('wiki.page_write', name=name),
15 | data=dict(message=message, content=content,
16 | csrf_token=self.client.csrf_token))
17 |
18 | def create_page(self, name, message=None, content=None):
19 | return self.client.post(url_for('wiki.page_write', name=name),
20 | data=dict(message=message, content=content,
21 | csrf_token=self.client.csrf_token))
22 |
23 |
24 | class UtilTest(WikiBaseTest):
25 | def test_cname_to_filename(self):
26 | eq_(cname_to_filename('test'), 'test.md')
27 |
28 | def test_filename_to_cname(self):
29 | eq_(filename_to_cname('test-1-2-3.md'), 'test-1-2-3')
30 |
31 |
32 | class WikiTest(WikiBaseTest):
33 | def test_routes(self):
34 | self.assert_200(self.client.get(url_for("wiki.create")))
35 | self.create_page('test', message='test message', content='testing')
36 |
37 | for route in ['page', 'edit', 'history']:
38 | rv = self.client.get(url_for("wiki.%s" % route, name='test'))
39 | self.assert_200(rv, "wiki.{0}: {1}".format(route, rv.status_code))
40 |
41 | self.assert_200(self.client.get(url_for('wiki.index')))
42 |
43 | def test_write_page(self):
44 | self.assert_200(self.create_page('test', message='test message', content='testing'))
45 |
46 | rv = self.client.get(url_for('wiki.page', name='test'))
47 | self.assert_200(rv)
48 |
49 | self.assert_context('name', 'test')
50 | eq_(next(self.get_context_variable('page').history)['message'], 'test message')
51 | eq_(self.get_context_variable('page').data, 'testing')
52 |
53 | def test_history(self):
54 | self.assert_200(self.client.get(url_for('wiki.history', name='test')))
55 |
56 | def test_delete_page(self):
57 | self.app.config['WIKI_LOCKED_PAGES'] = ['test']
58 | self.assert_403(self.client.delete(url_for('wiki.page_write', name='test')))
59 | self.app.config['WIKI_LOCKED_PAGES'] = []
60 |
61 | # Create page, check it exists
62 | self.create_page('test', message='test message', content='testing')
63 | self.assert_200(self.client.get(url_for('wiki.page', name='test')))
64 |
65 | # Delete page
66 | self.assert_200(self.client.delete(url_for('wiki.page_write', name='test')))
67 |
68 | rv = self.client.get(url_for('wiki.page', name='test'))
69 | self.assert_status(rv, 302)
70 |
71 | def test_revert(self):
72 | rv1 = self.create_page('test', message='test message', content='testing_old')
73 | self.update_page('test', message='test message', content='testing_new')
74 | data = rv1.json
75 | self.client.post(url_for('wiki.revert'),
76 | data=dict(name='test', commit=data['sha'],
77 | csrf_token=self.client.csrf_token))
78 | self.client.get(url_for('wiki.page', name='test'))
79 | eq_(self.get_context_variable('page').data, 'testing_old')
80 | self.assert_404(self.client.post(url_for('wiki.revert'),
81 | data=dict(name='test', commit='does not exist',
82 | csrf_token=self.client.csrf_token)))
83 |
84 | self.app.config['WIKI_LOCKED_PAGES'] = ['test']
85 | self.assert_403(self.client.post(url_for('wiki.revert'),
86 | data=dict(name='test', commit=data['sha'],
87 | csrf_token=self.client.csrf_token)))
88 | self.app.config['WIKI_LOCKED_PAGES'] = []
89 |
90 | def test_anon(self):
91 | rv1 = self.create_page('test', message='test message', content='testing_old')
92 | self.update_page('test', message='test message', content='testing_new')
93 | data = rv1.json
94 | self.app.config['ALLOW_ANON'] = False
95 | self.assert_403(self.update_page('test', message='test message', content='testing_again'))
96 | self.assert_403(self.client.post(url_for('wiki.revert'),
97 | data=dict(name='test', commit=data['sha'],
98 | csrf_token=self.client.csrf_token)))
99 |
100 |
101 | class RelativePathTest(WikiTest):
102 | def configure(self):
103 | return dict(RELATIVE_PATH='wiki')
104 |
--------------------------------------------------------------------------------
/realms/modules/wiki/views.py:
--------------------------------------------------------------------------------
1 | from __future__ import absolute_import
2 |
3 | import collections
4 | import itertools
5 | import sys
6 | from datetime import datetime
7 |
8 | from flask import abort, g, render_template, request, redirect, Blueprint, flash, url_for, current_app, make_response
9 | from werkzeug.contrib.atom import AtomFeed
10 | from flask_login import login_required, current_user
11 |
12 | from realms.version import __version__
13 | from realms.lib.util import to_canonical, remove_ext, gravatar_url
14 | from .models import PageNotFound
15 |
16 | blueprint = Blueprint('wiki', __name__, template_folder='templates',
17 | static_folder='static', static_url_path='/static/wiki')
18 |
19 |
20 | @blueprint.route("/_commit//")
21 | def commit(name, sha):
22 | if current_app.config.get('PRIVATE_WIKI') and current_user.is_anonymous:
23 | return current_app.login_manager.unauthorized()
24 |
25 | cname = to_canonical(name)
26 |
27 | data = g.current_wiki.get_page(cname, sha=sha.decode())
28 |
29 | if not data:
30 | abort(404)
31 |
32 | partials = _partials(data.imports, sha=sha.decode())
33 |
34 | return render_template('wiki/page.html', name=name, page=data, commit=sha, partials=partials)
35 |
36 |
37 | @blueprint.route(r"/_compare//")
38 | def compare(name, fsha, dots, lsha):
39 | if current_app.config.get('PRIVATE_WIKI') and current_user.is_anonymous:
40 | return current_app.login_manager.unauthorized()
41 |
42 | diff = g.current_wiki.get_page(name, sha=lsha).compare(fsha)
43 | return render_template('wiki/compare.html',
44 | name=name, diff=diff, old=fsha, new=lsha)
45 |
46 |
47 | @blueprint.route("/_revert", methods=['POST'])
48 | @login_required
49 | def revert():
50 | cname = to_canonical(request.form.get('name'))
51 | commit = request.form.get('commit')
52 | message = request.form.get('message', "Reverting %s" % cname)
53 |
54 | if not current_app.config.get('ALLOW_ANON') and current_user.is_anonymous:
55 | return dict(error=True, message="Anonymous posting not allowed"), 403
56 |
57 | if cname in current_app.config.get('WIKI_LOCKED_PAGES'):
58 | return dict(error=True, message="Page is locked"), 403
59 |
60 | try:
61 | sha = g.current_wiki.get_page(cname).revert(commit,
62 | message=message,
63 | username=current_user.username,
64 | email=current_user.email)
65 | except PageNotFound as e:
66 | return dict(error=True, message=e.message), 404
67 |
68 | if sha:
69 | flash("Page reverted")
70 |
71 | return dict(sha=sha.decode())
72 |
73 |
74 | @blueprint.route("/_history/")
75 | def history(name):
76 | if current_app.config.get('PRIVATE_WIKI') and current_user.is_anonymous:
77 | return current_app.login_manager.unauthorized()
78 | return render_template('wiki/history.html', name=name)
79 |
80 |
81 | @blueprint.route("/_feed/")
82 | def feed(name):
83 | if current_app.config.get('PRIVATE_WIKI') and current_user.is_anonymous:
84 | return current_app.login_manager.unauthorized()
85 | cname = to_canonical(name)
86 | wiki_name = current_app.config['SITE_TITLE']
87 | start = 0
88 | length = int(request.args.get('length', 20))
89 |
90 | the_feed = AtomFeed(
91 | title="{} - Recent changes for page '{}'".format(wiki_name, cname),
92 | url=url_for('wiki.page', name=cname, _external=True),
93 | id="{}_pagefeed_{}".format(to_canonical(wiki_name), cname),
94 | feed_url=url_for('wiki.feed', name=cname, _external=True),
95 | generator=("Realms wiki", 'https://github.com/scragg0x/realms-wiki', __version__)
96 | )
97 |
98 | page = g.current_wiki.get_page(cname)
99 | items = list(itertools.islice(page.history, start, start + length)) # type: list[dict]
100 |
101 | for item in items:
102 | the_feed.add(
103 | title="Commit '{}'".format(item['sha']),
104 | content=item['message'],
105 | url=url_for('wiki.commit', name=name, sha=item['sha'], _external=True),
106 | id="{}/{}".format(item['sha'], cname),
107 | author=item['author'],
108 | updated=datetime.fromtimestamp(item['time'])
109 | )
110 |
111 | response = make_response((the_feed.to_string(), {'Content-type': 'application/atom+xml; charset=utf-8'}))
112 | response.add_etag()
113 | return response.make_conditional(request)
114 |
115 |
116 | @blueprint.route("/_history_data/")
117 | def history_data(name):
118 | """Ajax provider for paginated history data."""
119 | if current_app.config.get('PRIVATE_WIKI') and current_user.is_anonymous:
120 | return current_app.login_manager.unauthorized()
121 | draw = int(request.args.get('draw', 0))
122 | start = int(request.args.get('start', 0))
123 | length = int(request.args.get('length', 10))
124 | page = g.current_wiki.get_page(name)
125 | items = list(itertools.islice(page.history, start, start + length)) # type: list[dict]
126 | for item in items:
127 | item['gravatar'] = gravatar_url(item['author_email'])
128 | item['DT_RowId'] = item['sha']
129 | date = datetime.fromtimestamp(item['time'])
130 | item['date'] = date.strftime(current_app.config.get('DATETIME_FORMAT', '%b %d, %Y %I:%M %p'))
131 | item['link'] = url_for('.commit', name=name, sha=item['sha'])
132 | total_records, hist_complete = page.history_cache
133 | if not hist_complete:
134 | # Force datatables to fetch more data when it gets to the end
135 | total_records += 1
136 | return {
137 | 'draw': draw,
138 | 'recordsTotal': total_records,
139 | 'recordsFiltered': total_records,
140 | 'data': items,
141 | 'fully_loaded': hist_complete
142 | }
143 |
144 |
145 | @blueprint.route("/_edit/")
146 | @login_required
147 | def edit(name):
148 | cname = to_canonical(name)
149 | page = g.current_wiki.get_page(cname)
150 |
151 | if not page:
152 | # Page doesn't exist
153 | return redirect(url_for('wiki.create', name=cname))
154 |
155 | g.assets['js'].append('editor.js')
156 | return render_template('wiki/edit.html',
157 | name=cname,
158 | content=page.data.decode(),
159 | # TODO: Remove this? See #148
160 | info=next(page.history),
161 | sha=page.sha)
162 |
163 |
164 | def _partials(imports, sha='HEAD'):
165 | page_queue = collections.deque(imports)
166 | partials = collections.OrderedDict()
167 | while page_queue:
168 | page_name = page_queue.popleft()
169 | if page_name in partials:
170 | continue
171 | page = g.current_wiki.get_page(page_name, sha=sha.decode())
172 | try:
173 | partials[page_name] = page.data
174 | except KeyError:
175 | partials[page_name] = "`Error importing wiki page '{0}'`".format(page_name)
176 | continue
177 | page_queue.extend(page.imports)
178 | # We want to retain the order (and reverse it) so that combining metadata from the imports works
179 | # nested list for python >3.5 compatibility
180 | return list(reversed(list(partials.items())))
181 |
182 |
183 | @blueprint.route("/_partials")
184 | def partials():
185 | if current_app.config.get('PRIVATE_WIKI') and current_user.is_anonymous:
186 | return current_app.login_manager.unauthorized()
187 | return {'partials': _partials(request.args.getlist('imports[]'))}
188 |
189 |
190 | @blueprint.route("/_create/", defaults={'name': None})
191 | @blueprint.route("/_create/")
192 | @login_required
193 | def create(name):
194 | cname = to_canonical(name) if name else ""
195 | if cname and g.current_wiki.get_page(cname):
196 | # Page exists, edit instead
197 | return redirect(url_for('wiki.edit', name=cname))
198 |
199 | g.assets['js'].append('editor.js')
200 | return render_template('wiki/edit.html',
201 | name=cname,
202 | content="",
203 | info={})
204 |
205 |
206 | def _get_subdir(path, depth):
207 | parts = path.split('/', depth)
208 | if len(parts) > depth:
209 | return parts[-2]
210 |
211 |
212 | def _tree_index(items, path=""):
213 | depth = len(path.split("/"))
214 | items = sorted(items, key=lambda x: x['name'])
215 | for subdir, items in itertools.groupby(items, key=lambda x: _get_subdir(x['name'], depth)):
216 | if not subdir:
217 | for item in items:
218 | yield dict(item, dir=False)
219 | else:
220 | size = 0
221 | ctime = sys.maxint
222 | mtime = 0
223 | for item in items:
224 | size += item['size']
225 | ctime = min(item['ctime'], ctime)
226 | mtime = max(item['mtime'], mtime)
227 | yield dict(name=path + subdir + "/",
228 | mtime=mtime,
229 | ctime=ctime,
230 | size=size,
231 | dir=True)
232 |
233 |
234 | @blueprint.route("/_index", defaults={"path": ""})
235 | @blueprint.route("/_index/")
236 | def index(path):
237 | if current_app.config.get('PRIVATE_WIKI') and current_user.is_anonymous:
238 | return current_app.login_manager.unauthorized()
239 |
240 | items = g.current_wiki.get_index()
241 | if path:
242 | path = to_canonical(path) + "/"
243 | items = filter(lambda x: x['name'].startswith(path), items)
244 | if not request.args.get('flat', '').lower() in ['yes', '1', 'true']:
245 | items = _tree_index(items, path=path)
246 |
247 | return render_template('wiki/index.html', index=items, path=path)
248 |
249 |
250 | @blueprint.route("/", methods=['POST', 'PUT', 'DELETE'])
251 | @login_required
252 | def page_write(name):
253 | cname = to_canonical(name)
254 |
255 | if not cname:
256 | return dict(error=True, message="Invalid name")
257 |
258 | if not current_app.config.get('ALLOW_ANON') and current_user.is_anonymous:
259 | return dict(error=True, message="Anonymous posting not allowed"), 403
260 |
261 | if request.method == 'POST':
262 | # Create
263 | if cname in current_app.config.get('WIKI_LOCKED_PAGES'):
264 | return dict(error=True, message="Page is locked"), 403
265 |
266 | sha = g.current_wiki.get_page(cname).write(request.form['content'],
267 | message=request.form['message'],
268 | username=current_user.username,
269 | email=current_user.email)
270 |
271 | elif request.method == 'PUT':
272 | edit_cname = to_canonical(request.form['name'])
273 |
274 | if edit_cname in current_app.config.get('WIKI_LOCKED_PAGES'):
275 | return dict(error=True, message="Page is locked"), 403
276 |
277 | if edit_cname != cname:
278 | g.current_wiki.get_page(cname).rename(edit_cname)
279 |
280 | sha = g.current_wiki.get_page(edit_cname).write(request.form['content'],
281 | message=request.form['message'],
282 | username=current_user.username,
283 | email=current_user.email)
284 |
285 | return dict(sha=sha.decode())
286 |
287 | elif request.method == 'DELETE':
288 | # DELETE
289 | if cname in current_app.config.get('WIKI_LOCKED_PAGES'):
290 | return dict(error=True, message="Page is locked"), 403
291 |
292 | sha = g.current_wiki.get_page(cname).delete(username=current_user.username,
293 | email=current_user.email)
294 |
295 | return dict(sha=sha.decode())
296 |
297 |
298 | @blueprint.route("/", defaults={'name': 'home'})
299 | @blueprint.route("/")
300 | def page(name):
301 | if current_app.config.get('PRIVATE_WIKI') and current_user.is_anonymous:
302 | return current_app.login_manager.unauthorized()
303 |
304 | cname = to_canonical(name)
305 | if cname != name:
306 | return redirect(url_for('wiki.page', name=cname))
307 |
308 | data = g.current_wiki.get_page(cname)
309 |
310 | if data:
311 | return render_template('wiki/page.html', name=cname, page=data, partials=_partials(data.imports))
312 | else:
313 | return redirect(url_for('wiki.create', name=cname))
314 |
--------------------------------------------------------------------------------
/realms/static/css/style.css:
--------------------------------------------------------------------------------
1 | body {
2 | font-family: "Lato", "Helvetica Neue", Helvetica, Arial, sans-serif;
3 | font-size: 15px;
4 | }
5 |
6 | .navbar {
7 | height: 50px !important;
8 | min-height: 49px !important;
9 | font-size: 0.85em;
10 | background: #242628;
11 | margin-bottom: 10px;
12 | border: 0;
13 | }
14 |
15 | .navbar-collapse {
16 | background: #242628;
17 | }
18 |
19 | .navbar-collapse.in .nav, .navbar-collapse.in, .navbar-collapse.collapsing {
20 | z-index: 1000;
21 | position: relative;
22 | }
23 |
24 | .navbar-toggle {
25 | margin-top: 8px;
26 | margin-bottom: 8px;
27 | }
28 |
29 | .navbar .nav li a, .navbar .nav li button, .navbar-brand {
30 | display: block;
31 | height: 49px;
32 | padding: 15px 15px;
33 | border-bottom: none;
34 | color: #7d878a;
35 | text-transform: uppercase;
36 | }
37 |
38 | .navbar .nav li a:hover, .navbar-brand:hover {
39 | color: #FFF !important;
40 | }
41 |
42 | .navbar .fa {
43 | margin-right: 5px;
44 | }
45 |
46 | .navbar .nav li {
47 | font-size: 1em;
48 | position: relative;
49 | border-right: #35393b 1px solid;
50 | }
51 |
52 | .navbar-brand {
53 | color: white;
54 | float: left;
55 | font-size: 19px;
56 | line-height: 21px;
57 | }
58 |
59 | .navbar {
60 | border-radius: 0;
61 | }
62 |
63 | .navbar .form-control {
64 | max-height: 33px;
65 | }
66 |
67 | .checkbox-cell {
68 | width: 4em;
69 | padding: 0.3em;
70 | }
71 |
72 | #app-wrap {
73 | top: 60px;
74 | left: 0;
75 | bottom: 0;
76 | right: 0;
77 | position: fixed;
78 | }
79 |
80 | #app-controls {
81 | margin:0;
82 | }
83 |
84 | .ace_gutter-cell {
85 | font-size: 1em;
86 | line-height: 1em;
87 | }
88 |
89 | #page-action-bar {
90 | position: relative;
91 | z-index: 100;
92 | float: right;
93 | }
94 |
95 | .avatar {
96 | -webkit-box-shadow: 0 1px 3px #1e1e1e;
97 | -moz-box-shadow: 0 1px 3px #1e1e1e;
98 | box-shadow: 0 1px 3px #1e1e1e;
99 | -webkit-border-radius: 2px;
100 | -moz-border-radius: 2px;
101 | -ms-border-radius: 2px;
102 | -o-border-radius: 2px;
103 | border-radius: 2px;
104 | }
105 |
106 | .navbar-nav>li.user-avatar img {
107 | margin-right: 3px;
108 | }
109 |
110 | .floating-header {
111 | position: absolute;
112 | right: 12px;
113 | bottom: -39px;
114 | z-index: 400;
115 | /* height: 20px; */
116 | padding: 1px;
117 | text-transform: uppercase;
118 | color: #aaa9a2;
119 | background-color: #000;
120 | border: 1px solid #000;
121 | font-size: 10px;
122 | }
123 |
124 | input.parsley-success,
125 | select.parsley-success,
126 | textarea.parsley-success {
127 | color: #468847;
128 | background-color: #DFF0D8;
129 | border: 1px solid #D6E9C6;
130 | }
131 |
132 | input.parsley-error,
133 | select.parsley-error,
134 | textarea.parsley-error {
135 | color: #B94A48;
136 | background-color: #F2DEDE;
137 | border: 1px solid #EED3D7;
138 | }
139 |
140 | .parsley-errors-list {
141 | margin: 2px 0 3px 0;
142 | padding: 0;
143 | list-style-type: none;
144 | font-size: 0.9em;
145 | line-height: 0.9em;
146 | opacity: 0;
147 | -moz-opacity: 0;
148 | -webkit-opacity: 0;
149 |
150 | transition: all .3s ease-in;
151 | -o-transition: all .3s ease-in;
152 | -ms-transition: all .3s ease-in;
153 | -moz-transition: all .3s ease-in;
154 | -webkit-transition: all .3s ease-in;
155 | }
156 |
157 | .parsley-errors-list.filled {
158 | opacity: 1;
159 | }
160 |
161 | a.label {
162 | text-decoration: none !important;
163 | }
164 |
165 | .diff {
166 | font-size: 14px !important;
167 | margin-bottom: 10px;
168 | }
169 |
170 | .entry-markdown.active {
171 | z-index: 2;
172 | }
173 |
174 | .entry-markdown, .entry-preview {
175 | width: 50%;
176 | padding: 15px;
177 | position: absolute;
178 | bottom: 0;
179 | top: 40px;
180 | background: #FFF;
181 | box-shadow: rgba(0,0,0,0.05) 0 1px 5px;
182 | }
183 |
184 | .entry-markdown {
185 | left: 0;
186 | border-right: #ddd 2px solid;
187 | }
188 |
189 | .entry-preview {
190 | right: 0;
191 | border-left: #ddd 2px solid;
192 | }
193 |
194 | .entry-preview-content {
195 | position: absolute;
196 | top: 0;
197 | right: 0;
198 | bottom: 0;
199 | left: 0;
200 | padding: 40px 10px 10px 10px;
201 | overflow: auto;
202 | cursor: default;
203 | }
204 |
205 | .ace_editor {
206 | height: auto;
207 | position: absolute !important;
208 | top: 0;
209 | left: 0;
210 | right: 0;
211 | bottom: 0;
212 | font-family: Inconsolata, monospace !important;
213 | font-size: 1.4em !important;
214 | line-height: 1.3em;
215 | }
216 |
217 | .floatingheader {
218 | position: absolute;
219 | top: 0;
220 | left: 0;
221 | right: 0;
222 | z-index: 3;
223 | height: 40px;
224 | padding: 8px 15px 12px;
225 | text-transform: uppercase;
226 | color: #333;
227 | background-color: #eee;
228 | }
229 |
230 | .editor {
231 | margin-top: 40px;
232 | }
233 |
234 | .btn-facebook {
235 | background-color: #325c99;
236 | color: #ffffff;
237 | }
238 |
239 | .btn-github {
240 | background-color: #4c4c4c;
241 | color: #ffffff;
242 | }
243 |
244 | .btn-google {
245 | background-color: #dd4b39;
246 | color: #ffffff;
247 | }
248 |
249 | .btn-twitter {
250 | background-color: #02acec;
251 | color: #ffffff;
252 | }
253 |
254 | @media (max-width:1000px) {
255 | .ace_content {
256 | padding: 3px;
257 | }
258 |
259 | .editor {
260 | margin-top: 0;
261 | }
262 |
263 | .entry-markdown, .entry-preview {
264 | top: 80px;
265 | left: 0;
266 | right: 0;
267 | width: 100%;
268 | border: none;
269 | min-height: 380px;
270 | z-index: 1;
271 | }
272 |
273 | .entry-markdown.active .entry-markdown-content {
274 | display: block;
275 | }
276 | .entry-markdown-content {
277 | display: none;
278 | }
279 |
280 | .entry-preview.active .entry-preview-content {
281 | display: block;
282 | }
283 |
284 | .entry-preview-content {
285 | display: none;
286 | padding: 10px;
287 | }
288 |
289 | .entry-markdown .floatingheader, .entry-preview .floatingheader {
290 | cursor: pointer;
291 | width: 50%;
292 | border-right: #fff 2px solid;
293 | color: #333;
294 | font-weight: normal;
295 | background: #EEE;
296 | position: absolute;
297 | top: -40px;
298 | left: 0;
299 | box-shadow: rgba(0,0,0,0.1) 0 -2px 3px inset;
300 | }
301 |
302 | .entry-preview .floatingheader {
303 | right: 0;
304 | left: auto;
305 | border-right: none;
306 | border-left: #fff 2px solid;
307 | }
308 |
309 | .entry-markdown.active header, .entry-preview.active header {
310 | cursor: auto;
311 | box-shadow: none;
312 | background-color: #3498db;
313 | color: #fff;
314 | }
315 | }
316 |
317 |
--------------------------------------------------------------------------------
/realms/static/fonts/FontAwesome.otf:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/scragg0x/realms-wiki/ed8c8c374e5ad1850f839547ad541dacaa4b90a3/realms/static/fonts/FontAwesome.otf
--------------------------------------------------------------------------------
/realms/static/fonts/fontawesome-webfont.eot:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/scragg0x/realms-wiki/ed8c8c374e5ad1850f839547ad541dacaa4b90a3/realms/static/fonts/fontawesome-webfont.eot
--------------------------------------------------------------------------------
/realms/static/fonts/fontawesome-webfont.ttf:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/scragg0x/realms-wiki/ed8c8c374e5ad1850f839547ad541dacaa4b90a3/realms/static/fonts/fontawesome-webfont.ttf
--------------------------------------------------------------------------------
/realms/static/fonts/fontawesome-webfont.woff:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/scragg0x/realms-wiki/ed8c8c374e5ad1850f839547ad541dacaa4b90a3/realms/static/fonts/fontawesome-webfont.woff
--------------------------------------------------------------------------------
/realms/static/humans.txt:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/scragg0x/realms-wiki/ed8c8c374e5ad1850f839547ad541dacaa4b90a3/realms/static/humans.txt
--------------------------------------------------------------------------------
/realms/static/img/favicon.ico:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/scragg0x/realms-wiki/ed8c8c374e5ad1850f839547ad541dacaa4b90a3/realms/static/img/favicon.ico
--------------------------------------------------------------------------------
/realms/static/js/hbs-helpers.js:
--------------------------------------------------------------------------------
1 | // Handlebar helpers
2 | Handlebars.registerHelper('well', function(options) {
3 | return '' + options.fn(this) + '
';
4 | });
5 |
6 | Handlebars.registerHelper('well-sm', function(options) {
7 | return '' + options.fn(this) + '
';
8 | });
9 |
10 | Handlebars.registerHelper('well-lg', function(options) {
11 | return '' + options.fn(this) + '
';
12 | });
13 |
--------------------------------------------------------------------------------
/realms/static/js/html5shiv.js:
--------------------------------------------------------------------------------
1 | /*
2 | HTML5 Shiv v3.7.0 | @afarkas @jdalton @jon_neal @rem | MIT/GPL2 Licensed
3 | */
4 | (function(l,f){function m(){var a=e.elements;return"string"==typeof a?a.split(" "):a}function i(a){var b=n[a[o]];b||(b={},h++,a[o]=h,n[h]=b);return b}function p(a,b,c){b||(b=f);if(g)return b.createElement(a);c||(c=i(b));b=c.cache[a]?c.cache[a].cloneNode():r.test(a)?(c.cache[a]=c.createElem(a)).cloneNode():c.createElem(a);return b.canHaveChildren&&!s.test(a)?c.frag.appendChild(b):b}function t(a,b){if(!b.cache)b.cache={},b.createElem=a.createElement,b.createFrag=a.createDocumentFragment,b.frag=b.createFrag();
5 | a.createElement=function(c){return!e.shivMethods?b.createElem(c):p(c,a,b)};a.createDocumentFragment=Function("h,f","return function(){var n=f.cloneNode(),c=n.createElement;h.shivMethods&&("+m().join().replace(/[\w\-]+/g,function(a){b.createElem(a);b.frag.createElement(a);return'c("'+a+'")'})+");return n}")(e,b.frag)}function q(a){a||(a=f);var b=i(a);if(e.shivCSS&&!j&&!b.hasCSS){var c,d=a;c=d.createElement("p");d=d.getElementsByTagName("head")[0]||d.documentElement;c.innerHTML="x";
6 | c=d.insertBefore(c.lastChild,d.firstChild);b.hasCSS=!!c}g||t(a,b);return a}var k=l.html5||{},s=/^<|^(?:button|map|select|textarea|object|iframe|option|optgroup)$/i,r=/^(?:a|b|code|div|fieldset|h1|h2|h3|h4|h5|h6|i|label|li|ol|p|q|span|strong|style|table|tbody|td|th|tr|ul)$/i,j,o="_html5shiv",h=0,n={},g;(function(){try{var a=f.createElement("a");a.innerHTML=" ";j="hidden"in a;var b;if(!(b=1==a.childNodes.length)){f.createElement("a");var c=f.createDocumentFragment();b="undefined"==typeof c.cloneNode||
7 | "undefined"==typeof c.createDocumentFragment||"undefined"==typeof c.createElement}g=b}catch(d){g=j=!0}})();var e={elements:k.elements||"abbr article aside audio bdi canvas data datalist details dialog figcaption figure footer header hgroup main mark meter nav output progress section summary template time video",version:"3.7.0",shivCSS:!1!==k.shivCSS,supportsUnknownElements:g,shivMethods:!1!==k.shivMethods,type:"default",shivDocument:q,createElement:p,createDocumentFragment:function(a,b){a||(a=f);
8 | if(g)return a.createDocumentFragment();for(var b=b||i(a),c=b.frag.cloneNode(),d=0,e=m(),h=e.length;d string,
59 | // permalink: false,
60 | // // renderPermalink: (slug, opts, state, permalink) => {},
61 | // permalinkClass: 'header-anchor',
62 | // permalinkSymbol: '¶',
63 | // permalinkBefore: false
64 | // });
65 |
66 | // Markdown Renderer
67 | var MDR = {
68 | meta: null,
69 | md: null,
70 | sanitize: true, // Override
71 | parse: function(md){
72 | return markdownit.render(md);
73 | },
74 | convert: function(md, partials, sanitize) {
75 | if (this.sanitize !== null) {
76 | sanitize = this.sanitize;
77 | }
78 | this.md = md;
79 | this.partials = partials;
80 | this.processMeta();
81 | try {
82 | var html = this.parse(this.md);
83 | } catch(e) {
84 | return this.md;
85 | }
86 |
87 | if (sanitize) {
88 | // Causes some problems with inline styles
89 | html = html_sanitize(html, function(url) {
90 | try {
91 | var prot = decodeURIComponent(url.toString());
92 | } catch (e) {
93 | return '';
94 | }
95 | if (prot.indexOf('javascript:') === 0) {
96 | return '';
97 | }
98 | return prot;
99 | }, function(id){
100 | return id;
101 | });
102 | }
103 | this.hook();
104 | return html;
105 | },
106 |
107 | processMeta: function() {
108 | var doc = metaMarked(this.md);
109 | this.md = doc.md;
110 | var meta = this.meta = {};
111 | if (this.partials) {
112 | $.each(this.partials, function(index, item) {
113 | var doc = metaMarked(item[1]);
114 | Handlebars.registerPartial(item[0], doc.md);
115 | $.extend(meta, doc.meta);
116 | })
117 | }
118 | $.extend(this.meta, doc.meta);
119 | if (Object.keys(this.meta).length) {
120 | try {
121 | var template = Handlebars.compile(this.md);
122 | this.md = template(this.meta);
123 | } catch(e) {
124 | console.log(e);
125 | }
126 | }
127 | },
128 |
129 | hook: function() {
130 | }
131 | };
132 |
133 | // Add some custom classes to table tags
134 | markdownit.renderer.rules.table_open = function (tokens, idx, options, env, self) {
135 | tokens[idx].attrPush(['class', 'table table-bordered']);
136 | return self.renderToken(tokens, idx, options);
137 | };
138 |
--------------------------------------------------------------------------------
/realms/static/js/respond.js:
--------------------------------------------------------------------------------
1 | /*! matchMedia() polyfill - Test a CSS media type/query in JS. Authors & copyright (c) 2012: Scott Jehl, Paul Irish, Nicholas Zakas. Dual MIT/BSD license */
2 | /*! NOTE: If you're already including a window.matchMedia polyfill via Modernizr or otherwise, you don't need this part */
3 | window.matchMedia=window.matchMedia||function(a){"use strict";var c,d=a.documentElement,e=d.firstElementChild||d.firstChild,f=a.createElement("body"),g=a.createElement("div");return g.id="mq-test-1",g.style.cssText="position:absolute;top:-100em",f.style.background="none",f.appendChild(g),function(a){return g.innerHTML='',d.insertBefore(f,e),c=42===g.offsetWidth,d.removeChild(f),{matches:c,media:a}}}(document);
4 |
5 | /*! Respond.js v1.3.0: min/max-width media query polyfill. (c) Scott Jehl. MIT/GPLv2 Lic. j.mp/respondjs */
6 | (function(a){"use strict";function x(){u(!0)}var b={};if(a.respond=b,b.update=function(){},b.mediaQueriesSupported=a.matchMedia&&a.matchMedia("only all").matches,!b.mediaQueriesSupported){var q,r,t,c=a.document,d=c.documentElement,e=[],f=[],g=[],h={},i=30,j=c.getElementsByTagName("head")[0]||d,k=c.getElementsByTagName("base")[0],l=j.getElementsByTagName("link"),m=[],n=function(){for(var b=0;l.length>b;b++){var c=l[b],d=c.href,e=c.media,f=c.rel&&"stylesheet"===c.rel.toLowerCase();d&&f&&!h[d]&&(c.styleSheet&&c.styleSheet.rawCssText?(p(c.styleSheet.rawCssText,d,e),h[d]=!0):(!/^([a-zA-Z:]*\/\/)/.test(d)&&!k||d.replace(RegExp.$1,"").split("/")[0]===a.location.host)&&m.push({href:d,media:e}))}o()},o=function(){if(m.length){var b=m.shift();v(b.href,function(c){p(c,b.href,b.media),h[b.href]=!0,a.setTimeout(function(){o()},0)})}},p=function(a,b,c){var d=a.match(/@media[^\{]+\{([^\{\}]*\{[^\}\{]*\})+/gi),g=d&&d.length||0;b=b.substring(0,b.lastIndexOf("/"));var h=function(a){return a.replace(/(url\()['"]?([^\/\)'"][^:\)'"]+)['"]?(\))/g,"$1"+b+"$2$3")},i=!g&&c;b.length&&(b+="/"),i&&(g=1);for(var j=0;g>j;j++){var k,l,m,n;i?(k=c,f.push(h(a))):(k=d[j].match(/@media *([^\{]+)\{([\S\s]+?)$/)&&RegExp.$1,f.push(RegExp.$2&&h(RegExp.$2))),m=k.split(","),n=m.length;for(var o=0;n>o;o++)l=m[o],e.push({media:l.split("(")[0].match(/(only\s+)?([a-zA-Z]+)\s?/)&&RegExp.$2||"all",rules:f.length-1,hasquery:l.indexOf("(")>-1,minw:l.match(/\(\s*min\-width\s*:\s*(\s*[0-9\.]+)(px|em)\s*\)/)&&parseFloat(RegExp.$1)+(RegExp.$2||""),maxw:l.match(/\(\s*max\-width\s*:\s*(\s*[0-9\.]+)(px|em)\s*\)/)&&parseFloat(RegExp.$1)+(RegExp.$2||"")})}u()},s=function(){var a,b=c.createElement("div"),e=c.body,f=!1;return b.style.cssText="position:absolute;font-size:1em;width:1em",e||(e=f=c.createElement("body"),e.style.background="none"),e.appendChild(b),d.insertBefore(e,d.firstChild),a=b.offsetWidth,f?d.removeChild(e):e.removeChild(b),a=t=parseFloat(a)},u=function(b){var h="clientWidth",k=d[h],m="CSS1Compat"===c.compatMode&&k||c.body[h]||k,n={},o=l[l.length-1],p=(new Date).getTime();if(b&&q&&i>p-q)return a.clearTimeout(r),r=a.setTimeout(u,i),void 0;q=p;for(var v in e)if(e.hasOwnProperty(v)){var w=e[v],x=w.minw,y=w.maxw,z=null===x,A=null===y,B="em";x&&(x=parseFloat(x)*(x.indexOf(B)>-1?t||s():1)),y&&(y=parseFloat(y)*(y.indexOf(B)>-1?t||s():1)),w.hasquery&&(z&&A||!(z||m>=x)||!(A||y>=m))||(n[w.media]||(n[w.media]=[]),n[w.media].push(f[w.rules]))}for(var C in g)g.hasOwnProperty(C)&&g[C]&&g[C].parentNode===j&&j.removeChild(g[C]);for(var D in n)if(n.hasOwnProperty(D)){var E=c.createElement("style"),F=n[D].join("\n");E.type="text/css",E.media=D,j.insertBefore(E,o.nextSibling),E.styleSheet?E.styleSheet.cssText=F:E.appendChild(c.createTextNode(F)),g.push(E)}},v=function(a,b){var c=w();c&&(c.open("GET",a,!0),c.onreadystatechange=function(){4!==c.readyState||200!==c.status&&304!==c.status||b(c.responseText)},4!==c.readyState&&c.send(null))},w=function(){var b=!1;try{b=new a.XMLHttpRequest}catch(c){b=new a.ActiveXObject("Microsoft.XMLHTTP")}return function(){return b}}();n(),b.update=n,a.addEventListener?a.addEventListener("resize",x,!1):a.attachEvent&&a.attachEvent("onresize",x)}})(this);
--------------------------------------------------------------------------------
/realms/static/robots.txt:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/scragg0x/realms-wiki/ed8c8c374e5ad1850f839547ad541dacaa4b90a3/realms/static/robots.txt
--------------------------------------------------------------------------------
/realms/templates/errors/404.html:
--------------------------------------------------------------------------------
1 | {% extends 'layout.html' %}
2 | {% block body %}
3 |
4 | Page Not Found
5 | {% if error is defined %}
6 | {{ error.description }}
7 | {% endif %}
8 |
9 | {% endblock %}
10 |
--------------------------------------------------------------------------------
/realms/templates/errors/error.html:
--------------------------------------------------------------------------------
1 | {% extends "layout.html" %}
2 | {% block title %}{{ title|escape }}{% endblock %}
3 | {% block scripts %}
4 | {% if traceback %}
5 |
6 |
7 |
8 | {% endif %}
9 | {% endblock %}
10 | {% block content %}
11 |
12 | {% if message %}{{ message|escape }}
{% endif %}
13 | {% if traceback %}{{ traceback|escape }}
{% endif %}
14 | ← Back to Safety
15 |
16 | Think you know what happened? Please
email us.
17 | {% endblock %}
--------------------------------------------------------------------------------
/realms/templates/layout.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
8 |
9 | {{ config.SITE_TITLE }}
10 |
11 |
12 |
13 |
14 | {% for bundle in g.assets['css'] %}
15 | {% assets bundle %}
16 |
17 | {% endassets %}
18 | {% endfor %}
19 |
20 | {% block css %}{% endblock %}
21 | {% block feed %}{% endblock %}
22 |
23 |
24 |
28 |
29 |
30 |
31 |
32 |
33 |
34 |
35 |
43 |
44 |
45 | {% if config.get('ROOT_ENDPOINT') != 'wiki.index' %}
46 | Index
47 | {% endif %}
48 | New
49 | {% if name %}
50 | Edit
51 | History
52 | {% endif %}
53 |
54 |
55 |
87 |
88 |
89 |
90 |
91 |
92 |
93 |
94 | {% with messages = get_flashed_messages(with_categories=True) %}
95 | {% if messages %}
96 | {% for category, message in messages %}
97 | {% if category == 'message' %}
98 | {% set category = "info" %}
99 | {% endif %}
100 |
101 | ×
102 | {{ message }}
103 |
104 | {% endfor %}
105 | {% endif %}
106 | {% endwith %}
107 | {% block body %}{% endblock %}
108 |
109 |
110 |
111 |
123 |
124 | {% for bundle in g.assets['js'] %}
125 | {% assets bundle %}
126 | {% if bundle == 'editor.js' %}
127 |
128 | {% else %}
129 |
130 | {% endif %}
131 | {% endassets %}
132 | {% endfor %}
133 |
134 | {% block js %}{% endblock %}
135 |
136 |
137 |
138 |
--------------------------------------------------------------------------------
/realms/templates/macros.html:
--------------------------------------------------------------------------------
1 | {# Source: https://gist.github.com/bearz/7394681 #}
2 |
3 | {# Renders field for bootstrap 3 standards.
4 |
5 | Params:
6 | field - WTForm field
7 | kwargs - pass any arguments you want in order to put them into the html attributes.
8 | There are few exceptions: for - for_, class - class_, class__ - class_
9 |
10 | Example usage:
11 | {{ macros.render_field(form.email, placeholder='Input email', type='email') }}
12 | #}
13 | {% macro render_field(field, label_visible=true) -%}
14 |
15 |
26 | {%- endmacro %}
27 |
28 | {# Renders checkbox fields since they are represented differently in bootstrap
29 | Params:
30 | field - WTForm field (there are no check, but you should put here only BooleanField.
31 | kwargs - pass any arguments you want in order to put them into the html attributes.
32 | There are few exceptions: for - for_, class - class_, class__ - class_
33 |
34 | Example usage:
35 | {{ macros.render_checkbox_field(form.remember_me) }}
36 | #}
37 | {% macro render_checkbox_field(field) -%}
38 |
39 |
40 | {{ field(type='checkbox', **kwargs) }} {{ field.label }}
41 |
42 |
43 | {%- endmacro %}
44 |
45 | {# Renders radio field
46 | Params:
47 | field - WTForm field (there are no check, but you should put here only BooleanField.
48 | kwargs - pass any arguments you want in order to put them into the html attributes.
49 | There are few exceptions: for - for_, class - class_, class__ - class_
50 |
51 | Example usage:
52 | {{ macros.render_radio_field(form.answers) }}
53 | #}
54 | {% macro render_radio_field(field) -%}
55 | {% for value, label, _ in field.iter_choices() %}
56 |
57 |
58 | {{ label }}
59 |
60 |
61 | {% endfor %}
62 | {%- endmacro %}
63 |
64 | {# Renders WTForm in bootstrap way. There are two ways to call function:
65 | - as macros: it will render all field forms using cycle to iterate over them
66 | - as call: it will insert form fields as you specify:
67 | e.g. {% call macros.render_form(form, action_url=url_for('login_view'), action_text='Login',
68 | class_='login-form') %}
69 | {{ macros.render_field(form.email, placeholder='Input email', type='email') }}
70 | {{ macros.render_field(form.password, placeholder='Input password', type='password') }}
71 | {{ macros.render_checkbox_field(form.remember_me, type='checkbox') }}
72 | {% endcall %}
73 |
74 | Params:
75 | form - WTForm class
76 | action_url - url where to submit this form
77 | action_text - text of submit button
78 | class_ - sets a class for form
79 | #}
80 | {% macro render_form(form, action_url='', action_text='Submit', class_='', btn_class='btn btn-default') -%}
81 |
82 |
99 | {%- endmacro %}
--------------------------------------------------------------------------------
/realms/version.py:
--------------------------------------------------------------------------------
1 | from __future__ import absolute_import
2 |
3 | __version__ = '0.9.3'
4 |
--------------------------------------------------------------------------------
/requirements-dev.txt:
--------------------------------------------------------------------------------
1 | -r requirements.txt
2 | Flask-Testing==0.4.2
3 | nose==1.3.4
4 | blinker==1.3
5 |
--------------------------------------------------------------------------------
/requirements.txt:
--------------------------------------------------------------------------------
1 | -e .
2 |
--------------------------------------------------------------------------------
/setup.py:
--------------------------------------------------------------------------------
1 | import os
2 | from pipenv.project import Project
3 | from pipenv.utils import convert_deps_to_pip
4 | from setuptools import setup, find_packages
5 |
6 | pipfile = Project().parsed_pipfile
7 | requirements = convert_deps_to_pip(pipfile['packages'], r=False)
8 |
9 | if os.environ.get('USER', '') == 'vagrant':
10 | del os.link
11 |
12 | DESCRIPTION = "Simple git based wiki"
13 |
14 | with open('README.md') as f:
15 | LONG_DESCRIPTION = f.read()
16 |
17 | __version__ = None
18 | exec(open('realms/version.py').read())
19 |
20 | CLASSIFIERS = [
21 | 'Intended Audience :: Developers',
22 | 'License :: OSI Approved :: GNU General Public License v2 (GPLv2)',
23 | 'Operating System :: POSIX :: Linux',
24 | 'Programming Language :: Python',
25 | 'Topic :: Internet :: WWW/HTTP :: Dynamic Content']
26 |
27 | setup(name='realms-wiki',
28 | version=__version__,
29 | packages=find_packages(),
30 | install_requires=requirements,
31 | entry_points={
32 | 'console_scripts': [
33 | 'realms-wiki = realms.commands:cli'
34 | ]},
35 | author='Matthew Scragg',
36 | author_email='scragg@gmail.com',
37 | maintainer='Matthew Scragg',
38 | maintainer_email='scragg@gmail.com',
39 | url='https://github.com/scragg0x/realms-wiki',
40 | license='GPLv2',
41 | include_package_data=True,
42 | description=DESCRIPTION,
43 | long_description=LONG_DESCRIPTION,
44 | platforms=['any'],
45 | classifiers=CLASSIFIERS)
46 |
--------------------------------------------------------------------------------