diff --git a/README.md b/README.md index 7b0f3e4..7a5fd42 100644 --- a/README.md +++ b/README.md @@ -1,3 +1,15 @@ # nextcloudregister -Nextcloud register form without using a nextclooud app. \ No newline at end of file +Nextcloud register form without using a nextcloud app. + +# installation + +To install you need to: + +* install flask and requests +* clone the repo in the /site directory +* copy the etc content in /etc +* link /etc/nextcloudregister/nextcloudregister.service to /etc/systemd/system/nextcloudregister.service +* configure nextcloudregister fot your nextcloud instance in the file /etc/nextcloudregister/config.ini + +By default it will serve on port 9000 \ No newline at end of file diff --git a/__init__.py b/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/etc/nextcloudregister/config.ini b/etc/nextcloudregister/config.ini new file mode 100644 index 0000000..fe4891a --- /dev/null +++ b/etc/nextcloudregister/config.ini @@ -0,0 +1,49 @@ +[api] +# api configuration +# username to authenticate on the api +username = admin +# password to use on the api +password = secret +# domain where is the nexcloud instance +domain = cloud.example.com +# whether or not we should use https +use_https = yes +# should we require a valid ssl certificate ? +# note: this option is ignored if use_https if false +check_certificate = yes +# nexcloud api base uri +# the part after the domain but before the enpoint itself +# you should not need to change it +base_uri = /ocs/v1.php/cloud + + +[web] +# configuration for the web part and the user interaction +# path to display the form +base_uri = +# color to match the nextcloud theme +color = #0082c9 +# link to the nexcloud instance landing page +instance_link = https://cloud.example.com/index.php/login +# link to the host organisation +org_link = https://example.com +# link to the favicon to use. Fo not use a small one as it is displayed +# in the form too +favicon = favicon.ico +# link to the EULA for this service +eula = https://example.com/eula + + + + +[rules] +# all business rules +# maximum account authorized on the nextcloud instance. +# 0 means unlimited +max_accounts = 50 +# whether or not the user must give an email to subscribe +# at least one of email and password is mandatory +mandatory_email = yes +# whether or not the user must give an password to subscribe +# at least one of email and password is mandatory +mandatory_password = yes \ No newline at end of file diff --git a/etc/nextcloudregister/nextcloudregister.service b/etc/nextcloudregister/nextcloudregister.service new file mode 100644 index 0000000..191a39a --- /dev/null +++ b/etc/nextcloudregister/nextcloudregister.service @@ -0,0 +1,13 @@ +[Unit] +Description=Nextcloudregister a service to register in nextcloud +After=local-fs.target +Wants=network-online.target + +[Service] +Environment=PYTHONPATH=/usr/lib/python3/dist-packages/:/sites/ +ExecStart=/usr/bin/uwsgi-core --ini /etc/nextcloudregister/uwsgi.ini +User=nextcloudregister +Group=nextcloudregister + +[Install] +WantedBy=multi-user.target \ No newline at end of file diff --git a/etc/nextcloudregister/uwsgi.ini b/etc/nextcloudregister/uwsgi.ini new file mode 100644 index 0000000..e60e612 --- /dev/null +++ b/etc/nextcloudregister/uwsgi.ini @@ -0,0 +1,5 @@ +[uwsgi] +socket = :9090 +protocol = http +wsgi-file = /sites/nextcloudregister/webapp.py +plugin = python3 \ No newline at end of file diff --git a/lib.py b/lib.py new file mode 100644 index 0000000..3f2d556 --- /dev/null +++ b/lib.py @@ -0,0 +1,194 @@ +from configparser import ConfigParser + +import requests + +CONFIG_PATH = "/etc/nextcloudregister/config.ini" + + +class NextcloudApiException(Exception): + # base exception for unknown or undocumented errors + pass + + +class NextcloudApiInvalidInputData(NextcloudApiException): + # data geven to the api were not valid + pass + + +class NextcloudApiUsernamealreadyExists(NextcloudApiException): + # try to create a user with an already existing username + pass + + +class NextcloudApiPermissionDenied(NextcloudApiException): + # Not enough right to perform that action + pass + + +class NextcloudApiUnknownError(NextcloudApiException): + # generic error with an hint + def __init__(self, hint): + self.hint = hint + + +class NextcloudApiNoEmailPassword(NextcloudApiException): + # At least one of email or password is mandatory + pass + + +class NextcloudApiCannotSendEmail(NextcloudApiException): + # invitation email not sent + pass + + +class NextcloudApi: + """Class that abstract the Nexcloud api to handle the user creation. This + class only abstract a small part of the api. + + :param str config_path: path to the configfile for the application + """ + + def __init__(self, config_path=CONFIG_PATH): + self.config_path = config_path + + @property + def config(self): + """lazy loading for the configuration to make the class free to + instanciate + """ + if not hasattr(self, "_config"): + self._config = ConfigParser() + self._config.read(self.config_path) + return self._config + + def count_accounts(self): + """Returns the number of accounts already existing on this nextcloud + instance + + :rtype: int + :returns the number of accounts + :raise: NextcloudApiException if the api was not available + """ + raw_dict = self._request(endpoint="users") + users = raw_dict['ocs']['data'].get('users', []) + return len(users) + + def create_account(self, username, password=None, email=None): + """Try to create a new user account using the provided data. + + :param str username: the username to use for this account + :param str password: the password for the new user. optional is an + email is given + :param str email: the email for the new user. optional if a password is + given + + :raise: + ValueError: if the email and the password are empty + NextcloudApiException: if the api was not available or an unknow + error ocured + NextcloudApiInvalidInputData: the given data are somwhat invalid + NextcloudApiUsernamealreadyExists: the given username is already + used + NextcloudApiPermissionDenied: the given user in the options has no + right to create a user + NextcloudApiUnknownError: un anknow error acured but with an hint + NextcloudApiCannotSendEmail: could not send the invitation email + """ + data = { + "userid": username, + } + if password: + data["password"] = password + if email: + data["email"] = email + result = self._request( + endpoint="users", + data=data, + verb="POST", + ) + status = result['ocs']['meta']['statuscode'] + if status == 101: + raise NextcloudApiInvalidInputData() + elif status == 102: + raise NextcloudApiUsernamealreadyExists() + elif status == 103: + raise NextcloudApiException("103") + elif status == 105: + raise NextcloudApiPermissionDenied() + elif status == 107: + raise NextcloudApiUnknownError( + hint=result['ocs']['meta']['message'] + ) + elif status == 108: + raise NextcloudApiNoEmailPassword() + elif status == 109: + NextcloudApiCannotSendEmail() + elif status != 100: + raise NextcloudApiException(str(status)) + + def _request(self, endpoint, *, data=None, verb="GET"): + """Abstract most of the boilerplate work to make a restapi call to + nextcloud. + :rtype: dict + :returns: the api answer to the request + :raise: NextcloudApiException if the api was not available + """ + if verb.upper() not in ("GET", "POST", "PUT", "DELETE"): + raise ValueError("Unknown http verb") + + url = self._get_url(endpoint) + + headers = { + "OCS-APIRequest": "true", + "Accept": "application/json", + } + if verb.upper() == "POST": + headers["Content-Type"] = "application/x-www-form-urlencoded" + url = f"{url}?format=json" + + method = getattr(requests, verb.lower()) + + kwargs = { + "headers": headers, + "auth": ( + self.config.get("api", "username"), + self.config.get("api", "password") + ), + "data": data, + } + + if self.config.getboolean("api", "use_https"): + if not self.config.getboolean("api", "check_certificate"): + kwargs["verify"] = False + + result = method(url, **kwargs) + if result.status_code not in (200, 204): + raise NextcloudApiException( + "Nextcloud api unavailable code: %s" % result.status_code + ) + return result.json() + + def _get_url(self, endpoint): + """Builds the url to call for the iven endpoint of api + + :rtype: str + :returns: the full url with scheme for the given endpoint + """ + url = "" + # build protocol scheme + url += "http" + if self.config.getboolean("api", "use_https"): + url += "s" + url += "://" + + # add domain + url += self.config.get("api", "domain") + # add fullpath to endpoint + url = "/".join( + ( + url, + self.config.get("api", "base_uri"), + endpoint, + ) + ) + return url diff --git a/requirement/webapp.txt b/requirement/webapp.txt new file mode 100644 index 0000000..7492448 --- /dev/null +++ b/requirement/webapp.txt @@ -0,0 +1,2 @@ +flask +requests \ No newline at end of file diff --git a/templates/form.html b/templates/form.html new file mode 100644 index 0000000..e7988f1 --- /dev/null +++ b/templates/form.html @@ -0,0 +1,54 @@ + + + + + + Nextcloud registration form + + {% if success and instance_link %} + + {% endif %} + + + +
+ {% if favicon %} + + {% endif %} + {% if max_accounts %} +
+ Il reste {{ count_accounts }} / {{ max_accounts }} comptes. +
+ {% endif %} + {% if error %} +
+ {{ error }} +
+ {% endif %} + {% if info %} +
+ {{ info }} +
+ {% endif %} +
+ + {% if mandatory_email %} + + {% endif %} + {% if mandatory_password %} + + + {% endif %} + {% if eula %} +
+ En m'inscrivant à ce service j'accepte ses Conditions Générales d'utilisation +
+ {% endif %} + +
+
+ + diff --git a/templates/style.css b/templates/style.css new file mode 100644 index 0000000..48b5c2c --- /dev/null +++ b/templates/style.css @@ -0,0 +1,78 @@ +body +{ + margin: 0; +} + +input +{ + display: block; + height:25px; + border-radius: 12.5px; + width:440px; + margin:20px; + border-width: 1px; + border-style: solid; + padding-left: 10px; + padding-right: 10px; + border-color: {{ color }}; +} +input[type=submit] +{ + color:white; + width:460px; + background-color: {{ color }}; +} + +.favicon +{ + display:block; + margin: 0 auto; + max-width: 100px; +} +.navbar +{ + color:white; + height:50px; + width:100%; + background-color: {{ color }}; +} + +#main +{ + padding-top:20px; + margin:auto; + width:500px; +} +.eula +{ + margin-left: 50px; +} + +.error +{ + background-color: #fff0f0; + border-color: #ff0000; +} + +.info +{ + background-color: #f0f0ff; + border-color: #0000ff; +} + +.message +{ + border-style: solid; + border-width: 1px; + border-radius: 12.5px; + width:440px; + margin:20px; + padding-left: 10px; + padding-right: 10px; + padding-top: 3px; + padding-bottom: 3px; +} +.count +{ + text-align: center; +} \ No newline at end of file diff --git a/webapp.py b/webapp.py new file mode 100644 index 0000000..bf3c8d9 --- /dev/null +++ b/webapp.py @@ -0,0 +1,206 @@ +from configparser import ConfigParser + +from flask import ( + Flask, + request, + render_template, + Response, +) + +from nextcloudregister.lib import ( + CONFIG_PATH, + NextcloudApi, + NextcloudApiCannotSendEmail, + NextcloudApiException, + NextcloudApiInvalidInputData, + NextcloudApiNoEmailPassword, + NextcloudApiPermissionDenied, + NextcloudApiUsernamealreadyExists, + NextcloudApiUnknownError, +) + +config = ConfigParser() +config.read(CONFIG_PATH) +app = Flask(__name__) +base_uri = config.get("web", "base_uri", fallback="") +base_uri = base_uri + ("" if base_uri.endswith("/") else "/") + + +@app.route(base_uri, methods=["GET", "POST"]) +def form_manager(): + if request.method == "POST": + return form_post() + else: + return form_get() + + +def form_get(data=None, error=None, info=None, success=False): + context = { + "base_uri": base_uri, + "data": data or {}, + "disable": success, + "error": error, + "eula": config.get("web", "eula", fallback=""), + "favicon": config.get("web", "favicon", fallback=""), + "info": info, + "instance_link": config.get("web", "instance_link", fallback=""), + "max_accounts": config.getint("rules", "max_accounts", fallback=0), + "org_link": config.get("web", "org_link", fallback=""), + "success": success, + } + context["mandatory_password"] = config.getboolean( + "rules", + "mandatory_password", + fallback=True, + ) + context["mandatory_email"] = config.getboolean( + "rules", + "mandatory_email", + fallback=False, + ) + count_accounts = 0 + api = NextcloudApi() + try: + count_accounts = api.count_accounts() + except NextcloudApiException: + context["max_accounts"] = 0 + if count_accounts >= context["max_accounts"] and not success: + context["disable"] = True + context["error"] = ( + "Tout les comptes disponibles sur cette instance ont deja été " + "distribués." + ) + else: + context["count_accounts"] = context["max_accounts"] - count_accounts + return render_template("form.html", **context) + + +def form_post(): + mandatory_email = config.getboolean( + "rules", + "mandatory_email", + fallback=False, + ) + mandatory_password = config.getboolean( + "rules", + "mandatory_password", + fallback=False, + ) + # validate mandatory fields + if not request.form.get("username"): + return form_get( + data=request.form, + error="Un nom d'utilisateur est obligatoire pour vous inscrire." + ) + + if mandatory_email and not request.form.get("email"): + return form_get( + data=request.form, + error="Une adresse mail est obligatoire pour vous inscrire." + ) + if mandatory_password and not request.form.get("password1"): + return form_get( + data=request.form, + error="Un mot de passe est obligatoire pour vous inscrire." + ) + + # validate password + if request.form.get("password1"): + password1 = request.form.get("password1") + password2 = request.form.get("password2") + if not password1 == password2: + return form_get( + data=request.form, + error="Les mots de passe ne correspondent pas." + ) + + api = NextcloudApi() + try: + api.create_account( + username=request.form.get("username"), + password=request.form.get("password1"), + email=request.form.get("email") + ) + except NextcloudApiCannotSendEmail: + return form_get( + data=request.form, + error="Impossible d'envoyer un email pour la creation du compte." + ) + except NextcloudApiInvalidInputData: + return form_get( + data=request.form, + error=( + "Erreur interne. Merci de contacter l'administrateur de " + "l'instance Nextcloud." + ) + ) + except NextcloudApiNoEmailPassword: + return form_get( + data=request.form, + error=( + "Une addresse email ou un mot de passe sont obligatoires pour " + "créer un compte Nextcloud." + ) + ) + except NextcloudApiPermissionDenied: + return form_get( + data=request.form, + error=( + "Erreur interne. Merci de contacter l'administrateur de " + "l'instance Nextcloud. (Permission denied)" + ) + ) + except NextcloudApiUsernamealreadyExists: + return form_get( + data=request.form, + error=( + "Le nom d'utilisateur que vous avez choisi est déjà utilisé " + "sur cette instance Nextcloud." + ) + ) + except NextcloudApiUnknownError as err: + return form_get( + data=request.form, + error=err.hint + ) + except NextcloudApiException as err: + return form_get( + data=request.form, + error=( + "Erreur interne. Merci de contacter l'administrateur de " + "l'instance Nextcloud. (%s)" % err + ) + ) + except Exception: + return form_get( + data=request.form, + error=( + "Erreur interne. Merci de contacter l'administrateur de " + "l'instance Nextcloud." + ) + ) + if config.get("web", "instance_link", fallback=""): + return form_get( + info=( + "L'inscription est un succes, vous allez maintenat être " + "redirigé vers votre instance Nextcloud." + ), + success=True, + ) + else: + return form_get( + info="Vous êtes maintenat inscrit sur cette instance Nextcloud", + success=True, + ) + + +@app.route(base_uri + "style.css") +def style(): + context = { + "color": config.get("web", "color") + } + css_content = render_template("style.css", **context) + return Response(css_content, mimetype="text/css") + + +application = app