1# -*- coding: ascii -*-
2"""
3web2ldap.app.entry - schema-aware Entry classes
4
5web2ldap - a web-based LDAP Client,
6see https://www.web2ldap.de for details
7
8(c) 1998-2021 by Michael Stroeder <michael@stroeder.com>
9
10This software is distributed under the terms of the
11Apache License Version 2.0 (Apache-2.0)
12https://www.apache.org/licenses/LICENSE-2.0
13"""
14
15from collections import UserDict
16from io import BytesIO
17
18import ldap0.schema.models
19from ldap0.cidict import CIDict
20from ldap0.schema.models import (
21    AttributeType,
22    ObjectClass,
23    SchemaElementOIDSet,
24)
25from ldap0.schema.subentry import SubSchema
26from ldap0.dn import DNObj
27from ldap0.ldif import LDIFWriter
28
29import web2ldapcnf
30
31from ..log import logger
32from ..msbase import GrabKeys
33
34from .tmpl import get_variant_filename
35from .gui import HIDDEN_FIELD
36from .schema import (
37    NEEDS_BINARY_TAG,
38    no_userapp_attr,
39    object_class_categories,
40)
41from .schema.viewer import schema_anchor
42from .schema.syntaxes import (
43    LDAPSyntaxValueError,
44    OctetString,
45    syntax_registry,
46)
47
48
49INPUT_FORM_LDIF_TMPL = """
50<fieldset>
51  <legend>Raw LDIF data</legend>
52  <textarea name="in_ldif" rows="50" cols="80" wrap="off">{value_ldif}</textarea>
53  <p>
54    Notes:
55  </p>
56  <ul>
57    <li>Lines containing "dn:" will be ignored</li>
58    <li>Only the first entry (until first empty line) will be accepted</li>
59    <li>Maximum length is set to {value_ldifmaxbytes} bytes</li>
60    <li>Allowed URL schemes: {text_ldifurlschemes}</li>
61  </ul>
62</fieldset>
63"""
64
65
66class DisplayEntry(UserDict):
67
68    def __init__(self, app, dn, schema, entry, sep_attr, links):
69        assert isinstance(dn, str), TypeError("Argument 'dn' must be str, was %r" % (dn))
70        assert isinstance(schema, SubSchema), \
71            TypeError('Expected schema to be instance of SubSchema, was %r' % (schema))
72        self._app = app
73        self.schema = schema
74        self.dn = dn
75        if isinstance(entry, dict):
76            self.entry = ldap0.schema.models.Entry(schema, dn, entry)
77        elif isinstance(entry, ldap0.schema.models.Entry):
78            self.entry = entry
79        else:
80            raise TypeError(
81                'Invalid type of argument entry, was %s.%s %r' % (
82                    entry.__class__.__module__,
83                    entry.__class__.__name__,
84                    entry,
85                )
86            )
87        self.soc = self.entry.get_structural_oc()
88        self.invalid_attrs = set()
89        self.sep_attr = sep_attr
90        self.links = links
91
92    def __getitem__(self, nameoroid):
93        try:
94            values = self.entry.__getitem__(nameoroid)
95        except KeyError:
96            return ''
97        result = []
98        syntax_se = syntax_registry.get_syntax(self.entry._s, nameoroid, self.soc)
99        for i, value in enumerate(values):
100            attr_instance = syntax_se(
101                self._app,
102                self.dn,
103                self.entry._s,
104                nameoroid,
105                value,
106                self.entry,
107            )
108            try:
109                attr_value_html = attr_instance.display(i, self.links)
110            except UnicodeError:
111                # Fall back to hex-dump output
112                attr_instance = OctetString(
113                    self._app,
114                    self.dn,
115                    self.schema,
116                    nameoroid,
117                    value,
118                    self.entry,
119                )
120                attr_value_html = attr_instance.display(i, self.links)
121            try:
122                attr_instance.validate(value)
123            except LDAPSyntaxValueError:
124                attr_value_html = '<s>%s</s>' % (attr_value_html)
125                self.invalid_attrs.add(nameoroid)
126            result.append(attr_value_html)
127        if self.sep_attr is not None:
128            value_sep = getattr(attr_instance, self.sep_attr)
129            return value_sep.join(result)
130        return result
131
132    @property
133    def rdn_dict(self):
134        return DNObj.from_str(self.dn).rdn_attrs()
135
136    def get_html_templates(self, cnf_key):
137        read_template_dict = CIDict(self._app.cfg_param(cnf_key, {}))
138        # This gets all object classes no matter what
139        all_object_class_oid_set = self.entry.object_class_oid_set()
140        # Initialize the set with only the STRUCTURAL object class of the entry
141        object_class_oid_set = SchemaElementOIDSet(
142            self.entry._s, ldap0.schema.models.ObjectClass, []
143        )
144        structural_oc = self.entry.get_structural_oc()
145        if structural_oc:
146            object_class_oid_set.add(structural_oc)
147        # Now add the other AUXILIARY and ABSTRACT object classes
148        for ocl in all_object_class_oid_set:
149            ocl_obj = self.entry._s.get_obj(ldap0.schema.models.ObjectClass, ocl)
150            if ocl_obj is None or ocl_obj.kind != 0:
151                object_class_oid_set.add(ocl)
152        template_oc = object_class_oid_set.intersection(read_template_dict.data.keys())
153        return template_oc.names, read_template_dict
154        # end of get_html_templates()
155
156    def template_output(self, cnf_key, display_duplicate_attrs=True):
157        # Determine relevant HTML templates
158        template_oc, read_template_dict = self.get_html_templates(cnf_key)
159        # Sort the object classes by object class category
160        structural_oc, abstract_oc, auxiliary_oc = object_class_categories(
161            self.entry._s,
162            template_oc,
163        )
164        # Templates defined => display the entry with the help of the template
165        used_templates = set()
166        displayed_attrs = set()
167        for oc_set in (structural_oc, abstract_oc, auxiliary_oc):
168            for ocl in oc_set:
169                read_template_filename = read_template_dict[ocl]
170                logger.debug('Template file name %r defined for %r', read_template_dict[ocl], ocl)
171                if not read_template_filename:
172                    logger.warning('Ignoring empty template file name for %r', ocl)
173                    continue
174                read_template_filename = get_variant_filename(
175                    read_template_filename,
176                    self._app.form.accept_language,
177                )
178                if read_template_filename in used_templates:
179                    # template already processed
180                    logger.debug(
181                        'Skipping already processed template file name %r for %r',
182                        read_template_dict[ocl],
183                        ocl,
184                    )
185                    continue
186                used_templates.add(read_template_filename)
187                try:
188                    with open(read_template_filename, 'rb') as template_file:
189                        template_str = template_file.read().decode('utf-8')
190                except IOError as err:
191                    logger.error(
192                        'Error reading template file %r for %r: %s',
193                        read_template_dict[ocl],
194                        ocl,
195                        err,
196                    )
197                    continue
198                template_attr_oid_set = {
199                    self.entry._s.get_oid(ldap0.schema.models.AttributeType, attr_type_name)
200                    for attr_type_name in GrabKeys(template_str)()
201                }
202                if (
203                        display_duplicate_attrs
204                        or not displayed_attrs.intersection(template_attr_oid_set)
205                    ):
206                    self._app.outf.write(template_str % self)
207                    displayed_attrs.update(template_attr_oid_set)
208        return displayed_attrs
209
210
211class InputFormEntry(DisplayEntry):
212
213    def __init__(
214            self, app, dn, schema, entry,
215            readonly_attr_oids,
216            existing_object_classes=None,
217            invalid_attrs=None
218        ):
219        assert isinstance(dn, str), TypeError("Argument 'dn' must be str, was {!r}".format(dn))
220        DisplayEntry.__init__(self, app, dn, schema, entry, 'field_sep', False)
221        self.existing_object_classes = existing_object_classes
222        self.readonly_attr_oids = readonly_attr_oids
223        self.invalid_attrs = invalid_attrs or {}
224        new_object_classes = set(self.entry.object_class_oid_set()) - {
225            self.entry._s.get_oid(ObjectClass, oc_name)
226            for oc_name in existing_object_classes or []
227        }
228        new_attribute_types = self.entry._s.attribute_types(
229            new_object_classes,
230            raise_keyerror=0,
231            ignore_dit_content_rule=self._app.ls.relax_rules
232        )
233        old_attribute_types = self.entry._s.attribute_types(
234            existing_object_classes or [],
235            raise_keyerror=0,
236            ignore_dit_content_rule=self._app.ls.relax_rules
237        )
238        self.new_attribute_types_oids = set()
239        self.new_attribute_types_oids.update(new_attribute_types[0].keys())
240        self.new_attribute_types_oids.update(new_attribute_types[1].keys())
241        for at_oid in list(old_attribute_types[0].keys())+list(old_attribute_types[1].keys()):
242            try:
243                self.new_attribute_types_oids.remove(at_oid)
244            except KeyError:
245                pass
246
247    def _reset_input_counters(self):
248        self.attr_counter = 0
249        self.row_counter = 0
250        # end of _reset_input_counters()
251
252    def __getitem__(self, nameoroid):
253        """
254        Return HTML input field(s) for the attribute specified by nameoroid.
255        """
256        oid = self.entry.name2key(nameoroid)[0]
257        nameoroid_se = self.entry._s.get_obj(AttributeType, nameoroid)
258        syntax_class = syntax_registry.get_syntax(self.entry._s, nameoroid, self.soc)
259        try:
260            attr_values = self.entry.__getitem__(nameoroid)
261        except KeyError:
262            attr_values = []
263        # Attribute value list must contain at least one element to display an input field
264        attr_values = attr_values or [None]
265
266        result = []
267
268        # Eliminate binary attribute values from input form
269        if not syntax_class.editable:
270            attr_values = [b'']
271
272        invalid_attr_indexes = set(self.invalid_attrs.get(nameoroid, []))
273
274        for attr_index, attr_value in enumerate(attr_values):
275
276            attr_inst = syntax_class(
277                self._app, self.dn, self.entry._s, nameoroid, attr_value, self.entry,
278            )
279            highlight_invalid = attr_index in invalid_attr_indexes
280
281            if (
282                    # Attribute type 'objectClass' always read-only here
283                    oid == '2.5.4.0'
284                ) or (
285                    # Attribute type 'structuralObjectClass' always read-only no matter what
286                    oid == '2.5.21.9'
287                ) or (
288                    # Check whether the server indicated this attribute
289                    # not to be writeable by bound identity
290                    not self.readonly_attr_oids is None and
291                    oid in self.readonly_attr_oids and
292                    not oid in self.new_attribute_types_oids
293                ) or (
294                    # Check whether attribute type/value is used in the RDN => not writeable
295                    self.existing_object_classes and
296                    attr_value and
297                    nameoroid in self.rdn_dict and
298                    self.rdn_dict[nameoroid].encode('utf-8') == attr_value
299                ) or (
300                    # Set to writeable if relax rules control is in effect
301                    # and attribute is NO-USER-APP in subschema
302                    not self._app.ls.relax_rules and
303                    no_userapp_attr(self.entry._s, oid)
304                ):
305                result.append('\n'.join((
306                    '<span class="InvalidInput">'*highlight_invalid,
307                    self._app.form.hidden_field_html('in_at', nameoroid, ''),
308                    HIDDEN_FIELD % ('in_avi', str(self.attr_counter), ''),
309                    HIDDEN_FIELD % (
310                        'in_av',
311                        self._app.form.s2d(attr_inst.form_value(), sp_entity='  '),
312                        self._app.form.s2d(attr_inst.form_value(), sp_entity='&nbsp;&nbsp;')
313                    ),
314                    '</span>'*highlight_invalid,
315                )))
316                self.row_counter += 1
317
318            else:
319                attr_title = ''
320                attr_type_tags = []
321                attr_type_name = str(nameoroid).split(';')[0]
322                if nameoroid_se:
323                    attr_type_name = (nameoroid_se.names or [nameoroid_se.oid])[0]
324                    try:
325                        attr_title = (nameoroid_se.desc or '')
326                    except UnicodeError:
327                        # This happens sometimes because of wrongly encoded schema files
328                        attr_title = ''
329                    # Determine whether transfer syntax has to be specified with ;binary
330                    if (
331                            nameoroid.endswith(';binary') or
332                            oid in NEEDS_BINARY_TAG or
333                            nameoroid_se.syntax in NEEDS_BINARY_TAG
334                        ):
335                        attr_type_tags.append('binary')
336                input_fields = attr_inst.input_fields()
337                for input_field in input_fields:
338                    input_field.name = 'in_av'
339                    input_field.charset = self._app.form.accept_charset
340                    result.append('\n'.join([
341                        '<span class="InvalidInput">'*highlight_invalid,
342                        HIDDEN_FIELD % (
343                            'in_at',
344                            ';'.join([attr_type_name]+attr_type_tags),
345                            ''
346                        ),
347
348                        HIDDEN_FIELD % ('in_avi', str(self.attr_counter), ''),
349                        input_field.input_html(
350                            id_value='_'.join((
351                                'inputattr', attr_type_name, str(attr_index)
352                            )),
353                            title=attr_title
354                        ),
355                        attr_inst.value_button(self._app.command, self.row_counter, '+'),
356                        attr_inst.value_button(self._app.command, self.row_counter, '-'),
357                        '</span>'*highlight_invalid,
358                    ]))
359                    self.row_counter += 1
360
361            self.attr_counter += 1
362
363        return '<a class="hide" id="in_a_%s"></a>%s' % (
364            self._app.form.s2d(nameoroid),
365            '\n<br>\n'.join(result),
366        )
367
368    def attribute_types(self):
369        # Initialize a list of assertions for filtering attribute types
370        # displayed in the input form
371        attr_type_filter = [
372            ('no_user_mod', [0]),
373            #('usage', range(2)),
374            ('collective', [0]),
375        ]
376        # Check whether Manage DIT control is in effect,
377        # filter out OBSOLETE attribute types otherwise
378        if not self._app.ls.relax_rules:
379            attr_type_filter.append(('obsolete', [0]))
380
381        # Filter out extensibleObject
382        object_class_oids = self.entry.object_class_oid_set()
383        try:
384            object_class_oids.remove('1.3.6.1.4.1.1466.101.120.111')
385        except KeyError:
386            pass
387        try:
388            object_class_oids.remove('extensibleObject')
389        except KeyError:
390            pass
391
392        required_attrs_dict, allowed_attrs_dict = self.entry._s.attribute_types(
393            list(object_class_oids),
394            attr_type_filter=attr_type_filter,
395            raise_keyerror=0,
396            ignore_dit_content_rule=self._app.ls.relax_rules,
397        )
398
399        # Additional check whether to explicitly add object class attribute.
400        # This is a work-around for LDAP servers which mark the
401        # objectClass attribute as not modifiable (e.g. MS Active Directory)
402        if '2.5.4.0' not in required_attrs_dict and '2.5.4.0' not in allowed_attrs_dict:
403            required_attrs_dict['2.5.4.0'] = self.entry._s.get_obj(ObjectClass, '2.5.4.0')
404        return required_attrs_dict, allowed_attrs_dict
405
406    def fieldset_table(self, attr_types_dict, fieldset_title):
407        self._app.outf.write(
408            """<fieldset title="%s">
409            <legend>%s</legend>
410            <table summary="%s">
411            """ % (fieldset_title, fieldset_title, fieldset_title)
412        )
413        seen_attr_type_oids = ldap0.cidict.CIDict()
414        attr_type_names = ldap0.cidict.CIDict()
415        for atype in self.entry.keys():
416            at_oid = self.entry.name2key(atype)[0]
417            if at_oid in attr_types_dict:
418                seen_attr_type_oids[at_oid] = None
419                attr_type_names[atype] = None
420        for at_oid, at_se in attr_types_dict.items():
421            if (
422                    at_se and
423                    at_oid not in seen_attr_type_oids and
424                    not no_userapp_attr(self.entry._s, at_oid)
425                ):
426                attr_type_names[(at_se.names or (at_se.oid,))[0]] = None
427        attr_types = list(attr_type_names.keys())
428        attr_types.sort(key=str.lower)
429        for attr_type in attr_types:
430            attr_type_name = schema_anchor(self._app, attr_type, AttributeType, link_text='&raquo')
431            attr_value_field_html = self[attr_type]
432            self._app.outf.write(
433                '<tr>\n<td class="InputAttrType">\n%s\n</td>\n<td>\n%s\n</td>\n</tr>\n' % (
434                    attr_type_name,
435                    attr_value_field_html,
436                )
437            )
438        self._app.outf.write('</table>\n</fieldset>\n')
439        # end of fieldset_table()
440
441    def table_input(self, attrs_dict_list):
442        self._reset_input_counters()
443        for attr_dict, fieldset_title in attrs_dict_list:
444            if attr_dict:
445                self.fieldset_table(attr_dict, fieldset_title)
446        # end of table_input()
447
448    def template_output(self, cnf_key, display_duplicate_attrs=True):
449        self._reset_input_counters()
450        displayed_attrs = DisplayEntry.template_output(
451            self, cnf_key, display_duplicate_attrs=display_duplicate_attrs
452        )
453        # Output hidden fields for attributes not displayed in template-based input form
454        for attr_type, attr_values in self.entry.items():
455            at_oid = self.entry.name2key(attr_type)[0]
456            syntax_class = syntax_registry.get_syntax(self.entry._s, attr_type, self.soc)
457            if syntax_class.editable and \
458               not no_userapp_attr(self.entry._s, attr_type) and \
459               not at_oid in displayed_attrs:
460                for attr_value in attr_values:
461                    attr_inst = syntax_class(
462                        self._app, self.dn, self.entry._s, attr_type, attr_value, self.entry
463                    )
464                    self._app.outf.write(self._app.form.hidden_field_html('in_at', attr_type, ''))
465                    self._app.outf.write(HIDDEN_FIELD % ('in_avi', str(self.attr_counter), ''))
466                    try:
467                        attr_value_html = self._app.form.s2d(attr_inst.form_value(), sp_entity='  ')
468                    except UnicodeDecodeError:
469                        # Simply display an empty string if anything goes wrong with Unicode
470                        # decoding (e.g. with binary attributes)
471                        attr_value_html = ''
472                    self._app.outf.write(HIDDEN_FIELD % (
473                        'in_av', attr_value_html, ''
474                    ))
475                    self.attr_counter += 1
476        return displayed_attrs # template_output()
477
478    def ldif_input(self):
479        bio = BytesIO()
480        ldif_writer = LDIFWriter(bio)
481        ldap_entry = {}
482        for attr_type in self.entry.keys():
483            attr_values = self.entry.__getitem__(attr_type)
484            if not no_userapp_attr(self.entry._s, attr_type):
485                ldap_entry[attr_type.encode('ascii')] = [
486                    attr_value
487                    for attr_value in attr_values
488                    if attr_value
489                ]
490        ldif_writer.unparse(self.dn.encode(self._app.ls.charset), ldap_entry)
491        self._app.outf.write(
492            INPUT_FORM_LDIF_TMPL.format(
493                value_ldif=self._app.form.s2d(
494                    bio.getvalue().decode('utf-8'),
495                    sp_entity='  ',
496                    lf_entity='\n',
497                ),
498                value_ldifmaxbytes=web2ldapcnf.ldif_maxbytes,
499                text_ldifurlschemes=', '.join(web2ldapcnf.ldif_url_schemes)
500            )
501        )
502        # end of ldif_input()
503