diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml
index e9530261221d1863318d1f0574f6592b8aae4001..3a8f6cb080013e40fd61279e6be21b24b08fbb39 100644
--- a/.gitlab-ci.yml
+++ b/.gitlab-ci.yml
@@ -24,42 +24,29 @@ before_script:
   - composer global require automattic/vipwpcs
   - phpcs --config-set installed_paths $HOME/.composer/vendor/wp-coding-standards/wpcs,$HOME/.composer/vendor/automattic/vipwpcs
 
-PHPunit:PHP5.3:MySQL:
-  image: containers.ethitter.com:443/docker/images/php:5.3
-  services:
-    - mysql:5.6
-  script:
-    - phpcs -n
-    - phpunit
-
-PHPunit:PHP5.6:MySQL:
-  image: containers.ethitter.com:443/docker/images/php:5.6
-  services:
-    - mysql:5.6
-  script:
-    - phpcs -n
-    - phpunit
-
-PHPunit:PHP7.0:MySQL:
+test_7.0:
   image: containers.ethitter.com:443/docker/images/php:7.0
   services:
     - mysql:5.6
   script:
+    - find . -type "f" -iname "*.php" | xargs -L "1" php -l
     - phpcs -n
     - phpunit
 
-PHPunit:PHP7.1:MySQL:
+test_7.1:
   image: containers.ethitter.com:443/docker/images/php:7.1
   services:
     - mysql:5.6
   script:
+    - find . -type "f" -iname "*.php" | xargs -L "1" php -l
     - phpcs -n
     - phpunit
 
-PHPunit:PHP7.2:MySQL:
+test_7.2:
   image: containers.ethitter.com:443/docker/images/php:7.2
   services:
     - mysql:5.6
   script:
+    - find . -type "f" -iname "*.php" | xargs -L "1" php -l
     - phpcs -n
     - phpunit
diff --git a/README.md b/README.md
index c19e0354724a3aa3f46a3a19d7256abb68012ef7..fa835b3b4d9705de32fcf155e1a12d81ee2ee2c8 100644
--- a/README.md
+++ b/README.md
@@ -3,6 +3,7 @@
 **Donate link:** https://ethitter.com/donate/  
 **Tags:** image, proxy, cdn  
 **Requires at least:** 4.9  
+**Requires PHP:** 7.0  
 **Tested up to:** 4.9  
 **Stable tag:** 0.1.0  
 **License:** GPLv2 or later  
diff --git a/camo-image-proxy.php b/camo-image-proxy.php
index 1c407ca4b6eaf7dc68e0ad0c029719869946afbd..9ed3d6e82970288e689fb4688c5ea76568ee3433 100755
--- a/camo-image-proxy.php
+++ b/camo-image-proxy.php
@@ -13,3 +13,58 @@
  */
 
 namespace Camo_Image_Proxy;
+
+const PLUGIN_PATH = __DIR__;
+
+/**
+ * Trait for singletons
+ */
+require_once PLUGIN_PATH . '/inc/trait-singleton.php';
+
+/**
+ * Plugin options
+ */
+require_once PLUGIN_PATH . '/inc/class-options.php';
+
+/**
+ * Options page
+ */
+require_once PLUGIN_PATH . '/inc/class-options-page.php';
+
+/**
+ * URL Building
+ */
+require_once PLUGIN_PATH . '/inc/class-url.php';
+
+/**
+ * Rewrite WordPress-generated URLs
+ */
+require_once PLUGIN_PATH . '/inc/class-rewrite-urls.php';
+
+/**
+ * Rewrite URLs in post content
+ */
+require_once PLUGIN_PATH . '/inc/class-rewrite-content.php';
+
+/**
+ * Assorted functions
+ */
+require_once PLUGIN_PATH . '/inc/functions.php';
+
+/**
+ * Load plugin singletons
+ */
+function init() {
+	Options::instance();
+	URL::instance();
+
+	if ( is_admin() ) {
+		Options_Page::instance();
+	}
+
+	if ( URL::instance()->can_rewrite() ) {
+		Rewrite_URLs::instance();
+		Rewrite_Content::instance();
+	}
+}
+add_action( 'init', __NAMESPACE__ . '\init' );
diff --git a/inc/class-options-page.php b/inc/class-options-page.php
new file mode 100644
index 0000000000000000000000000000000000000000..2e9540697b0a2be04cf06dbc1d68c149581a28cf
--- /dev/null
+++ b/inc/class-options-page.php
@@ -0,0 +1,86 @@
+<?php
+/**
+ * Plugin options page
+ *
+ * @package Camo_Image_Proxy
+ */
+
+namespace Camo_Image_Proxy;
+
+/**
+ * Class Options_Page
+ */
+class Options_Page {
+	use Singleton;
+
+	/**
+	 * Settings screen section
+	 *
+	 * @var string
+	 */
+	private $section = 'camp-image-proxy';
+
+	/**
+	 * Field labels
+	 *
+	 * @var array
+	 */
+	private $labels = [];
+
+	/**
+	 * Option name
+	 *
+	 * @var string
+	 */
+	private $name;
+
+	/**
+	 * Hooks
+	 */
+	public function setup() {
+		$this->name = Options::instance()->name;
+
+		$this->labels['host'] = __( 'Host', 'camo-image-proxy' );
+		$this->labels['key']  = __( 'Shared Key', 'camo-image-proxy' );
+
+		add_action( 'admin_init', [ $this, 'action_admin_init' ] );
+	}
+
+	/**
+	 * Add fields to Media settings page
+	 */
+	public function action_admin_init() {
+		register_setting( 'media', $this->name, [ Options::instance(), 'sanitize_all' ] );
+		add_settings_section( $this->section, __( 'Camo Image Proxy', 'camo-image-proxy' ), '__return_false', 'media' );
+
+		foreach ( $this->labels as $key => $label ) {
+			$args = [
+				'option' => $key,
+				'label'  => $label,
+			];
+			add_settings_field( $key, $label, [ $this, 'screen' ], 'media', $this->section, $args );
+		}
+	}
+
+	/**
+	 * Render options field
+	 *
+	 * @param array $args Field arguments.
+	 */
+	public function screen( $args ) {
+		$value      = Options::instance()->get( $args['option'] );
+		$input_type = 'host' === $args['option'] ? 'url' : 'text';
+		$name       = sprintf( '%1$s[%2$s]', $this->name, $args['option'] );
+		$html_id    = sprintf( '%1$s-%2$s', str_replace( '_', '-', $this->name ), $args['option'] );
+
+		?>
+		<input
+			type="<?php echo esc_attr( $input_type ); ?>"
+			name="<?php echo esc_attr( $name ); ?>"
+			class="regular-text"
+			id="<?php echo esc_attr( $html_id ); ?>"
+			value="<?php echo esc_attr( $value ); ?>"
+		/>
+		<?php
+	}
+}
diff --git a/inc/class-options.php b/inc/class-options.php
new file mode 100644
index 0000000000000000000000000000000000000000..888477643126d055e138888218cc3f497ffbf446
--- /dev/null
+++ b/inc/class-options.php
@@ -0,0 +1,132 @@
+<?php
+/**
+ * Plugin options
+ *
+ * @package Camo_Image_Proxy
+ */
+
+namespace Camo_Image_Proxy;
+
+/**
+ * Class Options
+ */
+class Options {
+	use Singleton;
+
+	/**
+	 * Option name
+	 *
+	 * @var string
+	 */
+	private $name = 'camo_image_proxy_opts';
+
+	/**
+	 * Allowed options
+	 *
+	 * @var array
+	 */
+	private $allowed_options = [
+		'host' => '',
+		'key'  => '',
+	];
+
+	/**
+	 * Access certain private properties
+	 *
+	 * @param string $name Property name.
+	 * @return mixed
+	 */
+	public function __get( string $name ) {
+		switch ( $name ) {
+			case 'name':
+				return $this->name;
+
+			default:
+				return new \WP_Error( 'invalid-property', __( 'Invalid property requested.', 'camo-image-proxy' ), $name );
+		}
+	}
+
+	/**
+	 * Retrieve full plugin options
+	 *
+	 * @return array
+	 */
+	private function get_all() : array {
+		$options = get_option( $this->name, [] );
+		$options = wp_parse_args( $options, $this->allowed_options );
+		return $options;
+	}
+
+	/**
+	 * Get plugin option
+	 *
+	 * @param string $option Plugin option to retrieve.
+	 * @return mixed
+	 */
+	public function get( string $option ) {
+		if ( ! array_key_exists( $option, $this->allowed_options ) ) {
+			return false;
+		}
+
+		$options = $this->get_all();
+		return $options[ $option ] ?? false;
+	}
+
+	/**
+	 * Set plugin option
+	 *
+	 * @param string $option Plugin option to set.
+	 * @param mixed  $value Option value.
+	 * @return bool
+	 */
+	public function set( string $option, $value ) : bool {
+		$value              = $this->sanitize( $option, $value );
+		$options            = $this->get_all();
+		$options[ $option ] = $value;
+
+		return update_option( $this->name, $options );
+	}
+
+	/**
+	 * Sanitize option
+	 *
+	 * @param string $option Plugin option.
+	 * @param mixed  $value Option value to sanitize.
+	 * @return mixed
+	 */
+	public function sanitize( string $option, $value ) {
+		switch ( $option ) {
+			case 'host':
+				$value = esc_url( $value );
+
+				if ( ! empty( $value ) ) {
+					$value = untrailingslashit( $value );
+				}
+
+				break;
+
+			case 'key':
+				$value = sanitize_text_field( $value );
+				break;
+
+			default:
+				return false;
+		}
+
+		return $value;
+	}
+
+	/**
+	 * Sanitize array of options
+	 *
+	 * @param array $options Options to sanitize.
+	 * @return array
+	 */
+	public function sanitize_all( array $options ) : array {
+		foreach ( $options as $option => $value ) {
+			$options[ $option ] = $this->sanitize( $option, $value );
+		}
+
+		return $options;
+	}
+}
diff --git a/inc/class-rewrite-content.php b/inc/class-rewrite-content.php
new file mode 100644
index 0000000000000000000000000000000000000000..ccf31a0a317a083ed6eeedb47785d6f1fd33fb78
--- /dev/null
+++ b/inc/class-rewrite-content.php
@@ -0,0 +1,43 @@
+<?php
+/**
+ * Rewrite images in content
+ *
+ * @package Camo_Image_Proxy
+ */
+
+namespace Camo_Image_Proxy;
+
+/**
+ * Class Rewrite_Content
+ */
+class Rewrite_Content {
+	use Singleton;
+
+	/**
+	 * Filter priority
+	 *
+	 * @var int
+	 */
+	private $priority;
+
+	/**
+	 * Hooks
+	 */
+	public function setup() {
+		$priority       = apply_filters( 'camo_image_proxy_rewrite_content_priority', PHP_INT_MAX - 1 );
+		$this->priority = absint( $priority );
+
+		add_filter( 'the_content', [ $this, 'filter_the_content' ], $this->priority );
+	}
+
+	/**
+	 * Rewrite image URLs in content
+	 *
+	 * @param string $content Post content.
+	 * @return string
+	 */
+	public function filter_the_content( string $content ) : string {
+		// TODO: only deal with image srcs, use DOM Document.
+		return $content;
+	}
+}
diff --git a/inc/class-rewrite-urls.php b/inc/class-rewrite-urls.php
new file mode 100644
index 0000000000000000000000000000000000000000..cd68b690bb0f07dc10f95e4201f315547181bfe2
--- /dev/null
+++ b/inc/class-rewrite-urls.php
@@ -0,0 +1,38 @@
+<?php
+/**
+ * Force Core's image functions to use Camo
+ *
+ * @package Camo_Image_Proxy
+ */
+
+namespace Camo_Image_Proxy;
+
+/**
+ * Class Rewrite_URLs
+ */
+class Rewrite_URLs {
+	use Singleton;
+
+	/**
+	 * Hooks
+	 */
+	public function setup() {
+		add_filter( 'wp_get_attachment_image_src', [ $this, 'encode_image' ] );
+	}
+
+	/**
+	 * Camouflage attachment URL
+	 *
+	 * @param array $image Image data.
+	 * @return array
+	 */
+	public function encode_image( array $image ) : array {
+		$url = URL::instance()->encode( $image[0] );
+
+		if ( is_string( $url ) ) {
+			$image[0] = $url;
+		}
+
+		return $image;
+	}
+}
diff --git a/inc/class-url.php b/inc/class-url.php
new file mode 100644
index 0000000000000000000000000000000000000000..33b4496143841e0b94eb78ba4d2b63035651c2bf
--- /dev/null
+++ b/inc/class-url.php
@@ -0,0 +1,93 @@
+<?php
+/**
+ * URL Building
+ *
+ * @package Camo_Image_Proxy
+ */
+
+namespace Camo_Image_Proxy;
+
+/**
+ * Class URL
+ */
+class URL {
+	use Singleton;
+
+	/**
+	 * Can URLs be rewritten to use Camo?
+	 *
+	 * @return bool
+	 */
+	public function can_rewrite() : bool {
+		// Never rewrite in admin.
+		if ( is_admin() ) {
+			return false;
+		}
+
+		$host = Options::instance()->get( 'host' );
+		$key  = Options::instance()->get( 'key' );
+
+		$can_rewrite = true;
+
+		// Validate host.
+		if ( ! $this->is_valid_url( $host ) ) {
+			$can_rewrite = false;
+		}
+
+		// Validate key.
+		// TODO: make sure it's an HMAC or something?
+		if ( empty( $key ) || ! is_string( $key ) ) {
+			$can_rewrite = false;
+		}
+
+		return apply_filters( 'camo_image_proxy_can_rewrite', $can_rewrite, $host, $key );
+	}
+
+	/**
+	 * Encode image URL
+	 *
+	 * @param string $url Image URL to encode.
+	 * @return string|bool
+	 */
+	public function encode( string $url ) : string {
+		if ( ! $this->can_rewrite() || ! $this->is_valid_url( $url ) ) {
+			return false;
+		}
+
+		$key         = hash_hmac( 'sha1', $url, Options::instance()->get( 'key' ) );
+		$url_encoded = bin2hex( $url );
+
+		$url_encoded = sprintf( '%1$s/%2$s/%3$s', Options::instance()->get( 'host' ), $key, $url_encoded );
+		$url_encoded = set_url_scheme( $url_encoded, 'https' );
+
+		return $url_encoded;
+	}
+
+	/**
+	 * Decode encoded URL
+	 *
+	 * @param string $url Camo URL to decode.
+	 * @return string|bool
+	 */
+	public function decode( string $url ) : string {
+		return false;
+	}
+
+	/**
+	 * Can we encode this URL?
+	 *
+	 * @param string $url URL to validate.
+	 * @return bool
+	 */
+	private function is_valid_url( string $url ) : bool {
+		if ( empty( $url ) ) {
+			return false;
+		}
+
+		if ( false === filter_var( $url, FILTER_VALIDATE_URL ) && false === filter_var( $url, FILTER_VALIDATE_IP ) ) {
+			return false;
+		}
+
+		return true;
+	}
+}
diff --git a/inc/functions.php b/inc/functions.php
new file mode 100644
index 0000000000000000000000000000000000000000..9fce3b24cf1505bbec4cf71f004f19bcb5f9fbc2
--- /dev/null
+++ b/inc/functions.php
@@ -0,0 +1,8 @@
+<?php
+/**
+ * Assorted helpers
+ *
+ * @package Camo_Image_Proxy
+ */
+
+namespace Camo_Image_Proxy;
diff --git a/inc/trait-singleton.php b/inc/trait-singleton.php
new file mode 100644
index 0000000000000000000000000000000000000000..b890d8dea40792bc78dbf38c352bc4eaf030840d
--- /dev/null
+++ b/inc/trait-singleton.php
@@ -0,0 +1,40 @@
+<?php
+/**
+ * Trait file for Singletons.
+ *
+ * @package Camo_Image_Proxy
+ */
+
+namespace Camo_Image_Proxy;
+
+/**
+ * Make a class into a singleton.
+ */
+trait Singleton {
+	/**
+	 * Existing instance.
+	 *
+	 * @var object
+	 */
+	protected static $instance;
+
+	/**
+	 * Get class instance.
+	 *
+	 * @return object
+	 */
+	public static function instance() {
+		if ( ! isset( static::$instance ) ) {
+			static::$instance = new static();
+			static::$instance->setup();
+		}
+		return static::$instance;
+	}
+
+	/**
+	 * Setup the singleton.
+	 */
+	public function setup() {
+		// Silence.
+	}
+}
diff --git a/languages/camo-image-proxy.pot b/languages/camo-image-proxy.pot
index 41db33f1d396c42e2424d5947384583cff1abf12..85a017c1f7a04dbb132fd2d8fe6faadf6df288c9 100644
--- a/languages/camo-image-proxy.pot
+++ b/languages/camo-image-proxy.pot
@@ -4,7 +4,7 @@ msgid ""
 msgstr ""
 "Project-Id-Version: Camo Image Proxy 0.1.0\n"
 "Report-Msgid-Bugs-To: https://wordpress.org/support/plugin/camo-image-proxy\n"
-"POT-Creation-Date: 2018-02-18 19:07:54+00:00\n"
+"POT-Creation-Date: 2018-02-19 01:54:23+00:00\n"
 "MIME-Version: 1.0\n"
 "Content-Type: text/plain; charset=utf-8\n"
 "Content-Transfer-Encoding: 8bit\n"
@@ -24,10 +24,22 @@ msgstr ""
 "X-Poedit-Bookmarks: \n"
 "X-Textdomain-Support: yes\n"
 
+#: inc/class-options-page.php:43
+msgid "Host"
+msgstr ""
+
+#: inc/class-options-page.php:44
+msgid "Shared Key"
+msgstr ""
+
 #. Plugin Name of the plugin/theme
 msgid "Camo Image Proxy"
 msgstr ""
 
+#: inc/class-options.php:45
+msgid "Invalid property requested."
+msgstr ""
+
 #. Plugin URI of the plugin/theme
 msgid "https://ethitter.com/plugins/"
 msgstr ""
diff --git a/readme.txt b/readme.txt
index 62370f4c382a6d0ebcb33b5ed03f44e478780698..877751af577415030358442c66a82c49dfa14ed7 100755
--- a/readme.txt
+++ b/readme.txt
@@ -3,6 +3,7 @@ Contributors: ethitter
 Donate link: https://ethitter.com/donate/
 Tags: image, proxy, cdn
 Requires at least: 4.9
+Requires PHP: 7.0
 Tested up to: 4.9
 Stable tag: 0.1.0
 License: GPLv2 or later