Repository: conan-io/conan

Issue:
Conan server can't be setup in the subdirectory of a domain
I want to run Conan at, say, `https://example.com/conan` behind Apache.

Currently, there is no way to do this. If I set `host_name` to `example.com/conan` and `port` to `443`, the Conan client will try to upload to `https://example.com/conan:443/v1/...`


Discussion:
There are 2 important end-points in the conan api:

- One for "logic", which is the main end-point
- One for transfers uploads/downloads, different to the above. 

You need to configure the second too. There are some details in: http://docs.conan.io/en/latest/server.html#server-parameters ([server] section). Have you set them up, something similar to the Nginx section?
```
host_name: example.com
port: 443
```
Tries to upload to `https://example.com:443/v1/...`.

```
host_name: example.com/conan
port: 443
```
Tries to upload to `https://example.com/conan:443/v1/...`.
Oh, sorry, I didn't read well your original post.

It seems it is not possible, could you configure a subdomain ``conan.example.com`` instead of that? If not possible, I think it could be an easy feature
Yes, this is what I did for now. I guess adding, say, `directory` variable and appending it after `host_name` wouldn't be hard.

Resolving patch:
diff --git a/conans/client/rest/rest_client.py b/conans/client/rest/rest_client.py
--- a/conans/client/rest/rest_client.py
+++ b/conans/client/rest/rest_client.py
@@ -11,7 +11,7 @@
 from conans.model.manifest import FileTreeManifest
 from conans.client.rest.uploader_downloader import Uploader, Downloader
 from conans.model.ref import ConanFileReference
-from six.moves.urllib.parse import urlsplit, parse_qs, urlencode
+from six.moves.urllib.parse import urlsplit, parse_qs, urlencode, urlparse, urljoin
 from conans import COMPLEX_SEARCH_CAPABILITY
 from conans.search.search import filter_packages
 from conans.model.info import ConanInfo
@@ -103,7 +103,7 @@ def get_conan_digest(self, conan_reference):
 
         # Obtain the URLs
         url = "%s/conans/%s/digest" % (self._remote_api_url, "/".join(conan_reference))
-        urls = self._get_json(url)
+        urls = self._get_file_to_url_dict(url)
 
         # Get the digest
         contents = self.download_files(urls)
@@ -118,7 +118,7 @@ def get_package_digest(self, package_reference):
         url = "%s/conans/%s/packages/%s/digest" % (self._remote_api_url,
                                                    "/".join(package_reference.conan),
                                                    package_reference.package_id)
-        urls = self._get_json(url)
+        urls = self._get_file_to_url_dict(url)
 
         # Get the digest
         contents = self.download_files(urls)
@@ -132,7 +132,7 @@ def get_package_info(self, package_reference):
         url = "%s/conans/%s/packages/%s/download_urls" % (self._remote_api_url,
                                                           "/".join(package_reference.conan),
                                                           package_reference.package_id)
-        urls = self._get_json(url)
+        urls = self._get_file_to_url_dict(url)
         if not urls:
             raise NotFoundException("Package not found!")
 
@@ -149,7 +149,7 @@ def get_recipe(self, conan_reference, dest_folder, filter_files_function):
         """Gets a dict of filename:contents from conans"""
         # Get the conanfile snapshot first
         url = "%s/conans/%s/download_urls" % (self._remote_api_url, "/".join(conan_reference))
-        urls = self._get_json(url)
+        urls = self._get_file_to_url_dict(url)
 
         urls = filter_files_function(urls)
         if not urls:
@@ -164,7 +164,7 @@ def get_package(self, package_reference, dest_folder):
         url = "%s/conans/%s/packages/%s/download_urls" % (self._remote_api_url,
                                                           "/".join(package_reference.conan),
                                                           package_reference.package_id)
-        urls = self._get_json(url)
+        urls = self._get_file_to_url_dict(url)
         if not urls:
             raise NotFoundException("Package not found!")
         # TODO: Get fist an snapshot and compare files and download only required?
@@ -198,7 +198,7 @@ def upload_recipe(self, conan_reference, the_files, retry, retry_wait, ignore_de
             url = "%s/conans/%s/upload_urls" % (self._remote_api_url, "/".join(conan_reference))
             filesizes = {filename.replace("\\", "/"): os.stat(abs_path).st_size
                          for filename, abs_path in files_to_upload.items()}
-            urls = self._get_json(url, data=filesizes)
+            urls = self._get_file_to_url_dict(url, data=filesizes)
             self.upload_files(urls, files_to_upload, self._output, retry, retry_wait)
         if deleted:
             self._remove_conanfile_files(conan_reference, deleted)
@@ -230,7 +230,7 @@ def upload_package(self, package_reference, the_files, retry, retry_wait):
             filesizes = {filename: os.stat(abs_path).st_size for filename,
                          abs_path in files_to_upload.items()}
             self._output.rewrite_line("Requesting upload permissions...")
-            urls = self._get_json(url, data=filesizes)
+            urls = self._get_file_to_url_dict(url, data=filesizes)
             self._output.rewrite_line("Requesting upload permissions...Done!")
             self._output.writeln("")
             self.upload_files(urls, files_to_upload, self._output, retry, retry_wait)
@@ -388,6 +388,20 @@ def _post_json(self, url, payload):
                                        json=payload)
         return response
 
+    def _complete_url(self, url):
+        """ Ensures that an url is absolute by completing relative urls with
+            the remote url. urls that are already absolute are not modified.
+        """
+        if bool(urlparse(url).netloc):
+            return url
+        return urljoin(self.remote_url, url)
+
+    def _get_file_to_url_dict(self, url, data=None):
+        """Call to url and decode the json returning a dict of {filepath: url} dict
+        converting the url to a complete url when needed"""
+        urls = self._get_json(url, data=data)
+        return {filepath: self._complete_url(url) for filepath, url in urls.items()}
+
     def _get_json(self, url, data=None):
         t1 = time.time()
         headers = self.custom_headers
@@ -509,7 +523,7 @@ def get_path(self, conan_reference, package_id, path):
                                                               "/".join(conan_reference),
                                                               package_id)
         try:
-            urls = self._get_json(url)
+            urls = self._get_file_to_url_dict(url)
         except NotFoundException:
             if package_id:
                 raise NotFoundException("Package %s:%s not found" % (conan_reference, package_id))
diff --git a/conans/server/conf/__init__.py b/conans/server/conf/__init__.py
--- a/conans/server/conf/__init__.py
+++ b/conans/server/conf/__init__.py
@@ -49,7 +49,9 @@ def __init__(self, base_folder, storage_folder=None, environment=os.environ):
                            "users": get_env("CONAN_SERVER_USERS", None, environment)}
 
     def _get_file_conf(self, section, varname=None):
-        """Gets the section from config file or raises an exception"""
+        """ Gets the section or variable from config file.
+        If the queried element is not found an exception is raised.
+        """
         try:
             if not os.path.exists(self.config_filename):
                 jwt_random_secret = ''.join(random.choice(string.ascii_letters) for _ in range(24))
@@ -71,7 +73,7 @@ def _get_file_conf(self, section, varname=None):
                 return section[varname]
             else:
                 return self.items(section)
-        except NoSectionError as exc:
+        except NoSectionError:
             raise ConanException("No section '%s' found" % section)
         except Exception as exc:
             logger.debug(exc)
@@ -80,51 +82,55 @@ def _get_file_conf(self, section, varname=None):
 
     @property
     def ssl_enabled(self):
-        if self.env_config["ssl_enabled"]:
-            return self.env_config["ssl_enabled"] == "true" or \
-                   self.env_config["ssl_enabled"] == "1"
-        else:
-            return self._get_file_conf("server", "ssl_enabled").lower() == "true" or \
-                   self._get_file_conf("server", "ssl_enabled").lower() == "1"
+        try:
+            ssl_enabled = self._get_conf_server_string("ssl_enabled").lower()
+            return ssl_enabled == "true" or ssl_enabled == "1"
+        except ConanException:
+            return None
 
     @property
     def port(self):
-        if self.env_config["port"]:
-            return int(self.env_config["port"])
-        else:
-            return int(self._get_file_conf("server", "port"))
+        return int(self._get_conf_server_string("port"))
 
     @property
     def public_port(self):
-        if self.env_config["public_port"]:
-            return int(self.env_config["public_port"])
-        elif self._get_file_conf("server", "public_port"):
-            return int(self._get_file_conf("server", "public_port"))
-        else:
+        try:
+            return int(self._get_conf_server_string("public_port"))
+        except ConanException:
             return self.port
 
     @property
     def host_name(self):
-        return self._get_conf_server_string("host_name")
+        try:
+            return self._get_conf_server_string("host_name")
+        except ConanException:
+            return None
 
     @property
     def public_url(self):
-        protocol = "https" if self.ssl_enabled else "http"
-        port = ":%s" % self.public_port if self.public_port != 80 else ""
-        return "%s://%s%s/v1" % (protocol, self.host_name, port)
+        host_name = self.host_name
+        ssl_enabled = self.ssl_enabled
+        protocol_version = "v1"
+        if host_name is None and ssl_enabled is None:
+            # No hostname and ssl config means that the transfer and the
+            # logical endpoint are the same and a relative URL is sufficient
+            return protocol_version
+        elif host_name is None or ssl_enabled is None:
+            raise ConanException("'host_name' and 'ssl_enable' have to be defined together.")
+        else:
+            protocol = "https" if ssl_enabled else "http"
+            port = ":%s" % self.public_port if self.public_port != 80 else ""
+            return "%s://%s%s/%s" % (protocol, host_name, port, protocol_version)
 
     @property
     def disk_storage_path(self):
         """If adapter is disk, means the directory for storage"""
-        if self.env_config["disk_storage_path"]:
-            ret = self.env_config["disk_storage_path"]
-        else:
-            try:
-                ret = conan_expand_user(self._get_file_conf("server", "disk_storage_path"))
-            except ConanException:
-                # If storage_path is not defined in file, use the current dir
-                # So tests use test folder instead of user/.conan_server
-                ret = os.path.dirname(self.config_filename)
+        try:
+            ret = conan_expand_user(self._get_conf_server_string("disk_storage_path"))
+        except ConanException:
+            # If storage_path is not defined, use the current dir
+            # So tests use test folder instead of user/.conan_server
+            ret = os.path.dirname(self.config_filename)
         ret = os.path.normpath(ret)  # Convert to O.S paths
         mkdir(ret)
         return ret
@@ -170,45 +176,45 @@ def validate_pass_encoding(password):
 
     @property
     def jwt_secret(self):
-        tmp = self._get_conf_server_string("jwt_secret")
-        if not tmp:
+        try:
+            return self._get_conf_server_string("jwt_secret")
+        except ConanException:
             raise ConanException("'jwt_secret' setting is needed. Please, write a value "
                                  "in server.conf or set CONAN_JWT_SECRET env value.")
-        return tmp
+
 
     @property
     def updown_secret(self):
-        tmp = self._get_conf_server_string("updown_secret")
-        if not tmp:
+        try:
+            return self._get_conf_server_string("updown_secret")
+        except ConanException:
             raise ConanException("'updown_secret' setting is needed. Please, write a value "
                                  "in server.conf or set CONAN_UPDOWN_SECRET env value.")
-        return self._get_conf_server_string("updown_secret")
 
     @property
     def store_adapter(self):
         return self._get_conf_server_string("store_adapter")
 
     def _get_conf_server_string(self, keyname):
+        """ Gets the value of a server config value either from the environment
+        or the config file. Values from the environment have priority. If the
+        value is not defined or empty an exception is raised.
+        """
         if self.env_config[keyname]:
             return self.env_config[keyname]
-        else:
-            return self._get_file_conf("server", keyname)
+
+        value = self._get_file_conf("server", keyname)
+        if value == "":
+            raise ConanException("no value for 'server.%s' is defined in the config file" % keyname)
+        return value
 
     @property
     def authorize_timeout(self):
-        if self.env_config["authorize_timeout"]:
-            return timedelta(seconds=int(self.env_config["authorize_timeout"]))
-        else:
-            tmp = self._get_file_conf("server", "authorize_timeout")
-            return timedelta(seconds=int(tmp))
+        return timedelta(seconds=int(self._get_conf_server_string("authorize_timeout")))
 
     @property
     def jwt_expire_time(self):
-        if self.env_config["jwt_expire_minutes"]:
-            return timedelta(minutes=int(self.env_config["jwt_expire_minutes"]))
-        else:
-            tmp = float(self._get_file_conf("server", "jwt_expire_minutes"))
-            return timedelta(minutes=tmp)
+        return timedelta(minutes=float(self._get_conf_server_string("jwt_expire_minutes")))
 
 
 def get_file_manager(config, public_url=None, updown_auth_manager=None):


Test patch:
diff --git a/conans/test/integration/complete_test.py b/conans/test/integration/complete_test.py
--- a/conans/test/integration/complete_test.py
+++ b/conans/test/integration/complete_test.py
@@ -1,4 +1,7 @@
 import unittest
+
+from nose_parameterized import parameterized
+
 from conans.test.utils.tools import TestServer, TestClient
 from conans.model.ref import ConanFileReference, PackageReference
 import os
@@ -11,12 +14,12 @@
 @attr("slow")
 class CompleteFlowTest(unittest.TestCase):
 
-    def setUp(self):
-        test_server = TestServer()
+    @parameterized.expand([(True, ), (False, )])
+    def reuse_test(self, complete_urls):
+        test_server = TestServer(complete_urls=complete_urls)
         self.servers = {"default": test_server}
         self.client = TestClient(servers=self.servers, users={"default": [("lasote", "mypass")]})
 
-    def reuse_test(self):
         conan_reference = ConanFileReference.loads("Hello0/0.1@lasote/stable")
         files = cpp_hello_conan_files("Hello0", "0.1", need_patch=True)
         self.client.save(files)
@@ -80,7 +83,6 @@ def reuse_test(self):
             self._assert_library_exists(ref, other_conan.paths)
 
         client3 = TestClient(servers=self.servers, users={"default": [("lasote", "mypass")]})
-        conan_reference = ConanFileReference.loads("Hello1/0.2@lasote/stable")
         files3 = cpp_hello_conan_files("Hello1", "0.1", ["Hello0/0.1@lasote/stable"])
         client3.save(files3)
         client3.run('install .')
diff --git a/conans/test/remote/rest_api_test.py b/conans/test/remote/rest_api_test.py
--- a/conans/test/remote/rest_api_test.py
+++ b/conans/test/remote/rest_api_test.py
@@ -49,6 +49,37 @@ def tearDownClass(cls):
     def tearDown(self):
         RestApiTest.server.clean()
 
+    def relative_url_completion_test(self):
+        api = RestApiClient(TestBufferConanOutput(), requester=requests)
+
+        # test absolute urls
+        self.assertEquals(api._complete_url("http://host"), "http://host")
+        self.assertEquals(api._complete_url("http://host:1234"), "http://host:1234")
+        self.assertEquals(api._complete_url("https://host"), "https://host")
+        self.assertEquals(api._complete_url("https://host:1234"), "https://host:1234")
+
+        # test relative urls
+        api.remote_url = "http://host"
+        self.assertEquals(api._complete_url("v1/path_to_file.txt"),
+                          "http://host/v1/path_to_file.txt")
+
+        api.remote_url = "http://host:1234"
+        self.assertEquals(api._complete_url("v1/path_to_file.txt"),
+                          "http://host:1234/v1/path_to_file.txt")
+
+        api.remote_url = "https://host"
+        self.assertEquals(api._complete_url("v1/path_to_file.txt"),
+                          "https://host/v1/path_to_file.txt")
+
+        api.remote_url = "https://host:1234"
+        self.assertEquals(api._complete_url("v1/path_to_file.txt"),
+                          "https://host:1234/v1/path_to_file.txt")
+
+        # test relative urls with subdirectory
+        api.remote_url = "https://host:1234/subdir/"
+        self.assertEquals(api._complete_url("v1/path_to_file.txt"),
+                          "https://host:1234/subdir/v1/path_to_file.txt")
+
     def server_info_test(self):
         check, version, capabilities = self.api.server_info()
         self.assertEquals(version, "0.16.0")
diff --git a/conans/test/server/conan_server_config_parser_test.py b/conans/test/server/conan_server_config_parser_test.py
--- a/conans/test/server/conan_server_config_parser_test.py
+++ b/conans/test/server/conan_server_config_parser_test.py
@@ -53,3 +53,20 @@ def test_not_allowed_encoding_password(self):
 
         server_config = ConanServerConfigParser(tmp_dir, environment={"CONAN_SERVER_USERS": "demo:manolito!@"})
         self.assertEquals(server_config.users, {"demo": "manolito!@"})
+
+    def test_relative_public_url(self):
+        tmp_dir = temp_folder()
+        server_conf = """
+[server]
+
+[write_permissions]
+
+[users]
+        """
+        server_dir = os.path.join(tmp_dir, ".conan_server")
+        mkdir(server_dir)
+        conf_path = os.path.join(server_dir, "server.conf")
+        save(conf_path, server_conf)
+
+        server_config = ConanServerConfigParser(tmp_dir)
+        self.assertEquals(server_config.public_url, "v1")
diff --git a/conans/test/server/conf_test.py b/conans/test/server/conf_test.py
--- a/conans/test/server/conf_test.py
+++ b/conans/test/server/conf_test.py
@@ -16,6 +16,8 @@
 disk_storage_path: %s
 ssl_enabled: true
 port: 9220
+host_name: localhost
+public_port: 12345
 
 
 [write_permissions]
@@ -80,6 +82,9 @@ def test_values(self):
         self.assertEquals(config.read_permissions, [("*/*@*/*", "*"),
                                                     ("openssl/2.0.1@lasote/testing", "pepe")])
         self.assertEquals(config.users, {"lasote": "defaultpass", "pepe": "pepepass"})
+        self.assertEquals(config.host_name, "localhost")
+        self.assertEquals(config.public_port, 12345)
+        self.assertEquals(config.public_url, "https://localhost:12345/v1")
 
         # Now check with environments
         tmp_storage = temp_folder()
@@ -89,6 +94,8 @@ def test_values(self):
         self.environ["CONAN_SSL_ENABLED"] = "False"
         self.environ["CONAN_SERVER_PORT"] = "1233"
         self.environ["CONAN_SERVER_USERS"] = "lasote:lasotepass,pepe2:pepepass2"
+        self.environ["CONAN_HOST_NAME"] = "remotehost"
+        self.environ["CONAN_SERVER_PUBLIC_PORT"] = "33333"
 
         config = ConanServerConfigParser(self.file_path, environment=self.environ)
         self.assertEquals(config.jwt_secret,  "newkey")
@@ -100,3 +107,6 @@ def test_values(self):
         self.assertEquals(config.read_permissions, [("*/*@*/*", "*"),
                                                     ("openssl/2.0.1@lasote/testing", "pepe")])
         self.assertEquals(config.users, {"lasote": "lasotepass", "pepe2": "pepepass2"})
+        self.assertEquals(config.host_name, "remotehost")
+        self.assertEquals(config.public_port, 33333)
+        self.assertEquals(config.public_url, "http://remotehost:33333/v1")
diff --git a/conans/test/utils/tools.py b/conans/test/utils/tools.py
--- a/conans/test/utils/tools.py
+++ b/conans/test/utils/tools.py
@@ -175,7 +175,7 @@ def __init__(self, read_permissions=None,
                  write_permissions=None, users=None, plugins=None, base_path=None,
                  server_version=Version(SERVER_VERSION),
                  min_client_compatible_version=Version(MIN_CLIENT_COMPATIBLE_VERSION),
-                 server_capabilities=None):
+                 server_capabilities=None, complete_urls=False):
         """
              'read_permissions' and 'write_permissions' is a list of:
                  [("opencv/2.3.4@lasote/testing", "user1, user2")]
@@ -195,9 +195,10 @@ def __init__(self, read_permissions=None,
 
         self.fake_url = "http://fake%s.com" % str(uuid.uuid4()).replace("-", "")
         min_client_ver = min_client_compatible_version
+        base_url = "%s/v1" % self.fake_url if complete_urls else "v1"
         self.test_server = TestServerLauncher(base_path, read_permissions,
                                               write_permissions, users,
-                                              base_url=self.fake_url + "/v1",
+                                              base_url=base_url,
                                               plugins=plugins,
                                               server_version=server_version,
                                               min_client_compatible_version=min_client_ver,