Projects
Kolab:16:Testing:Candidate
kolab-syncroton
Log In
Username
Password
Overview
Repositories
Revisions
Requests
Users
Attributes
Meta
Expand all
Collapse all
Changes of Revision 19
View file
kolab-syncroton.spec
Changed
@@ -36,7 +36,7 @@ %global _ap_sysconfdir %{_sysconfdir}/%{httpd_name} Name: kolab-syncroton -Version: 2.4.1 +Version: 2.4.2 Release: 1%{?dist} Summary: ActiveSync for Kolab Groupware @@ -193,6 +193,9 @@ %attr(0770,%{httpd_user},%{httpd_group}) %{_var}/log/%{name} %changelog +* Thu Jul 27 2023 Christian Mollekopf <mollekopf@apheleia-it.ch> - 2.4.2-1 +- Release version 2.4.2 + * Mon Jul 17 2023 Christian Mollekopf <mollekopf@apheleia-it.ch> - 2.4.1-1 - Release version 2.4.1
View file
buildtarball.sh
Changed
@@ -2,7 +2,7 @@ set -e -VERSION=2.4.1 +VERSION=2.4.2 GIT_REF=master NAME=kolab-syncroton-$VERSION
View file
debian.changelog
Changed
@@ -1,3 +1,9 @@ +kolab-syncroton (2.4.2-0~kolab1) unstable; urgency=low + + * Release version 2.4.2 + + -- Christian Mollekopf <mollekopf@apheleia-it.ch> Thu, 27 July 2023 15:13:40 +0200 + kolab-syncroton (2.4.1-0~kolab1) unstable; urgency=low * Release version 2.4.1
View file
kolab-syncroton-2.4.1.tar.gz/lib/ext/Syncroton/Server.php -> kolab-syncroton-2.4.2.tar.gz/lib/ext/Syncroton/Server.php
Changed
@@ -123,10 +123,11 @@ $decoder = new Syncroton_Wbxml_Decoder($this->_body); $requestBody = $decoder->decode(); if ($this->_logger instanceof Zend_Log) { - $requestBody->formatOutput = true; - $this->_logger->debug(__METHOD__ . '::' . __LINE__ . " xml request:\n" . $requestBody->saveXML()); + $this->_logDomDocument($requestBody, 'request', __METHOD__, __LINE__); } } catch(Syncroton_Wbxml_Exception_UnexpectedEndOfFile $e) { + if ($this->_logger instanceof Zend_Log) + $this->_logger->warn(__METHOD__ . '::' . __LINE__ . " unexpected end of file."); $requestBody = NULL; } } else { @@ -174,7 +175,7 @@ if ($response instanceof DOMDocument) { if ($this->_logger instanceof Zend_Log) { - $this->_logDomDocument(Zend_Log::DEBUG, $response, __METHOD__, __LINE__); + $this->_logDomDocument($response, 'response', __METHOD__, __LINE__); } if (isset($command) && $command instanceof Syncroton_Command_ICommand) { @@ -244,13 +245,17 @@ /** * write (possible big) DOMDocument in smaller chunks to log file * - * @param int $priority * @param DOMDocument $dom + * @param string $action * @param string $method * @param int $line */ - protected function _logDomDocument($priority, DOMDocument $dom, $method, $line) + protected function _logDomDocument(DOMDocument $dom, $action, $method, $line) { + if (method_exists($this->_logger, 'hasDebug') && !$this->_logger->hasDebug()) { + return; + } + $tempStream = tmpfile(); $meta_data = stream_get_meta_data($tempStream); @@ -261,8 +266,12 @@ $dom->formatOutput = false; rewind($tempStream); - - $this->_logger->log($method . '::' . $line . " xml response(first 4k):\n" . fread($tempStream, 4 * 1024), $priority); + + $loops = 0; + while (!feof($tempStream)) { + $this->_logger->debug("{$method}::{$line} xml {$action} ({$loops}):\n" . fread($tempStream, 1048576)); + $loops++; + } fclose($tempStream); }
View file
kolab-syncroton-2.4.1.tar.gz/lib/ext/Syncroton/Wbxml/Exception/UnexpectedEndOfFile.php -> kolab-syncroton-2.4.2.tar.gz/lib/ext/Syncroton/Wbxml/Exception/UnexpectedEndOfFile.php
Changed
@@ -19,5 +19,5 @@ class Syncroton_Wbxml_Exception_UnexpectedEndOfFile extends Syncroton_Wbxml_Exception { - protected $message = 'unexpcted end of file detected'; -} \ No newline at end of file + protected $message = 'unexpected end of file detected'; +}
View file
kolab-syncroton-2.4.1.tar.gz/lib/kolab_sync.php -> kolab-syncroton-2.4.2.tar.gz/lib/kolab_sync.php
Changed
@@ -49,7 +49,7 @@ protected $per_user_log_dir; const CHARSET = 'UTF-8'; - const VERSION = "2.4.1"; + const VERSION = "2.4.2"; /**
View file
kolab-syncroton-2.4.1.tar.gz/lib/kolab_sync_backend.php -> kolab-syncroton-2.4.2.tar.gz/lib/kolab_sync_backend.php
Changed
@@ -365,7 +365,7 @@ return false; } - $metadata = $metadata$name; + $metadata = isset($metadata$name) ? $metadata$name : array(); if ($flag) { if (empty($metadata)) { @@ -385,8 +385,7 @@ // 2 - synchronize with alarms $metadata'FOLDER'$deviceid'S' = $flag; } - - if (!$flag) { + else { unset($metadata'FOLDER'$deviceid'S'); if (empty($metadata'FOLDER'$deviceid)) { @@ -403,7 +402,7 @@ } // Return if nothing's been changed - if (!self::data_array_diff($this->folder_meta$name, $metadata)) { + if (!self::data_array_diff(isset($this->folder_meta$name) ? $this->folder_meta$name : null, $metadata)) { return true; }
View file
kolab-syncroton-2.4.1.tar.gz/lib/kolab_sync_data.php -> kolab-syncroton-2.4.2.tar.gz/lib/kolab_sync_data.php
Changed
@@ -1771,35 +1771,39 @@ $ex_list = array(); // exceptions (modified occurences) - foreach ((array)$data'recurrence''EXCEPTIONS' as $exception) { - $exception'_mailbox' = $data'_mailbox'; + if (!empty($data'recurrence''EXCEPTIONS')) { + foreach ((array)$data'recurrence''EXCEPTIONS' as $exception) { + $exception'_mailbox' = $data'_mailbox'; - $ex = $this->getEntry($collection, $exception, true); - $date = clone ($exception'recurrence_date' ?: $ex'startTime'); + $ex = $this->getEntry($collection, $exception, true); + $date = clone ($exception'recurrence_date' ?: $ex'startTime'); - $ex'exceptionStartTime' = self::set_exception_time($date, $data'_start'); + $ex'exceptionStartTime' = self::set_exception_time($date, $data'_start'); - // remove fields not supported by Syncroton_Model_EventException - unset($ex'uID'); + // remove fields not supported by Syncroton_Model_EventException + unset($ex'uID'); - // @TODO: 'thisandfuture=true' is not supported in Activesync - // we'd need to slit the event into two separate events + // @TODO: 'thisandfuture=true' is not supported in Activesync + // we'd need to slit the event into two separate events - $ex_list = new Syncroton_Model_EventException($ex); + $ex_list = new Syncroton_Model_EventException($ex); + } } // exdate (deleted occurences) - foreach ((array)$data'recurrence''EXDATE' as $exception) { - if (!($exception instanceof DateTime)) { - continue; - } + if (!empty($data'recurrence''EXDATE')) { + foreach ((array)$data'recurrence''EXDATE' as $exception) { + if (!($exception instanceof DateTime)) { + continue; + } - $ex = array( - 'deleted' => 1, - 'exceptionStartTime' => self::set_exception_time($exception, $data'_start'), - ); + $ex = array( + 'deleted' => 1, + 'exceptionStartTime' => self::set_exception_time($exception, $data'_start'), + ); - $ex_list = new Syncroton_Model_EventException($ex); + $ex_list = new Syncroton_Model_EventException($ex); + } } return $ex_list;
View file
kolab-syncroton-2.4.1.tar.gz/lib/kolab_sync_data_calendar.php -> kolab-syncroton-2.4.2.tar.gz/lib/kolab_sync_data_calendar.php
Changed
@@ -185,20 +185,9 @@ $config = $this->getFolderConfig($event'_mailbox'); $result = array(); - // Timezone // Kolab Format 3.0 and xCal does support timezone per-date, but ActiveSync allows // only one timezone per-event. We'll use timezone of the start date - if ($event'start' instanceof DateTime) { - $timezone = $event'start'->getTimezone(); - - if ($timezone && ($tz_name = $timezone->getName()) != 'UTC') { - $tzc = kolab_sync_timezone_converter::getInstance(); - - if ($tz_name = $tzc->encodeTimezone($tz_name, $event'start'->format('Y-m-d'))) { - $result'timezone' = $tz_name; - } - } - } + $result'timezone' = kolab_sync_timezone_converter::encodeTimezoneFromDate($event'start'); // Calendar namespace fields foreach ($this->mapping as $key => $name) { @@ -332,7 +321,7 @@ } // Event meeting status - $this->meeting_status_from_kolab($collection, $event, $result); + $this->meeting_status_from_kolab($event, $result); // Recurrence (and exceptions) $this->recurrence_from_kolab($collection, $event, $result); @@ -356,8 +345,23 @@ */ public function toKolab(Syncroton_Model_IEntry $data, $folderid, $entry = null, $timezone = null) { + $foldername = isset($entry'_mailbox') ? $entry'_mailbox' : $this->getFolderName($folderid); + if (empty($entry)) { + // If we don't have an existing event (not a modification) we nevertheless check for conflicts. + // This is necessary so we don't overwrite the server-side copy in case the client did not have it available + // when generating an Add command. + try { + $folder = $this->getFolderObject($foldername); + $entry = $folder->get_object($data->uID); + + if ($entry) { + $this->logger->debug('Found and existing event for UID: ' . $data->uID); + } + } catch (Exception $e) { + // uID is not available on exceptions, so we guard for that and silently ignore. + } + } $event = !empty($entry) ? $entry : array(); - $foldername = isset($event'_mailbox') ? $event'_mailbox' : $this->getFolderName($folderid); $config = $this->getFolderConfig($foldername); $is_exception = $data instanceof Syncroton_Model_EventException; $dummy_tz = str_repeat('A', 230) . '=='; @@ -386,7 +390,7 @@ } if (empty($timezone)) { - $timezone = $old_timezone ?: new DateTimeZone('UTC'); + $timezone = !empty($old_timezone) ? $old_timezone : new DateTimeZone('UTC'); } $event'allday' = 0; @@ -490,10 +494,10 @@ // this update resets attendee status set in the MeetingResponse request. // We ignore changes to attendees data on such updates if ($is_outlook && $this->isDummyOutlookUpdate($data, $entry, $event)) { + $this->logger->debug('Dummy outlook update detected, ignoring attendee changes.'); $attendees = $entry'attendees'; } else if (isset($data->attendees)) { - $statusMap = array_flip($this->attendeeStatusMap); foreach ($data->attendees as $attendee) { if (!empty($organizer_email) && $attendee->email && !strcasecmp($attendee->email, $organizer_email)) { // skip the organizer @@ -819,10 +823,12 @@ $event'attendees'$i'status' = $status; $event'attendees'$i'rsvp' = false; $event_attendee = $attendee; + $this->logger->debug('Updating existing attendee: ' . $attendee'email' . ' status: ' . $status); } } if (empty($event_attendee)) { + $this->logger->debug('Adding new attendee ' . $emails0 . ' status: ' . $status); // Add the user to the attendees list $event'attendees' = array( 'role' => 'OPT-PARTICIPANT', @@ -872,7 +878,7 @@ /** * Set MeetingStatus according to event data */ - protected function meeting_status_from_kolab($collection, $event, &$result) + protected function meeting_status_from_kolab($event, &$result) { // 0 - The event is an appointment, which has no attendees. // 1 - The event is a meeting and the user is the meeting organizer.
View file
kolab-syncroton-2.4.1.tar.gz/lib/kolab_sync_data_email.php -> kolab-syncroton-2.4.2.tar.gz/lib/kolab_sync_data_email.php
Changed
@@ -118,6 +118,48 @@ } /** + * Encode a globalObjId according to https://interoperability.blob.core.windows.net/files/MS-ASEMAIL/%5bMS-ASEMAIL%5d-150526.pdf 2.2.2.3 + * + * @param array $data An array with the data to encode + * + * @return string the encoded globalObjId + */ + public static function encodeGlobalObjId(array $data): string + { + $classid = "040000008200e00074c5b7101a82e008"; + $uid = $data'uid'; + $vcalid = "vCal-Uid\1\0\0\0{$uid}\0"; + + $packed = pack( + "H32nCCPx8Va*", + $classid, + $data'year' ?? 0, + $data'month' ?? 0, + $data'day' ?? 0, + $data'now' ?? 0, + strlen($vcalid), + $vcalid + ); + + return base64_encode($packed); + } + + /** + * Decode a globalObjId according to https://interoperability.blob.core.windows.net/files/MS-ASEMAIL/%5bMS-ASEMAIL%5d-150526.pdf 2.2.2.3 + * + * @param string the encoded globalObjId + * + * @return array An array with the decoded data + */ + public static function decodeGlobalObjId(string $globalObjId): array + { + $unpackString = 'H32classid/nyear/Cmonth/Cday/Pnow/x8/Vbytecount/a*data'; + $decoded = unpack($unpackString, base64_decode($globalObjId)); + $decoded'uid' = substr($decoded'data', strlen("vCal-Uid\1\0\0\0"), -1); + return $decoded; + } + + /** * Creates model object * * @param Syncroton_Model_SyncCollection $collection Collection data @@ -339,19 +381,73 @@ $result'nativeBodyType' = $message->has_html_part() ? 2 : 1; // Message class + $result'messageClass' = 'IPM.Note'; + $result'contentClass' = 'urn:content-classes:message'; + if ($headers->ctype == 'multipart/signed' - && count($message->attachments) == 1 && $message->attachments0->mimetype == 'application/pkcs7-signature' + && !empty($message->parts1) + && $message->parts1->mimetype == 'application/pkcs7-signature' ) { $result'messageClass' = 'IPM.Note.SMIME.MultipartSigned'; } else if ($headers->ctype == 'application/pkcs7-mime' || $headers->ctype == 'application/x-pkcs7-mime') { $result'messageClass' = 'IPM.Note.SMIME'; } - else { - $result'messageClass' = 'IPM.Note'; - } + else if ($event = $this->get_invitation_event_from_message($message)) { + $result'messageClass' = 'IPM.Schedule.Meeting.Request'; + $result'contentClass' = 'urn:content-classes:calendarmessage'; + + $meeting = array(); + + $meeting'allDayEvent' = $event'allday' ?? null ? 1 : 0; + $meeting'startTime' = $event'start'; + $meeting'dtStamp' = $event'created' ?? null; + $meeting'endTime' = $event'end' ?? null; + + //TODO implement recurrences. We can't detect exceptions like this (don't know how), and the recurrences structure is different from event, + //so that also doesn't work like this. + // if (isset($event'recurrence''EXCEPTIONS')) { + // $meeting'instanceType' = Syncroton_Model_EmailMeetingRequest::TYPE_RECURRING_EXCEPTION; + // $this->recurrence_from_kolab($collection, $event, $meeting); + // // } else if (isset($event'recurrence')) { + // // $meeting'instanceType' = Syncroton_Model_EmailMeetingRequest::TYPE_RECURRING_SINGLE; + // // $meeting'recurrenceId' = set the date; + // } else if (isset($event'recurrence')) { + // $meeting'instanceType' = Syncroton_Model_EmailMeetingRequest::TYPE_RECURRING_MASTER; + // $this->recurrence_from_kolab($collection, $event, $meeting); + // } else { + // $meeting'instanceType' = Syncroton_Model_EmailMeetingRequest::TYPE_NORMAL; + // } + $meeting'instanceType' = Syncroton_Model_EmailMeetingRequest::TYPE_NORMAL; + + $meeting'location' = $event'location' ?? null; + + // Organizer + if (!empty($event'attendees')) { + foreach ($event'attendees' as $idx => $attendee) { + if ($attendee'role' == 'ORGANIZER') { + if ($email = $attendee'email') { + $meeting'organizer' = $email; + } + break; + } + } + } - $result'contentClass' = 'urn:content-classes:message'; + // Kolab Format 3.0 and xCal does support timezone per-date, but ActiveSync allows + // only one timezone per-event. We'll use timezone of the start date + $meeting'timeZone' = kolab_sync_timezone_converter::encodeTimezoneFromDate($event'start'); + $meeting'globalObjId' = self::encodeGlobalObjId('uid' => $event'uid'); + + //TODO handle other methods + if ($event'_method' == 'REQUEST') { + $meeting'meetingMessageType' = Syncroton_Model_EmailMeetingRequest::MESSAGE_TYPE_REQUEST; + } else { + $meeting'meetingMessageType' = Syncroton_Model_EmailMeetingRequest::MESSAGE_TYPE_NORMAL; + } + + $result'meetingRequest' = new Syncroton_Model_EmailMeetingRequest($meeting); + } // Categories (Tags) if (isset($this->tag_categories) && $this->tag_categories) { @@ -584,13 +680,22 @@ * @param string $folderId Folder identifier * @param Syncroton_Model_IEntry $entry Entry * - * @return array + * @return string ID of the created entry */ public function createEntry($folderId, Syncroton_Model_IEntry $entry) { - // Throw exception here for better handling of unsupported - // entry creation, it can be object of class Email or SMS here - throw new Syncroton_Exception_Status_Sync(Syncroton_Exception_Status_Sync::INVALID_ITEM); + // Creating emails is not normally supported like this, but is implemented for testing purposes + $foldername = $this->backend->folder_id2name($folderId, $this->device->deviceid); + + $flag = !empty($entry->read) ? 'SEEN' : 'UNSEEN'; + $uid = $this->storage->save_message($foldername, $entry->body->data, '', false, $flag); + + if (!$uid) { + $this->logger->error("Error while storing the message " . $this->storage->get_error_str()); + throw new Syncroton_Exception_Status_Sync(Syncroton_Exception_Status_Sync::SYNC_SERVER_ERROR); + } + + return $this->createMessageId($folderId, $uid); } /** @@ -1265,13 +1370,13 @@ $entryid = $entryid'itemId'; } - // Note: the id might be in a form of <folder>::<uid>::<part_id> - list($folderid, $uid) = explode('::', $entryid); - - if (empty($uid)) { + if (!is_string($entryid) || !strpos($entryid, '::')) { return; } + // Note: the id might be in a form of <folder>::<uid>::<part_id> + list($folderid, $uid) = explode('::', $entryid); + $foldername = $this->backend->folder_id2name($folderid, $this->device->deviceid); if ($foldername === null || $foldername === false) { @@ -1674,22 +1779,33 @@ /** * Returns calendar event data from the iTip invitation attached to a mail message */ + public function get_invitation_event_from_message($message) + { + // Parse the message and find iTip attachments + $libcal = libcalendaring::get_instance(); + $libcal->mail_message_load(array('object' => $message)); + $ical_objects = $libcal->get_mail_ical_objects(); + + // We support only one event in the iTip + foreach ($ical_objects as $mime_id => $event) { + if ($event'_type' == 'event') { + $event'_method' = $ical_objects->method; + return $event; + } + } + return null; + } + + /** + * Returns calendar event data from the iTip invitation attached to a mail message + */ public function get_invitation_event($messageId) { // Get the mail message object if ($message = $this->getObject($messageId)) { - // Parse the message and find iTip attachments - $libcal = libcalendaring::get_instance(); - $libcal->mail_message_load(array('object' => $message)); - $ical_objects = $libcal->get_mail_ical_objects(); - - // We support only one event in the iTip - foreach ($ical_objects as $mime_id => $event) { - if ($event'_type' == 'event') { - return $event; - } - } + return $this->get_invitation_event_from_message($message); } + return null; }
View file
kolab-syncroton-2.4.1.tar.gz/lib/kolab_sync_data_tasks.php -> kolab-syncroton-2.4.2.tar.gz/lib/kolab_sync_data_tasks.php
Changed
@@ -117,7 +117,7 @@ $result = array(); // Completion status (required) - $result'complete' = intval($task'status' == 'COMPLETED' || $task'complete' == 100); + $result'complete' = intval($task'status' ?? null == 'COMPLETED' || $task'complete' ?? null == 100); // Calendar namespace fields foreach ($this->mapping as $key => $name) {
View file
kolab-syncroton-2.4.1.tar.gz/lib/kolab_sync_logger.php -> kolab-syncroton-2.4.2.tar.gz/lib/kolab_sync_logger.php
Changed
@@ -59,6 +59,17 @@ } /** + * Check whether debug logging is enabled + * + * @return bool + */ + public function hasDebug() + { + // This is what we check in self::log() below + return !empty($this->log_dir) && $this->mode >= self::NOTICE; + } + + /** * Message logger * * @param string $message Log message
View file
kolab-syncroton-2.4.1.tar.gz/lib/kolab_sync_message.php -> kolab-syncroton-2.4.2.tar.gz/lib/kolab_sync_message.php
Changed
@@ -388,9 +388,11 @@ // Unify char-case of header names $headers = array(); foreach ($lines as $line) { - list($field, $string) = explode(':', $line, 2); - if ($field = self::normalize_header_name($field)) { - $headers$field = trim($string); + if (strpos($line, ':') !== false) { + list($field, $string) = explode(':', $line, 2); + if ($field = self::normalize_header_name($field)) { + $headers$field = trim($string); + } } }
View file
kolab-syncroton-2.4.1.tar.gz/lib/kolab_sync_timezone_converter.php -> kolab-syncroton-2.4.2.tar.gz/lib/kolab_sync_timezone_converter.php
Changed
@@ -217,6 +217,29 @@ return $this->_packTimezoneInfo($offsets); } + + /** + * Returns an encoded timezone representation from $date + * + * @param DateTime $date The date with the timezone to encode + * + * @return string encoded timezone + */ + public static function encodeTimezoneFromDate($date) + { + if ($date instanceof DateTime) { + $timezone = $date->getTimezone(); + + if ($timezone && ($tz_name = $timezone->getName()) != 'UTC') { + $tzc = self::getInstance(); + if ($tz_name = $tzc->encodeTimezone($tz_name, $date->format('Y-m-d'))) { + return $tz_name; + } + } + } + return null; + } + /** * Get offsets for given timezone *
View file
kolab-syncroton-2.4.2.tar.gz/tests/globalid_converter.php
Added
@@ -0,0 +1,32 @@ +<?php + +require_once "../lib/kolab_sync_data.php"; +require_once "../lib/kolab_sync_data_email.php"; + +class globalid_converter extends PHPUnit\Framework\TestCase +{ + function test_decode() + { + // https://learn.microsoft.com/en-us/openspecs/exchange_server_protocols/ms-asemail/e7424ddc-dd10-431e-a0b7-5c794863370e + $input = 'BAAAAIIA4AB0xbcQGoLgCAAAAAAAAAAAAAAAAAAAAAAAAAAAMwAAAHZDYWwtVWlkAQAAAHs4MTQxMkQzQy0yQTI0LTRFOUQtQjIwRS0xMUY3QkJFOTI3OTl9AA=='; + $output = kolab_sync_data_email::decodeGlobalObjId($input); + + $this->assertSame(51, $output'bytecount'); + $this->assertSame('{81412D3C-2A24-4E9D-B20E-11F7BBE92799}', $output'uid'); + + $encoded = kolab_sync_data_email::encodeGlobalObjId($output); + $this->assertSame($encoded, $input); + + + $input = 'BAAAAIIA4AB0xbcQGoLgCAfUCRDgQMnBJoXEAQAAAAAAAAAAEAAAAAvw7UtuTulOnjnjhns3jvM='; + $output = kolab_sync_data_email::decodeGlobalObjId($input); + + $this->assertSame(16, $output'bytecount'); + $this->assertSame(2004, $output'year'); + $this->assertSame(9, $output'month'); + $this->assertSame(16, $output'day'); + //FIXME we don't currently implement non ical uids + // $encoded = kolab_sync_data_email::encodeGlobalObjId($output); + // $this->assertSame($encoded, $input); + } +}
View file
kolab-syncroton.dsc
Changed
@@ -2,7 +2,7 @@ Source: kolab-syncroton Binary: kolab-syncroton Architecture: all -Version: 2.4.1-1~kolab1 +Version: 2.4.2-1~kolab1 Maintainer: Jeroen van Meeuwen <vanmeeuwen@kolabsys.com> Uploaders: Jeroen van Meeuwen <vanmeeuwen@kolabsys.com> Homepage: http://www.kolab.org/
Locations
Projects
Search
Status Monitor
Help
Open Build Service
OBS Manuals
API Documentation
OBS Portal
Reporting a Bug
Contact
Mailing List
Forums
Chat (IRC)
Twitter
Open Build Service (OBS)
is an
openSUSE project
.