Repository: nextcloud/appstore

Issue:
Uploading contacts.tar.gz triggers "No possible app folder found"
```
➜  /tmp curl -X POST -u admin https://apps.weasel.rocks/api/v1/apps/releases -H "Content-Type: application/json" -d '{"download":"https://github.com/owncloud/contacts/releases/download/v0.0.0.104/contacts.tar.gz"}'
Enter host password for user 'admin':
{"detail":"No possible app folder found. App folder must contain only lowercase ASCII characters or underscores"}%
```

But from what I see the archive should be compliant? 



Discussion:
Also applies to https://github.com/nextcloud/news/archive/8.8.0.tar.gz so my guess is something with the logic is wrong :)

Already checking :)

Issue here is the regex which is matched on 

<class 'list'>: ['contacts/appinfo', 'contacts/appinfo/app.php', 'contacts/appinfo/info.xml', 'contacts/appinfo/routes.php', 'contacts/controller', 'contacts/controller/pagecontroller.php', 'contacts/css', 'contacts/css/public', 'contacts/css/public/style.css', 'contacts/img', 'contacts/img/app.png', 'contacts/img/app.svg', 'contacts/img/favicon-mask.svg', 'contacts/img/favicon-touch.png', 'contacts/img/favicon-touch.svg', 'contacts/img/favicon.ico', 'contacts/img/favicon.png', 'contacts/img/favicon.svg', 'contacts/l10n', 'contacts/l10n/.tx', 'contacts/l10n/.tx/config', 'contacts/l10n/ar.js', 'contacts/l10n/ar.json', 'contacts/l10n/bg_BG.js', 'contacts/l10n/bg_BG.json', 'contacts/l10n/cs_CZ.js', 'contacts/l10n/cs_CZ.json', 'contacts/l10n/da.js', 'contacts/l10n/da.json', 'contacts/l10n/de.js', 'contacts/l10n/de.json', 'contacts/l10n/de_DE.js', 'contacts/l10n/de_DE.json', 'contacts/l10n/el.js', 'contacts/l10n/el.json', 'contacts/l10n/en_GB.js', 'contacts/l10n/en_GB.json', 'contacts/l10n/es.js', 'contacts/l10n/es.json', 'contacts/l10n/et_EE.js', 'contacts/l10n/et_EE.json', 'contacts/l10n/fi_FI.js', 'contacts/l10n/fi_FI.json', 'contacts/l10n/fr.js', 'contacts/l10n/fr.json', 'contacts/l10n/he.js', 'contacts/l10n/he.json', 'contacts/l10n/hu_HU.js', 'contacts/l10n/hu_HU.json', 'contacts/l10n/is.js', 'contacts/l10n/is.json', 'contacts/l10n/it.js', 'contacts/l10n/it.json', 'contacts/l10n/ja.js', 'contacts/l10n/ja.json', 'contacts/l10n/ko.js', 'contacts/l10n/ko.json', 'contacts/l10n/mk.js', 'contacts/l10n/mk.json', 'contacts/l10n/nb_NO.js', 'contacts/l10n/nb_NO.json', 'contacts/l10n/nl.js', 'contacts/l10n/nl.json', 'contacts/l10n/no-php', 'contacts/l10n/oc.js', 'contacts/l10n/oc.json', 'contacts/l10n/pl.js', 'contacts/l10n/pl.json', 'contacts/l10n/pt_BR.js', 'contacts/l10n/pt_BR.json', 'contacts/l10n/pt_PT.js', 'contacts/l10n/pt_PT.json', 'contacts/l10n/ro.js', 'contacts/l10n/ro.json', 'contacts/l10n/ru.js', 'contacts/l10n/ru.json', 'contacts/l10n/sl.js', 'contacts/l10n/sl.json', 'contacts/l10n/sq.js', 'contacts/l10n/sq.json', 'contacts/l10n/sv.js', 'contacts/l10n/sv.json', 'contacts/l10n/th_TH.js', 'contacts/l10n/th_TH.json', 'contacts/l10n/tr.js', 'contacts/l10n/tr.json', 'contacts/l10n/zh_CN.js', 'contacts/l10n/zh_CN.json', 'contacts/l10n/zh_TW.js', 'contacts/l10n/zh_TW.json', 'contacts/templates', 'contacts/templates/addressBook.html', 'contacts/templates/addressBookList.html', 'contacts/templates/avatar.html', 'contacts/templates/contact.html', 'contacts/templates/contactDetails.html', 'contacts/templates/contactImport.html', 'contacts/templates/contactList.html', 'contacts/templates/detailItems', 'contacts/templates/detailItems/adr.html', 'contacts/templates/detailItems/date.html', 'contacts/templates/detailItems/groups.html', 'contacts/templates/detailItems/hidden.html', 'contacts/templates/detailItems/n.html', 'contacts/templates/detailItems/tel.html', 'contacts/templates/detailItems/text.html', 'contacts/templates/detailItems/textarea.html', 'contacts/templates/detailItems/url.html', 'contacts/templates/group.html', 'contacts/templates/groupList.html', 'contacts/templates/main.php', 'contacts/templates/part.settings.php', 'contacts/js/public', 'contacts/js/public/script.js', 'contacts/js/dav/dav.js', 'contacts/js/vendor/angular/angular.js', 'contacts/js/vendor/angular-route/angular-route.js', 'contacts/js/vendor/angular-cache/dist/angular-cache.js', 'contacts/js/vendor/angular-uuid4/angular-uuid4.js', 'contacts/js/vendor/vcard/src/vcard.js', 'contacts/js/vendor/angular-bootstrap/ui-bootstrap.min.js', 'contacts/js/vendor/angular-bootstrap/ui-bootstrap-tpls.min.js', 'contacts/js/vendor/angular-sanitize/angular-sanitize.js', 'contacts/js/vendor/ui-select/dist/select.js', 'contacts/js/vendor/jquery-timepicker/jquery.ui.timepicker.js']

Every element starts with contacts/ however the parser only matches https://github.com/nextcloud/appstore/blob/master/nextcloudappstore/core/api/v1/release/parser.py#L37

In some cases it seems that the folder is there as single name, sometimes not. Will go to bed now ;)

BTW fix is a bit more complicated since we also need to extract the folder name.


Resolving patch:
diff --git a/nextcloudappstore/core/api/v1/release/parser.py b/nextcloudappstore/core/api/v1/release/parser.py
--- a/nextcloudappstore/core/api/v1/release/parser.py
+++ b/nextcloudappstore/core/api/v1/release/parser.py
@@ -1,7 +1,7 @@
 import re
 import tarfile  # type: ignore
 import lxml.etree  # type: ignore
-from typing import Dict, Any, Tuple
+from typing import Dict, Any, Tuple, List, Set
 
 from nextcloudappstore.core.api.v1.release import ReleaseConfig
 from nextcloudappstore.core.versioning import pad_max_version, pad_min_version
@@ -34,7 +34,7 @@ def __init__(self, config: ReleaseConfig) -> None:
         :argument config the config
         """
         self.config = config
-        self.app_folder_regex = re.compile(r'^[a-z]+[a-z_]*$')
+        self.app_folder_regex = re.compile(r'^[a-z]+[a-z_]*(?:/.*)*$')
 
     def extract_app_metadata(self, archive_path: str) -> Tuple[str, str]:
         """
@@ -54,26 +54,30 @@ def extract_app_metadata(self, archive_path: str) -> Tuple[str, str]:
         return result
 
     def _parse_archive(self, tar: Any) -> Tuple[str, str]:
-        folder = list(
-            filter(lambda name: re.match(self.app_folder_regex, name),
-                   tar.getnames()
-                   )
-        )
-        if len(folder) > 1:
+        folders = self._find_app_folders(tar.getnames())
+        if len(folders) > 1:
             msg = 'More than one possible app folder found'
             raise InvalidAppPackageStructureException(msg)
-        elif len(folder) == 0:
+        elif len(folders) == 0:
             msg = 'No possible app folder found. App folder must contain ' \
                   'only lowercase ASCII characters or underscores'
             raise InvalidAppPackageStructureException(msg)
 
-        app_id = folder[0]
+        app_id = folders.pop()
         info_path = '%s/appinfo/info.xml' % app_id
         try:
-            app_member = tar.getmember(app_id)
-            appinfo_member = tar.getmember('%s/appinfo' % app_id)
             info_member = tar.getmember(info_path)
-            possible_links = [app_member, appinfo_member, info_member]
+            possible_links = [info_member]
+            # its complicated, sometimes there are single members, sometimes
+            # there aren't
+            try:
+                possible_links.append(tar.getmember(app_id))
+            except KeyError:
+                pass
+            try:
+                possible_links.append(tar.getmember('%s/appinfo' % app_id))
+            except KeyError:
+                pass
 
             for possible_link in possible_links:
                 if possible_link.issym() or possible_link.islnk():
@@ -115,6 +119,12 @@ def _stream_read_file(self, info_file: Any, max_info_size: int) -> str:
 
         return result.decode('utf-8')
 
+    def _find_app_folders(self, members: List[str]) -> Set[str]:
+        regex = self.app_folder_regex
+        matching_members = filter(lambda f: re.match(regex, f), members)
+        folders = map(lambda m: m.split('/')[0], matching_members)
+        return set(folders)
+
 
 def element_to_dict(element: Any) -> Dict:
     type = element.get('type')


Test patch:
diff --git a/nextcloudappstore/core/api/v1/tests/data/archives/contacts.tar.gz b/nextcloudappstore/core/api/v1/tests/data/archives/contacts.tar.gz
new file mode 100644
Binary files /dev/null and b/nextcloudappstore/core/api/v1/tests/data/archives/contacts.tar.gz differ
diff --git a/nextcloudappstore/core/api/v1/tests/test_parser.py b/nextcloudappstore/core/api/v1/tests/test_parser.py
--- a/nextcloudappstore/core/api/v1/tests/test_parser.py
+++ b/nextcloudappstore/core/api/v1/tests/test_parser.py
@@ -72,6 +72,12 @@ def test_entities(self):
                                self.config.pre_info_xslt,
                                self.config.info_xslt)
 
+    def test_extract_contracts(self):
+        path = self.get_path('data/archives/contacts.tar.gz')
+        extractor = GunZipAppMetadataExtractor(self.config)
+        full_extracted, app_id = extractor.extract_app_metadata(path)
+        self.assertEqual('contacts', app_id)
+
     def test_extract_gunzip_info(self):
         path = self.get_path('data/archives/full.tar.gz')
         extractor = GunZipAppMetadataExtractor(self.config)