helpers.py 7 KB
Newer Older
BERJON Matthieu's avatar
BERJON Matthieu committed
1
2
import base64
import hashlib
3
import logging
BERJON Matthieu's avatar
BERJON Matthieu committed
4
import os
5
import re
BERJON Matthieu's avatar
BERJON Matthieu committed
6

7
import redis
8
import IPy
9
from django.conf import settings
10
from django.db.models import Q
11

12
import config
13
from .models import Job, Webapp, AllgoUser
14

BERJON Matthieu's avatar
BERJON Matthieu committed
15

16
log = logging.getLogger('allgo')
BERJON Matthieu's avatar
BERJON Matthieu committed
17
18
DEFAULT_ENTROPY = 32 # number of bytes to return by default

19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43

##################################################
# redis keys


# job log
REDIS_KEY_JOB_LOG       = "log:job:%d"
REDIS_KEY_JOB_STATE     = "state:job:%d"

# pubsub channels for waking up allgo.aio (frontend) and the controller
# (ourselves)
REDIS_CHANNEL_AIO        = "notify:aio"
REDIS_CHANNEL_CONTROLLER = "notify:controller"

# pubsub messages
REDIS_MESSAGE_JOB_UPDATED    = "job:%d"
REDIS_MESSAGE_WEBAPP_UPDATED = "webapp:%d"

##################################################


# global redis connection pool
_redis_connection_pool = None


BERJON Matthieu's avatar
BERJON Matthieu committed
44
45
46
47
48
49
def get_ssh_data(key):
    """
    Return the fingerprint and comment of a given SSH key.

    It has been tested only on RSA keys
    """
BAIRE Anthony's avatar
BAIRE Anthony committed
50
51
52
53
54
    # FIXME: this implementation computes a MD5 hash, which was superseded a
    #        long time ago. The current openssh fingerprinats are based on
    #        use SHA256, the output looks like:
    #           2048 SHA256:sjsPbfDzfuylskauytlylfpaltjufjhqnphYvVYnhbI
    #        We should use this format too
BERJON Matthieu's avatar
BERJON Matthieu committed
55
56
57
58
59
60
61
62
63
64
65
    key_parts = key.strip().split(None, 2)
    if len(key_parts) == 3:
        comment = key_parts[2]
    else:
        comment = None

    key = base64.b64decode(key.strip().split()[1].encode('ascii'))
    fp_plain = hashlib.md5(key).hexdigest()
    fp_encoded = ':'.join(a+b for a, b in zip(fp_plain[::2], fp_plain[1::2]))

    return fp_encoded, comment
66

67
def upload_data(uploaded_files, job):
68
69
70
71
    """
    Upload any data according to a specific job id

    Args:
72
73
        uploaded_files:   iterable that yields
                          django.core.files.uploadedfile.UploadedFile objects
74
        job (Job):        job
75
76
77

    Examples:

78
        >>> upload_data(self.request.FILES.getlist('files'), job)
79
80
81
82

    Returns:
        Nothing
    """
83

84
    job_dir = job.data_dir
85
86
    os.makedirs(job_dir)

87
    for file_data in uploaded_files:
88
89
90
91
92
93
94
95
96
97
98
99
100
101
        filename = file_data.name

        # sanitise the filename to prevent directory escape
        #
        # The filename is provided by the user submitting the job, it cannot be
        # tructed. Dangerous characters are replaced with "_" so as to
        # guarantee that we do not write anything outside the job dir.
        #
        # This is a security feature, do not remove it.
        #
        if filename in (".", ".."):
            filename = filename.replace(".", "_")
        filename = filename.replace("/", "_")

102
103
        filepath = os.path.join(job_dir, filename)
        with open(filepath, 'wb+') as destination:
104
105
            for chunk in file_data.chunks():
                destination.write(chunk)
106

107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
def lookup_job_file(job_id, filename):
    """Look up a job data file and return its real path

    This function also performs additional security checks to prevent escaping
    from the job data directory:
    - prevent accessing subdirectories or other job directories
    - exclude non-regular files
    - exclude symbolic links

    returns None if lookup fails
    """

    path = os.path.join(settings.DATASTORE, str(job_id), filename)
    if (        "/" not in filename
        and     os.path.isfile(path)
        and not os.path.islink(path)
        ):
        return path
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144

def get_redis_connection():
    "Get a redis connection from the global pool"
    global _redis_connection_pool

    if _redis_connection_pool is None:
        _redis_connection_pool = redis.ConnectionPool(
                host=config.env.ALLGO_REDIS_HOST)

    return redis.Redis(connection_pool=_redis_connection_pool)


def notify_controller(obj):
    """Notify the controller that an entry was updated in the db
    
    The notification is sent through the redis pubsub channel
    REDIS_CHANNEL_CONTROLLER.
    """
    conn = get_redis_connection()

145
    if isinstance(obj, Job):
146
        conn.publish(REDIS_CHANNEL_CONTROLLER, REDIS_MESSAGE_JOB_UPDATED % obj.id)
147
    elif isinstance(obj, Webapp):
148
        conn.publish(REDIS_CHANNEL_CONTROLLER, REDIS_MESSAGE_WEBAPP_UPDATED % obj.id)
149
150
151
    else:
        raise TypeError(obj)

152
153
154
155
156
157
158
159
160
161
162
163

_ALLOWED_IP_NETWORKS = list(map(IPy.IP, config.env.ALLGO_ALLOWED_IP_ADMIN.split(",")))
def is_allowed_ip_admin(ip):
    """Return true if admin actions are allowed from this IP address
    
    The function return true if the provided ip address is included in at least
    one network listed in ALLGO_ALLOWED_IP_ADMIN.
    """
    return any(ip in net for net in _ALLOWED_IP_NETWORKS)



164
165
166
167
168
169
170
171
172
173
174
175
176
177
def get_base_url(request):
    """Extract the base url from this django request object

        typically this will be "https://allgo.inria.fr"
    """
    scheme = request.META.get("HTTP_X_FORWARDED_PROTO", request.scheme)
    # NOTE: django's request.get_host()/.get_port() are kind of broken because
    # they do not expect the port to be provided in the Host/X-Forwarded-Host
    # headers (which is quite common)
    host = request.META.get("HTTP_X_FORWARDED_HOST")
    if host is None:
        host = request.get_host()
    return "%s://%s" % (scheme, host)

178

179
180
181
def get_request_user(request):
    """Return the authenticated user from the provided request

182
183
184
185
186
187
    Depending on the request path, the authentication is attempted on:
    - the token provided in the HTTP Authorization header for /api/ urls
    - the session cookie for other urls

    In case of /auth requests we assume that 'X-Original-URI' is the path of
    the current request.
188
189
190
191
192

    Args:
        request

    Returns:
193
        a User or None
194
    """
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
    path = request.path
    if path == "/auth":
        path = request.META['HTTP_X_ORIGINAL_URI']
    if path.startswith("/api/"):
        # authenticated by token for API requests
        #
        # NOTE: we must NOT authenticate by cookie because the CORS
        #       configuration in the nginx.conf allows all origins
        mo = re.match("Token token=(\S+)",
                request.META.get('HTTP_AUTHORIZATION', ''))
        if mo:
            return getattr(
                    # FIXME: user token should have a unicity constraint
                    AllgoUser.objects.filter(token=mo.group(1)).first(),
                    "user", None)
    else:
        # authenticated by cookie for other requests
        if request.user.is_authenticated:
            return request.user
214
215


216
def query_webapps_for_user(user):
217
218
219
220
    """Return a queryset of all webapps visible by a given user"""

    if user.is_superuser:
        return Webapp.objects.all()
221

222
223
224
    # select webapps that are either public or owned by the user
    # if only_published_version is True, then only published version of non user app
    # are returned.
225
    # version__ refers to WebappVersion_set, with 'related_name'
226
    # cf https://docs.djangoproject.com/en/2.2/topics/db/queries/#following-relationships-backward
227
    q_filter = Q(private=False) & Q(version__published=True)
228

229
230
231
    qs = Webapp.objects \
            .filter(Q(user_id=user.id) | q_filter) \
            .distinct()
232

233
    return qs