Skip to main content

<object-storage-proxy ⚡> Yet Another Object Storage Proxy

Project description

CI PyPI - Downloads PyPI - version

<object-storage-proxy ⚡> Yet Another Object Storage Reverse Proxy

📌 Note: This project is still under heavy development, and its APIs are subject to change.

Introduction

A fast and safe in-process reverse proxy server, based on Cloudflare's pingora, to reverse proxy AWS and IBM Cloud Object Storage buckets and integrate your Authentication and Authorization services.

  • Compatible with AWS SDK -> aws cli/boto3, polars, spark, datafusion, ...
  • Decouples frontend from backend authentication and authorization.
  • Python interface: pass in callables for credentials fetching, validation, lookup secret for access_key (with cache).
  • Compatibility Gateway between systems that support only 1 hmac credentials pair, and multi-credentials buckets backend.

Implementation

The Server Config accepts the following:

proxy_server_config = ProxyServerConfig(
    cos_map=cos_map,
    bucket_creds_fetcher=do_hmac_creds,
    validator=do_validation,
    http_port=6190,
    https_port=8443,
    threads=1,
    verify=False,
    hmac_keystore=hmac_keys,
    skip_signature_validation=False,
    hmac_fetcher=lookup_secret_key
)
argument description optional default value
cos_map bucket configuration, see below NA
bucket_creds_fetcher python callable to retrieve credentials for a given bucket, to return either api key or hmac key pair NA
validator python callable, validates access for a given token/bucket combination NA
http_port server listener port on http ✅ at least http_port or https_port, or both NA
https_port server listener port on https ✅ at least http_port or https_port, or both NA
threads number of service threads 1
verify ignore ssl verification errors on backend storage (IBM/AWS) (for dev purposes) False
hmac_keystore
skip_signature_validation ignore ssl verification errors on frontend (for dev purposes) False

The bucket dict contains for each bucket:

- endpoint host
- port
- api key (optional)
- hmac access key (optional)
- hmac secret key (optional)
- ttl (optional, default 300) -> keep this reasonably short, but size to your needs
cos_map = {
    "bucket1": {
        "host": "s3.eu-de.cloud-object-storage.appdomain.cloud",
        "port": 443,
        "ttl": 0
    },
    "bucket2": {
        "host": "s3.eu-de.cloud-object-storage.appdomain.cloud",
        "port": 443,
        "apikey": "apikey"
    },
    "proxy-bucket01": {
        "host": "s3.eu-de.cloud-object-storage.appdomain.cloud",
        "port": 443,
        "access_key": "<redacted>",
        "secret_key": "<redacted>",
        "ttl": 300
    },
    "proxy-aws-bucket01": {
        "host": "s3.eu-west-3.amazonaws.com",
        "region": "eu-west-3",
        "access_key": os.getenv("AWS_ACCESS_KEY"),
        "secret_key": os.getenv("AWS_SECRET_KEY"),
        "port": 443,
        "ttl": 300
    }    
}

The Python callables take two arguments: TODO: add prefix for fine-grained validation

- token: parsed from the original aws request's authorization header
- bucket: parsed from the uri path
    def your_credentials_fetcher(token: str, bucket: str) -> str
    def your_request_authorizer(token: str, bucket: str) -> bool

The Problem

secrets

IBM COS Storage is built in a way where buckets are grouped by a cos (Cloud Object Storage) instance. Access to a bucket is managed by either an api key or hmac secrets, configured on the cos instance.

endpoint

Each bucket has its own endpoint: <bucket_name>.s3..cloud-object-storage.appdomain.cloud:.

The port is not always different, though, but it might be. Depends on your implementation.

You can imagine managing multiple buckets across instances can become quite cumbersome, even with aws profiles etc.

solution

There are two ways to access a bucket: through virtual addressing style (bucket.ibm-cos-host:port) and path style (ibm-cos-host/bucket).

your client (aws s3 compatible) -> http(s)://this-proxy/bucket01 -> https://bucket01.s3.eu-de.cloud-object-storage.appdomain.cloud:443

  1. translate path style to virtual style
  2. abstract authentication & authorization

Pass in a function which maps bucket to instance (credentials), and a function to map bucket to port (endpoint)

request lifecycle

request stages

authentication & authorization

The advantage is we can plug in a python authentication function and another function for authorization, allowing for fine-grained control.

authentication

We use the standard aws hmac header and aws v4 request signing algorithm.

authorization

Pass in a callable from python which will be called from rust. This will be cached (ttl) for consecutive requests.

Examples

With local configuration.

~/.aws/config

[profile osp]
region = eu-west-3
output = json
services = osp-services
s3 =
    addressing_style = path

[services osp-services]
s3 =
  endpoint_url = http://localhost:6190

~/.aws/credentials

[osp]
aws_access_key_id = MYLOCAL123  # <-- this could be an internal client identifier, to fetch openid connect/oauth2 token or anything that makes sense for your business
aws_secret_access_key = nothingmeaningful # <-- private key to sign original request

Set up a minimal server implementation:

import json
import os
import random
import object_storage_proxy as osp

from dotenv import load_dotenv

from object_storage_proxy import start_server, ProxyServerConfig


_TRUES  = {"y", "yes", "t", "true", "on", "1"}
_FALSES = {"n", "no", "f", "false", "off", "0"}


def strtobool(val: str) -> bool:
    """Convert a string to True/False, raise ValueError otherwise."""
    v = val.lower()
    if v in _TRUES:
        return True
    if v in _FALSES:
        return False
    raise ValueError(f"invalid truth value {val!r}")


def do_api_creds(token: str, bucket: str) -> str:
    """Fetch credentials (ro, rw, access_denied) for the given bucket, depending on the token. """
    apikey = os.getenv("COS_API_KEY")
    if not apikey:
        raise ValueError("COS_API_KEY environment variable not set")
    
    print(f"Fetching credentials for {bucket}...")
    return apikey


def do_hmac_creds(token: str, bucket: str) -> str:
    """ Fetch HMAC credentials (ro, rw, access_denied) for the given bucket, depending on the token """
    access_key = os.getenv("ACCESS_KEY")
    secret_key = os.getenv("SECRET_KEY")
    if not access_key or not secret_key:
        raise ValueError("ACCESS_KEY or SECRET_KEY environment variable not set")
        
    print(f"Fetching HMAC credentials for {bucket}...")

    return json.dumps({
        "access_key": access_key,
        "secret_key": secret_key
    })

def lookup_secret_key(access_key: str) -> str | None:
    # get all environment variables ending in ACCESS_KEY
    access_keys = [{key:value} for key, value in os.environ.items() if key.endswith("ACCESS_KEY") and value==access_key ]

    if len(access_keys) > 0:
        access_key_var = next((k for k, v in access_keys[0].items() if v == access_key), None)

        secret_key_var = access_key_var.replace("ACCESS_KEY", "SECRET_KEY")
        return os.getenv(secret_key_var, None)
    else:
        print(f"no access keys found for : {access_key}")


def do_validation(token: str, bucket: str) -> bool:
    """ Authorize the request based on token for the given bucket. 
        You can plug in your own authorization service here.
        The token is a client identifier used to fetch an authorization token and further authenticate/authorize.
        The bucket is the bucket name.
        The function should return True if the request is authorized, False otherwise.
    """

    print(f"PYTHON: Validating headers: {token} for {bucket}...")
    # return random.choice([True, False])
    return True


def main() -> None:
    load_dotenv()

    counting = strtobool(os.getenv("OSP_ENABLE_REQUEST_COUNTING", "false"))

    if counting:
        osp.enable_request_counting()
        print("Request counting enabled")

    apikey = os.getenv("COS_API_KEY")
    if not apikey:
        raise ValueError("COS_API_KEY environment variable not set")

    cos_map = {
        "bucket1": {
            "host": "s3.eu-de.cloud-object-storage.appdomain.cloud",
            "region": "eu-de",
            "port": 443,
            "apikey": apikey,
            "ttl": 0
        },
        "bucket2": {
            "host": "s3.eu-de.cloud-object-storage.appdomain.cloud",
            "region": "eu-de",
            "port": 443,
            "apikey": apikey
        },
        "proxy-bucket01": {
            "host": "s3.eu-de.cloud-object-storage.appdomain.cloud",
            "region": "eu-de",
            # "access_key": os.getenv("ACCESS_KEY"),
            # "secret_key": os.getenv("SECRET_KEY"),
            "port": 443,
            "ttl": 300
        },
        "proxy-bucket05": {
            "host": "s3.eu-de.cloud-object-storage.appdomain.cloud",
            "region": "eu-de",
            "access_key": os.getenv("PROXY_BUCKET05_ACCESS_KEY"),
            "secret_key": os.getenv("PROXY_BUCKET05_SECRET_KEY"),
            "port": 443,
            "ttl": 300
        },
        "proxy-aws-bucket01": {
            "host": "s3.eu-west-3.amazonaws.com",
            "region": "eu-west-3",
            "access_key": os.getenv("AWS_ACCESS_KEY"),
            "secret_key": os.getenv("AWS_SECRET_KEY"),
            "port": 443,
            "ttl": 300
        }
    }

    hmac_keys= [
        # {
        #     "access_key": os.getenv("LOCAL_ACCESS_KEY"),
        #     "secret_key": os.getenv("LOCAL_SECRET_KEY")
        # },
        {
            "access_key": os.getenv("LOCAL2_ACCESS_KEY"),
            "secret_key": os.getenv("LOCAL2_SECRET_KEY")
        },

    ]

    ra = ProxyServerConfig(
        cos_map=cos_map,
        bucket_creds_fetcher=do_hmac_creds,
        validator=do_validation,
        http_port=6190,
        # https_port=8443,
        threads=1,
        # verify=False,
        hmac_keystore=hmac_keys,
        skip_signature_validation=False,
        hmac_fetcher=lookup_secret_key
    )

    start_server(ra)


if __name__ == "__main__":
    main()

Run with aws-cli (but could be anything compatible with the aws s3 api like polars, spark, presto, ...):

$ aws s3 ls s3://proxy-bucket01/ --recursive --summarize --human-readable --profile osp
2025-04-17 17:45:30   33 Bytes README.md
2025-04-17 17:48:04   33 Bytes README2.md

Total Objects: 2
   Total Size: 66 Bytes
$

Server output:

$ uv run python test_server.py
2025-04-19T13:19:54.402023+02:00  INFO object_storage_proxy: Logger initialized; starting server on http port 6190 and https port 8443
2025-04-19T13:19:54.402361+02:00  INFO object_storage_proxy: Bucket creds fetcher provided: Py(0x100210680)
Fetching credentials for bucket01...
2025-04-19T13:19:54.402485+02:00  INFO object_storage_proxy: Callback returned: Kn2t...
[src/lib.rs:327:5] &run_args.cos_map = Py(
    0x000000010061aa00,
)
2025-04-19T13:19:54.403738+02:00  INFO pingora_core::server: Bootstrap starting
2025-04-19T13:19:54.403852+02:00  INFO pingora_core::server: Bootstrap done
2025-04-19T13:19:54.424489+02:00  INFO pingora_core::server: Server starting
PYTHON: Validating headers: MYLOCAL123 for proxy-bucket01...
2025-04-19T13:19:58.124729+02:00  INFO object_storage_proxy::utils::validator: Callback returned: false
PYTHON: Validating headers: MYLOCAL123 for proxy-bucket01...
2025-04-19T13:20:00.919320+02:00  INFO object_storage_proxy::utils::validator: Callback returned: true
2025-04-19T13:20:01.181775+02:00  INFO object_storage_proxy::credentials::secrets_proxy: No cached token found for proxy-bucket01, fetching ...
2025-04-19T13:20:01.181859+02:00  INFO object_storage_proxy::credentials::secrets_proxy: Fetching bearer token for the API key
2025-04-19T13:20:01.739385+02:00  INFO object_storage_proxy::credentials::secrets_proxy: Received access token
2025-04-19T13:20:01.739600+02:00  INFO object_storage_proxy::credentials::secrets_proxy: Fetched new token for proxy-bucket01
2025-04-19T13:20:01.739668+02:00  INFO object_storage_proxy: Sending request to upstream: https://proxy-bucket01.s3.eu-de.cloud-object-storage.appdomain.cloud/?list-type=2&prefix=&encoding-type=url
2025-04-19T13:20:01.739922+02:00  INFO object_storage_proxy: Request sent to upstream.

test

See the included python test script.

Create self-signed certificates and export the environment variables:

openssl req -x509 -newkey rsa:4096 -sha256 -nodes \
        -keyout key.pem -out cert.pem -days 365 -subj "/CN=localhost"
export TLS_CERT_PATH=/full/path/cert.pem
export TLS_KEY_PATH=/full/path/key.pem

Status

  • pingora proxy implementation
  • pass in credentials handler (which may return either api key string or json string with access_key and secret_key )
  • cache credentials
  • pass in bucket/instance and bucket/port config
  • split in workspace crate with core, cli and python crates (too many specifics for python)
  • config mgmt
  • cache authorization (with optional ttl)
  • http frontend (optional)
  • https frontend (supports HTTP/2) (optional)
  • configurable request counting
  • call the api key fetcher callback only once, save to cos map
  • config for #threads in ProxyServerConfig
  • also pass path and method to python callbacks and cache by token/bucket/path/method (identity based access/cache)
  • option to disable upstream/peer certificate validation (for development, not production!)
  • expose pingora proxy server and services configuration to python
  • drop proxy headers (x-forwarded-proto, x-forwarded-host, ..) for signing

Project details


Download files

Download the file for your platform. If you're not sure which to choose, learn more about installing packages.

Source Distribution

object_storage_proxy-0.3.11.tar.gz (78.8 kB view details)

Uploaded Source

Built Distributions

If you're not sure about the file name format, learn more about wheel file names.

object_storage_proxy-0.3.11-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl (7.8 MB view details)

Uploaded CPython 3.13manylinux: glibc 2.17+ x86-64

object_storage_proxy-0.3.11-cp313-cp313-macosx_11_0_arm64.whl (5.0 MB view details)

Uploaded CPython 3.13macOS 11.0+ ARM64

object_storage_proxy-0.3.11-cp313-cp313-macosx_10_12_x86_64.whl (5.4 MB view details)

Uploaded CPython 3.13macOS 10.12+ x86-64

object_storage_proxy-0.3.11-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl (7.8 MB view details)

Uploaded CPython 3.12manylinux: glibc 2.17+ x86-64

object_storage_proxy-0.3.11-cp312-cp312-macosx_11_0_arm64.whl (5.0 MB view details)

Uploaded CPython 3.12macOS 11.0+ ARM64

object_storage_proxy-0.3.11-cp312-cp312-macosx_10_12_x86_64.whl (5.4 MB view details)

Uploaded CPython 3.12macOS 10.12+ x86-64

object_storage_proxy-0.3.11-cp311-cp311-musllinux_1_2_x86_64.whl (7.6 MB view details)

Uploaded CPython 3.11musllinux: musl 1.2+ x86-64

object_storage_proxy-0.3.11-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl (7.8 MB view details)

Uploaded CPython 3.11manylinux: glibc 2.17+ x86-64

object_storage_proxy-0.3.11-cp311-cp311-macosx_11_0_arm64.whl (5.0 MB view details)

Uploaded CPython 3.11macOS 11.0+ ARM64

object_storage_proxy-0.3.11-cp311-cp311-macosx_10_12_x86_64.whl (5.4 MB view details)

Uploaded CPython 3.11macOS 10.12+ x86-64

object_storage_proxy-0.3.11-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl (7.8 MB view details)

Uploaded CPython 3.10manylinux: glibc 2.17+ x86-64

File details

Details for the file object_storage_proxy-0.3.11.tar.gz.

File metadata

File hashes

Hashes for object_storage_proxy-0.3.11.tar.gz
Algorithm Hash digest
SHA256 1a836cbb9690cb11e517a4c21c74dc7fd4f4a6b855e3077d115c1ef91f856310
MD5 ff8e1f3c5e0e554a134a77c7f4b1da54
BLAKE2b-256 560392fd8bc6dd089e3d2431812bf8b17e6d6a54d13c8ff0c6af57ae8c6f97e1

See more details on using hashes here.

File details

Details for the file object_storage_proxy-0.3.11-pp311-pypy311_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl.

File metadata

File hashes

Hashes for object_storage_proxy-0.3.11-pp311-pypy311_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl
Algorithm Hash digest
SHA256 304c113a10d444f6b28370a619282b0a7b441d1e879e56754774f27f6ee555a2
MD5 a286116c4d402f4275b94602b67c8538
BLAKE2b-256 f11c4855d912bfba1bd348f201c52eb28a1c1f126b0883c7e84faa5cd4d948dc

See more details on using hashes here.

File details

Details for the file object_storage_proxy-0.3.11-pp310-pypy310_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl.

File metadata

File hashes

Hashes for object_storage_proxy-0.3.11-pp310-pypy310_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl
Algorithm Hash digest
SHA256 e640a6175558a1e837014624fbcde3f0ddecbfb28264c5511408dc63050bb6a1
MD5 b9f8358ffb5a8e3943cb54e730cef9f2
BLAKE2b-256 ef9aa8f3a36a490d514284c2c7aba1501cd9899b11c078f0fc68727966875fc6

See more details on using hashes here.

File details

Details for the file object_storage_proxy-0.3.11-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl.

File metadata

File hashes

Hashes for object_storage_proxy-0.3.11-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl
Algorithm Hash digest
SHA256 e7dfdd0fe1553848c72a5ef49eccf4d046ac49424bda1f71b9d5b620a4d7a433
MD5 eed17bfbae51f66951dcf5029ec3a3ca
BLAKE2b-256 245c719cdc1932ad8669081ce61489d11c9f1ab6b55662b6ccf1e6f13748c4a7

See more details on using hashes here.

File details

Details for the file object_storage_proxy-0.3.11-cp313-cp313-macosx_11_0_arm64.whl.

File metadata

File hashes

Hashes for object_storage_proxy-0.3.11-cp313-cp313-macosx_11_0_arm64.whl
Algorithm Hash digest
SHA256 6740b09d064164a544fefaf9b3fb11ad0a1ffae21a23caac3ad8c3210c235951
MD5 7a35cecb1d49bb2a789b2c8d2d6b83d4
BLAKE2b-256 4f95dcf531b06819d77fe81f2f04f28a6bedbf0f16cf4d4d4273116dfd618384

See more details on using hashes here.

File details

Details for the file object_storage_proxy-0.3.11-cp313-cp313-macosx_10_12_x86_64.whl.

File metadata

File hashes

Hashes for object_storage_proxy-0.3.11-cp313-cp313-macosx_10_12_x86_64.whl
Algorithm Hash digest
SHA256 fe093bf155a56624bace3c7051eb0d67009cce93725a80a5859ab72cadd3b93d
MD5 459fb4bbb96b2f9345ed61c1cdcec309
BLAKE2b-256 cb14ab4b45469c74a3af916c7b3ffc241bf747317f748e9661aa72e6368235ca

See more details on using hashes here.

File details

Details for the file object_storage_proxy-0.3.11-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl.

File metadata

File hashes

Hashes for object_storage_proxy-0.3.11-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl
Algorithm Hash digest
SHA256 3db61cd47ac7fdff6b2f0c248317e3bb43cb8798dfb44b90dfeb8b5852c82ad7
MD5 ae249e3e17eab658b19efffe0df96206
BLAKE2b-256 4bb82f937a151971be325792a69a771635014df15d46307234251bf9303c8521

See more details on using hashes here.

File details

Details for the file object_storage_proxy-0.3.11-cp312-cp312-macosx_11_0_arm64.whl.

File metadata

File hashes

Hashes for object_storage_proxy-0.3.11-cp312-cp312-macosx_11_0_arm64.whl
Algorithm Hash digest
SHA256 728956336dda6f9be9c12aed5cd9c8120afc8857475d8c49e612cf4428952e8e
MD5 f7ef94ee4fe2a4f27f85849f0f1d2aeb
BLAKE2b-256 bb622a7352a4895ae60dae8ac4569d5f1fb6aac385a5df9835de88df86bb77d2

See more details on using hashes here.

File details

Details for the file object_storage_proxy-0.3.11-cp312-cp312-macosx_10_12_x86_64.whl.

File metadata

File hashes

Hashes for object_storage_proxy-0.3.11-cp312-cp312-macosx_10_12_x86_64.whl
Algorithm Hash digest
SHA256 1d433af3e4324f144537f8a62ff8852873af3d67f971dfaa9d173d13d5b5123a
MD5 20521e210bb46e6b06ef82e9e7e31c75
BLAKE2b-256 3dffdc35f95964272a4662bcad3efecab149ee3d6b190c6a49a6a5ab69dc48dd

See more details on using hashes here.

File details

Details for the file object_storage_proxy-0.3.11-cp311-cp311-musllinux_1_2_x86_64.whl.

File metadata

File hashes

Hashes for object_storage_proxy-0.3.11-cp311-cp311-musllinux_1_2_x86_64.whl
Algorithm Hash digest
SHA256 3711bd394f202a3dac3fc281927b54b14691b2961e99fe997c9e4ca4773e78d0
MD5 abeceee2c012a0858e52ac675e34c9bf
BLAKE2b-256 82314aab2f342c4708340e88230eef492155f2fe7583939edab0f79cb5f03240

See more details on using hashes here.

File details

Details for the file object_storage_proxy-0.3.11-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl.

File metadata

File hashes

Hashes for object_storage_proxy-0.3.11-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl
Algorithm Hash digest
SHA256 2549711380f0fa931df40f857b6c07d6abc1c98c7d8d33cc19a4c21ca9b25d6f
MD5 29757c171395ce96fb67a19f054af6e0
BLAKE2b-256 10e6ba6e5e8238bdcdcc1eb55a839581c56ddd204062ffd143e3862fb7725ee9

See more details on using hashes here.

File details

Details for the file object_storage_proxy-0.3.11-cp311-cp311-macosx_11_0_arm64.whl.

File metadata

File hashes

Hashes for object_storage_proxy-0.3.11-cp311-cp311-macosx_11_0_arm64.whl
Algorithm Hash digest
SHA256 04b8e605e1b554156efda49e9662f3781393b3117b9e42064c63e688adce7f11
MD5 cba424724331c60033655fdc473591d4
BLAKE2b-256 cca78d02a0e49004461c825d0c02daf7fcb301e569ac81502090359737b80dda

See more details on using hashes here.

File details

Details for the file object_storage_proxy-0.3.11-cp311-cp311-macosx_10_12_x86_64.whl.

File metadata

File hashes

Hashes for object_storage_proxy-0.3.11-cp311-cp311-macosx_10_12_x86_64.whl
Algorithm Hash digest
SHA256 bafe8e82454c253bac6bc6c26ff0d92316a0f1a17562b818ca87dce7aec55ad8
MD5 86e78dac856f729efe1565328de20b87
BLAKE2b-256 e84460b3ca0d1140ffcabd07650124cc5a1e9bb1f14e404686745837c6e6cde3

See more details on using hashes here.

File details

Details for the file object_storage_proxy-0.3.11-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl.

File metadata

File hashes

Hashes for object_storage_proxy-0.3.11-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl
Algorithm Hash digest
SHA256 12aee350ac20af639a58512b713671a5426137e578b7ec4d439a315e7c9cd6cb
MD5 0df19ffc8efed3fb40135cbc5314329b
BLAKE2b-256 1c52a3a9a9610b8186149c02912e5641336a6db8320741a52d9f5ef8e0a0cb75

See more details on using hashes here.

Supported by

AWS Cloud computing and Security Sponsor Datadog Monitoring Depot Continuous Integration Fastly CDN Google Download Analytics Pingdom Monitoring Sentry Error logging StatusPage Status page