1<?php
2
3declare(strict_types=1);
4
5namespace MStilkerich\CardDavAddressbook4Roundcube;
6
7use rcmail;
8use rcube_db;
9use Psr\Log\LoggerInterface;
10
11/**
12 * Access module for the roundcube database.
13 *
14 * The purpose of this class is to decouple all access to the roundcube database from the rest of the plugin. The main
15 * purpose of this is to set a ground for testing, where the actual access to the database (this class) could be
16 * replaced by mocks. The methods of this class should be able to satisfy all query needs of the plugin without the need
17 * to have SQL queries directly inside the plugin, as these would be difficult to parse in a test mock.
18 *
19 * @todo At the moment, this class is just a container for the already existing methods and only partially fulfills its
20 *   purpose stated above.
21 */
22abstract class Database
23{
24    /** @var string[] DBTABLES_WITHOUT_ID List of table names that have no single ID column. */
25    private const DBTABLES_WITHOUT_ID = ['group_user'];
26
27    /** @var LoggerInterface $logger */
28    private static $logger;
29
30    /** @var ?rcube_db $dbHandle */
31    private static $dbHandle;
32
33    /** @var bool $inTransaction Indicates whether we are currently inside a transaction */
34    private static $inTransaction;
35
36    /**
37     * Initializes the Database class.
38     *
39     * Must be called before using any methods in this class.
40     *
41     * @param LoggerInterface $logger A logger object that log messages can be sent to.
42     */
43    public static function init(LoggerInterface $logger, rcube_db $dbh = null): void
44    {
45        self::$logger = $logger;
46
47        self::$inTransaction = false;
48
49        if (isset($dbh)) {
50            self::setDbHandle($dbh);
51        }
52    }
53
54    public static function getDbHandle(): rcube_db
55    {
56        $dbh = self::$dbHandle;
57        if (!isset($dbh)) {
58            $dbh = rcmail::get_instance()->db;
59            self::setDbHandle($dbh);
60        }
61
62        return $dbh;
63    }
64
65    /**
66     * Starts a transaction on the internal DB connection.
67     *
68     * Note that all queries in the transaction must be done using the functions provided by this class, or the database
69     * handle acquired by the getDbHandle() function of this class, to make sure they use the same database connection.
70     *
71     * @see self::getDbHandle()
72     */
73    public static function startTransaction(bool $readonly = true): void
74    {
75        $dbh = self::getDbHandle();
76
77        if (self::$inTransaction) {
78            throw new \Exception("Cannot start nested transaction");
79        } else {
80            // SQLite3 always has Serializable isolation of transactions, and does not support
81            // the SET TRANSACTION command.
82            $level = $readonly ? 'REPEATABLE READ' : 'SERIALIZABLE';
83            $mode  = $readonly ? "READ ONLY" : "READ WRITE";
84
85            switch ($dbh->db_provider) {
86                case "mysql":
87                    $ret = $dbh->query("SET TRANSACTION ISOLATION LEVEL $level, $mode");
88                    break;
89                case "sqlite":
90                    $ret = true;
91                    break;
92                case "postgres":
93                    $ret = $dbh->query("SET SESSION CHARACTERISTICS AS TRANSACTION ISOLATION LEVEL $level, $mode");
94                    break;
95                default:
96                    self::$logger->critical("Unsupported database backend: " . $dbh->db_provider);
97                    return;
98            }
99
100            if ($ret !== false) {
101                $ret = $dbh->startTransaction();
102            }
103
104            if ($ret === false) {
105                self::$logger->error(__METHOD__ . " ERROR: " . $dbh->is_error());
106                throw new DatabaseException($dbh->is_error());
107            }
108
109            self::$inTransaction = true;
110        }
111    }
112
113    /**
114     * Commits the transaction on the internal DB connection.
115     */
116    public static function endTransaction(): void
117    {
118        $dbh = self::getDbHandle();
119
120        if (self::$inTransaction) {
121            self::$inTransaction = false;
122
123            if ($dbh->endTransaction() === false) {
124                self::$logger->error("Database::endTransaction ERROR: " . $dbh->is_error());
125                throw new DatabaseException($dbh->is_error());
126            }
127
128            self::resetTransactionSettings();
129        } else {
130            throw new \Exception("Attempt to commit a transaction while not within a transaction");
131        }
132    }
133
134    /**
135     * Rolls back the transaction on the internal DB connection.
136     */
137    public static function rollbackTransaction(): void
138    {
139        $dbh = self::getDbHandle();
140
141        if (self::$inTransaction) {
142            self::$inTransaction = false;
143            if ($dbh->rollbackTransaction() === false) {
144                self::$logger->error("Database::rollbackTransaction ERROR: " . $dbh->is_error());
145                throw new \Exception($dbh->is_error());
146            }
147
148            self::resetTransactionSettings();
149        } else {
150            // not throwing an error here facilitates usage of the interface at caller side. The caller
151            // can issue rollback without having to keep track whether an error occurred before/after a
152            // transaction was started/ended.
153            self::$logger->notice("Ignored request to rollback a transaction while not within a transaction");
154        }
155    }
156
157    /**
158     * Resets database transaction settings to defaults that should apply to autocommit transactions.
159     */
160    private static function resetTransactionSettings(): void
161    {
162        $logger = self::$logger;
163        $dbh = self::getDbHandle();
164
165        switch ($dbh->db_provider) {
166            case "postgres":
167                $ret = $dbh->query(
168                    "SET SESSION CHARACTERISTICS AS TRANSACTION ISOLATION LEVEL REPEATABLE READ, READ WRITE"
169                );
170                break;
171            default:
172                return;
173        }
174
175        // reset default session characteristics to read/write for autocommit single-statement transactions
176        if ($ret === false) {
177            $logger->warning(__METHOD__ . " ERROR: " . $dbh->is_error());
178        }
179    }
180
181    /**
182     * Checks if the database schema is up to date and performs migrations if needed.
183     *
184     * If this function encounters an error, it will abort execution of the migrations. The database will be
185     * in a potentially broken state, causing further errors when the plugin is executed. Unfortunately, I do not see a
186     * better way to handle errors. Throwing an exception would result in Roundcube not being usable at all for the user
187     * in case of errors.
188     *
189     * @param string $dbPrefix The optional prefix to all database table names as configured in Roundcube.
190     * @param string $scriptDir Path of the parent directory containing all the migration scripts, each in a subdir.
191     */
192    public static function checkMigrations(string $dbPrefix, string $scriptDir): void
193    {
194        $dbh = self::getDbHandle();
195
196        // We only support the non-commercial database types supported by roundcube, so quit with an error
197        switch ($dbh->db_provider) {
198            case "mysql":
199                $db_backend = "mysql";
200                break;
201            case "sqlite":
202                $db_backend = "sqlite3";
203                break;
204            case "postgres":
205                $db_backend = "postgres";
206                break;
207            default:
208                self::$logger->critical("Unsupported database backend: " . $dbh->db_provider);
209                return;
210        }
211
212        // (1) Determine which migration scripts are available, in alphabetical ascending order
213        $migrationsAvailable = [];
214        foreach (scandir($scriptDir, SCANDIR_SORT_ASCENDING) as $migrationDir) {
215            if (preg_match("/^\d{4}-/", $migrationDir)) {
216                $migrationsAvailable[] = $migrationDir;
217            }
218        }
219
220        // (2) Determine which migration scripts have already been executed. This must handle the initial case that
221        //     the plugin's database tables to not exist yet, in which case they will be initialized.
222        $migrationsDone = [];
223        $dbh->set_option('ignore_key_errors', true);
224        $sql_result = $dbh->query('SELECT filename FROM ' . $dbh->table_name('carddav_migrations')) ?: [];
225        while ($processed = $dbh->fetch_assoc($sql_result)) {
226            $migrationsDone[$processed['filename']] = true;
227        }
228        $dbh->set_option('ignore_key_errors', null);
229
230        // (3) Execute the migration scripts that have not been executed before
231        foreach ($migrationsAvailable as $migration) {
232            // skip migrations that have already been done
233            if (key_exists($migration, $migrationsDone)) {
234                continue;
235            }
236
237            self::$logger->notice("In migration: $migration");
238
239            $phpMigrationScript = "$scriptDir/$migration/migrate.php";
240            $sqlMigrationScript = "$scriptDir/$migration/$db_backend.sql";
241
242            if (file_exists($phpMigrationScript)) {
243                include $phpMigrationScript;
244                $migrationClass = "\MStilkerich\CardDavAddressbook4Roundcube\DBMigrations\Migration"
245                    . substr($migration, 0, 4); // the 4-digit number
246
247                /**
248                 * @psalm-suppress InvalidStringClass
249                 * @var DBMigrationInterface $migrationObj
250                 */
251                $migrationObj = new $migrationClass();
252                if ($migrationObj->migrate($dbh, self::$logger) === false) {
253                    return; // error already logged
254                }
255            } elseif (file_exists($sqlMigrationScript)) {
256                if (self::performSqlMigration($sqlMigrationScript, $dbPrefix, $dbh) === false) {
257                    return; // error already logged
258                }
259            } else {
260                self::$logger->warning("No migration script found for: $migration");
261                // do not continue with other scripts that may depend on this one
262                return;
263            }
264
265            $dbh->query(
266                "INSERT INTO " . $dbh->table_name("carddav_migrations") . " (filename) VALUES (?)",
267                $migration
268            );
269
270            if ($dbh->is_error()) {
271                self::$logger->error("Recording exec of migration $migration failed: " . $dbh->is_error());
272                return;
273            }
274        }
275    }
276
277    private static function performSqlMigration(string $migrationScript, string $dbPrefix, rcube_db $dbh): bool
278    {
279        $queries_raw = file_get_contents($migrationScript);
280
281        if ($queries_raw === false) {
282            self::$logger->error("Failed to read migration script: $migrationScript - aborting");
283            return false;
284        }
285
286        $queryCount = preg_match_all('/.+?;/s', $queries_raw, $queries);
287        self::$logger->info("Found $queryCount queries in $migrationScript");
288        if ($queryCount > 0) {
289            foreach ($queries[0] as $query) {
290                $query = str_replace("TABLE_PREFIX", $dbPrefix, $query);
291                $dbh->query($query);
292
293                if ($dbh->is_error()) {
294                    self::$logger->error("Migration query ($query) failed: " . $dbh->is_error());
295                    return false;
296                }
297            }
298        }
299
300        return true;
301    }
302
303    /**
304     * Stores a contact to the local database.
305     *
306     * @param string etag of the VCard in the given version on the CardDAV server
307     * @param string path to the VCard on the CardDAV server
308     * @param string string representation of the VCard
309     * @param array  associative array containing the roundcube save data for the contact
310     * @param ?string optionally, database id of the contact if the store operation is an update
311     *
312     * @return string The database id of the created or updated card.
313     */
314    public static function storeContact(
315        string $abookid,
316        string $etag,
317        string $uri,
318        string $vcfstr,
319        array $save_data,
320        ?string $dbid = null
321    ) {
322        // build email search string
323        $email_keys = preg_grep('/^email(:|$)/', array_keys($save_data));
324        $email_addrs = [];
325        foreach ($email_keys as $email_key) {
326            $email_addrs = array_merge($email_addrs, (array) $save_data[$email_key]);
327        }
328        $save_data['email'] = implode(', ', $email_addrs);
329
330        // extra columns for the contacts table
331        $xcol_all = array('firstname','surname','organization','showas','email');
332        $xcol = [];
333        $xval = [];
334        foreach ($xcol_all as $k) {
335            if (key_exists($k, $save_data)) {
336                $xcol[] = $k;
337                $xval[] = $save_data[$k];
338            }
339        }
340
341        return self::storeAddressObject('contacts', $abookid, $etag, $uri, $vcfstr, $save_data, $dbid, $xcol, $xval);
342    }
343
344    /**
345     * Stores a group in the database.
346     *
347     * If the group is based on a KIND=group vcard, the record must be stored with ETag, URI and VCard. Otherwise, if
348     * the group is derived from a CATEGORIES property of a contact VCard, the ETag, URI and VCard must be set to NULL
349     * to indicate this.
350     *
351     * @param array   associative array containing at least name and cuid (card UID)
352     * @param ?string optionally, database id of the group if the store operation is an update
353     * @param ?string etag of the VCard in the given version on the CardDAV server
354     * @param ?string path to the VCard on the CardDAV server
355     * @param ?string string representation of the VCard
356     *
357     * @return string The database id of the created or updated card.
358     */
359    public static function storeGroup(
360        string $abookid,
361        array $save_data,
362        ?string $dbid = null,
363        ?string $etag = null,
364        ?string $uri = null,
365        ?string $vcfstr = null
366    ) {
367        return self::storeAddressObject('groups', $abookid, $etag, $uri, $vcfstr, $save_data, $dbid);
368    }
369
370    /**
371     * Inserts a new contact or group into the database, or updates an existing one.
372     *
373     * If the address object is not backed by an object on the server side (CATEGORIES-type groups), the parameters
374     * $etag, $uri and $vcfstr are not applicable and shall be passed as NULL.
375     *
376     * @param string $table The target table, without carddav_ prefix (contacts or groups)
377     * @param string $abookid The database ID of the addressbook the address object belongs to.
378     * @param ?string $etag The ETag value of the CardDAV-server address object that this object is created from.
379     * @param ?string $uri  The URI of the CardDAV-server address object that this object is created from.
380     * @param ?string $vcfstr The VCard string of the CardDAV-server address object that this object is created from.
381     * @param string[] $save_data The Roundcube representation of the address object.
382     * @param ?string $dbid If an existing object is updated, this specifies its database id.
383     * @param string[] $xcol Database column names of attributes to insert.
384     * @param string[] $xval The values to insert into the column specified by $xcol at the corresponding index.
385     * @return string The database id of the created or updated card.
386     */
387    private static function storeAddressObject(
388        string $table,
389        string $abookid,
390        ?string $etag,
391        ?string $uri,
392        ?string $vcfstr,
393        array $save_data,
394        ?string $dbid,
395        array $xcol = [],
396        array $xval = []
397    ): string {
398        $dbh = self::getDbHandle();
399
400        $carddesc = $uri ?? "(entry not backed by card)";
401        $xcol[] = 'name';
402        $xval[] = $save_data['name'];
403
404        if (isset($etag)) {
405            $xcol[] = 'etag';
406            $xval[] = $etag;
407        }
408
409        if (isset($vcfstr)) {
410            $xcol[] = 'vcard';
411            $xval[] = $vcfstr;
412        }
413
414        if (isset($dbid)) {
415            self::$logger->debug("UPDATE card $dbid/$carddesc in $table");
416
417            self::update($dbid, $xcol, $xval, $table, 'id');
418        } else {
419            self::$logger->debug("INSERT card $carddesc to $table");
420
421            $xcol[] = 'abook_id';
422            $xval[] = $abookid;
423
424            if (isset($uri)) {
425                $xcol[] = 'uri';
426                $xval[] = $uri;
427            }
428            if (isset($save_data['cuid'])) {
429                $xcol[] = 'cuid';
430                $xval[] = $save_data['cuid'];
431            }
432
433            $dbid = self::insert($table, $xcol, $xval);
434        }
435
436
437        return $dbid;
438    }
439
440    /**
441     * Stores a new entity to the database.
442     *
443     * @param string $table The database table to store the entity to.
444     * @param string[] $cols Database column names of attributes to insert.
445     * @param string[] $vals The values to insert into the column specified by $cols at the corresponding index.
446     * @return string The database id of the created database record. Empty string if the table has no ID column.
447     */
448    public static function insert(string $table, array $cols, array $vals): string
449    {
450        $dbh = self::getDbHandle();
451
452        $sql = 'INSERT INTO ' . $dbh->table_name("carddav_$table") .
453            '(' . implode(",", $cols)  . ') ' .
454            'VALUES (?' . str_repeat(',?', count($cols) - 1) . ')';
455
456        $sql_result = $dbh->query($sql, $vals);
457
458        if (in_array($table, self::DBTABLES_WITHOUT_ID)) {
459            $dbid = "";
460        } else {
461            $dbid = $dbh->insert_id("carddav_$table");
462            $dbid = is_bool($dbid) ? "" /* error thrown below */ : (string) $dbid;
463        }
464        self::$logger->debug("INSERT $table ($sql) -> $dbid");
465
466        if ($dbh->is_error()) {
467            self::$logger->error("Database::insert ($sql) ERROR: " . $dbh->is_error());
468            throw new \Exception($dbh->is_error());
469        }
470
471        return $dbid;
472    }
473
474    /**
475     * Updates records in a database table.
476     *
477     * @param string|string[] $id A single database ID (string) or an array of database IDs if several records should be
478     *                            updated. These IDs are queried against the database column specified by $idfield. Can
479     *                            be a single or multiple values, null is not permitted.
480     * @param string[] $cols      Database column names of attributes to update.
481     * @param string[] $vals      The values to set into the column specified by $cols at the corresponding index.
482     * @param string $table       Name of the database table to select from, without the carddav_ prefix.
483     * @param string $idfield     The name of the column against which $id is matched.
484     * @param array  $other_conditions An associative array with database column names as keys and their match criterion
485     *                                 as value.
486     * @return int                The number of rows updated.
487     * @see self::getConditionQuery()
488     */
489    public static function update(
490        $id,
491        array $cols,
492        array $vals,
493        string $table = 'contacts',
494        string $idfield = 'id',
495        array $other_conditions = []
496    ): int {
497        $dbh = self::getDbHandle();
498        $sql = 'UPDATE ' . $dbh->table_name("carddav_$table") . ' SET ' . implode("=?,", $cols) . '=? WHERE ';
499
500        // Main selection condition
501        $sql .= self::getConditionQuery($dbh, $idfield, $id);
502
503        // Append additional conditions
504        $sql .= self::getOtherConditionsQuery($dbh, $other_conditions);
505
506        self::$logger->debug("UPDATE $table ($sql)");
507        $sql_result = $dbh->query($sql, $vals);
508
509        if ($dbh->is_error()) {
510            self::$logger->error("Database::update ($sql) ERROR: " . $dbh->is_error());
511            throw new DatabaseException($dbh->is_error());
512        }
513
514        return $dbh->affected_rows($sql_result);
515    }
516
517    /**
518     * Gets rows from a database table.
519     *
520     * @param string|string[] $id A single database ID (string) or an array of database IDs if several records should be
521     *                            queried. These IDs are queried against the database column specified by $idfield. Can
522     *                            be a single or multiple values, null is not permitted.
523     * @param string $cols        A comma-separated list of database column names used in the SELECT clause of the SQL
524     *                            statement. By default, all columns are selected.
525     * @param string $table       Name of the database table to select from, without the carddav_ prefix.
526     * @param bool $retsingle     If true, exactly one single row is expected as result. If false, any number of rows is
527     *                            expected as result.
528     * @param string $idfield     The name of the column against which $id is matched.
529     * @param array  $other_conditions An associative array with database column names as keys and their match criterion
530     *                                 as value.
531     * @return array              If $retsingle is true and no error occurred, returns an associative row array with the
532     *                            matching row, where keys are fieldnames and their value is the corresponding database
533     *                            value of the field in the result row. If $retsingle is false, a possibly empty array
534     *                            of such row-arrays is returned.
535     * @see self::getConditionQuery()
536     */
537    public static function get(
538        $id,
539        string $cols = '*',
540        string $table = 'contacts',
541        bool $retsingle = true,
542        string $idfield = 'id',
543        array $other_conditions = []
544    ): array {
545        $dbh = self::getDbHandle();
546
547        $sql = "SELECT $cols FROM " . $dbh->table_name("carddav_$table") . ' WHERE ';
548
549        // Main selection condition
550        $sql .= self::getConditionQuery($dbh, $idfield, $id);
551
552        // Append additional conditions
553        $sql .= self::getOtherConditionsQuery($dbh, $other_conditions);
554
555        $sql_result = $dbh->query($sql);
556
557        if ($dbh->is_error()) {
558            self::$logger->error("Database::get ($sql) ERROR: " . $dbh->is_error());
559            throw new DatabaseException($dbh->is_error());
560        }
561
562        // single result row expected?
563        if ($retsingle) {
564            $ret = $dbh->fetch_assoc($sql_result);
565            if ($ret === false) {
566                throw new \Exception("Single-row query ($sql) without result");
567            }
568            return $ret;
569        } else {
570            // multiple rows requested
571            $ret = [];
572            while ($row = $dbh->fetch_assoc($sql_result)) {
573                $ret[] = $row;
574            }
575            return $ret;
576        }
577    }
578
579    /**
580     * Deletes rows from a database table.
581     *
582     * @param string|string[] $id A single database ID (string) or an array of database IDs if several records should be
583     *                            deleted. These IDs are queried against the database column specified by $idfield. Can
584     *                            be a single or multiple values, null is not permitted.
585     * @param string $table       Name of the database table to select from, without the carddav_ prefix.
586     * @param string $idfield     The name of the column against which $id is matched.
587     * @param array  $other_conditions An associative array with database column names as keys and their match criterion
588     *                                 as value.
589     * @return int                The number of rows deleted.
590     * @see self::getConditionQuery()
591     */
592    public static function delete(
593        $id,
594        string $table = 'contacts',
595        string $idfield = 'id',
596        array $other_conditions = []
597    ): int {
598        $dbh = self::getDbHandle();
599
600        $sql = "DELETE FROM " . $dbh->table_name("carddav_$table") . " WHERE ";
601
602        // Main selection condition
603        $sql .= self::getConditionQuery($dbh, $idfield, $id);
604
605        // Append additional conditions
606        $sql .= self::getOtherConditionsQuery($dbh, $other_conditions);
607
608        self::$logger->debug("Database::delete $sql");
609
610        $sql_result = $dbh->query($sql);
611
612        if ($dbh->is_error()) {
613            self::$logger->error("Database::delete ($sql) ERROR: " . $dbh->is_error());
614            throw new DatabaseException($dbh->is_error());
615        }
616
617        return $dbh->affected_rows($sql_result);
618    }
619
620    /**
621     * Creates a condition query on a database column to be used in an SQL WHERE clause.
622     *
623     * @param rcube_db $dbh The roundcube database handle.
624     * @param string $field Name of the database column.
625     *                      Prefix with ! to invert the condition.
626     *                      Prefix with % to indicate that the value is a pattern to be matched with ILIKE.
627     *                      Prefixes can be combined but must be given in the order listed here.
628     * @param ?string|string[] $value The value to check field for. Can be one of the following:
629     *          - null: Assert that the field is NULL (or not NULL if inverted)
630     *          - string: Assert that the field value matches $value (or does not match value, if inverted)
631     *          - string[]: Assert that the field matches one of the strings in values (or none, if inverted)
632     */
633    private static function getConditionQuery(rcube_db $dbh, string $field, $value): string
634    {
635        $invertCondition = false;
636        $ilike = false;
637
638        if ($field[0] === "!") {
639            $field = substr($field, 1);
640            $invertCondition = true;
641        }
642        if ($field[0] === "%") {
643            $field = substr($field, 1);
644            $ilike = true;
645        }
646
647        $sql = $dbh->quote_identifier($field);
648        if (!isset($value)) {
649            $sql .= $invertCondition ? ' IS NOT NULL' : ' IS NULL';
650        } elseif (is_array($value)) {
651            if (count($value) > 0) {
652                if ($ilike) {
653                    throw new \Exception("getConditionQuery $field - ILIKE match only supported for single pattern");
654                }
655                $quoted_values = array_map([ $dbh, 'quote' ], $value);
656                $sql .= $invertCondition ? " NOT IN" : " IN";
657                $sql .= " (" . implode(",", $quoted_values) . ")";
658            } else {
659                throw new \Exception("getConditionQuery $field - empty values array provided");
660            }
661        } else {
662            if ($ilike) {
663                if ($dbh->db_provider === "mysql") {
664                    $sql .= " COLLATE utf8mb4_unicode_ci ";
665                }
666                $ilikecmd = ($dbh->db_provider === "postgres") ? "ILIKE" : "LIKE";
667                $sql .= $invertCondition ? " NOT $ilikecmd " : " $ilikecmd ";
668            } else {
669                $sql .= $invertCondition ? " <> " : " = ";
670            }
671            $sql .= $dbh->quote($value);
672        }
673
674        return $sql;
675    }
676
677    private static function getOtherConditionsQuery(rcube_db $dbh, array $other_conditions): string
678    {
679        $sql = "";
680
681        foreach ($other_conditions as $field => $value) {
682            $sql .= ' AND ';
683            $sql .= self::getConditionQuery($dbh, $field, $value);
684        }
685
686        return $sql;
687    }
688
689    private static function setDbHandle(rcube_db $dbh): void
690    {
691        self::$dbHandle = $dbh;
692
693        // attempt to enable foreign key constraints on SQLite. May fail if not supported
694        if ($dbh->db_provider == "sqlite") {
695            $dbh->query('PRAGMA FOREIGN_KEYS=ON;');
696        }
697    }
698}
699
700// vim: ts=4:sw=4:expandtab:fenc=utf8:ff=unix:tw=120
701