├── .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 | 6 | 7 | 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 |
6 | {% for r in results %} 7 | 8 |

{{ r['name'] }}

9 |

10 | {{ r['content'][:100] }} 11 |

12 |
13 | {% endfor %} 14 |
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(""); 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() + '
') 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 |
6 | View Old 7 | View New 8 |
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 | 52 |
53 |
54 | 58 |
59 | {% endif %} 60 | 61 | 83 | 84 | 118 | 119 |
120 | {% if name in config['WIKI_LOCKED_PAGES'] %} 121 | 122 | 123 | 124 | 125 | {% else %} 126 | 127 | 128 | 129 | 130 | {% endif %} 131 |
132 | 133 |
134 |
135 | 136 |
137 |
138 | Markdown 139 | 140 |
141 |
142 |
{{ content }}
144 |
145 |
146 | 147 |
148 |
149 | Preview 150 |
151 |
152 |
153 |
154 |
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 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 |
NameRevision MessageDate
Loading file history...
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 | 23 | 24 | 25 | 26 | 27 | 28 | {% for file in index %} 29 | 30 | {% if file['dir'] %} 31 | 32 | 33 | {% else %} 34 | 35 | 36 | {% endif %} 37 | 38 | 39 | 40 | 41 | {% endfor %} 42 |
NameModified
Dir{{ file['name'][path|length:] }}Page{{ file['name'][path|length:] }}{{ file['ctime']|datetime }}
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 |
9 | Edit 10 | History 11 |
12 | {% endblock %} 13 | 14 | {% block body %} 15 | {% if commit %} 16 |
17 |
18 | 19 | 20 | 22 |
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 | 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 |
16 | {% if (field.type != 'HiddenField' or field.type !='CSRFTokenField') and label_visible %} 17 | 18 | {% endif %} 19 | {{ field(class_='form-control', **kwargs) }} 20 | {% if field.errors %} 21 | {% for e in field.errors %} 22 |

{{ e }}

23 | {% endfor %} 24 | {% endif %} 25 |
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 | 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 | 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 |
83 | {{ form.hidden_tag() if form.hidden_tag }} 84 | {% if caller %} 85 | {{ caller() }} 86 | {% else %} 87 | {% for f in form %} 88 | {% if f.type == 'BooleanField' %} 89 | {{ render_checkbox_field(f) }} 90 | {% elif f.type == 'RadioField' %} 91 | {{ render_radio_field(f) }} 92 | {% else %} 93 | {{ render_field(f) }} 94 | {% endif %} 95 | {% endfor %} 96 | {% endif %} 97 | 98 |
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 | --------------------------------------------------------------------------------