How can I serve a text file at a custom URL

You can utilize add_rewrite_rule to create a new endpoint like http://example.com/api/files/xyz which processes the request and renders the contents from your server. This allows you to mask the origin of the file but still access it’s content.

add_rewrite_rule requires you flush_rewrite_rules but you only need to do that once every time you make a change to your rewrites. So essentially leave that one line in to test but take it out during production.

Once you’ve determined that the url is requesting a file, and which file you want present, do a quick is_readable check to make sure the file exists and you have access to the content.

At that point you can write some headers to describe the file, read the contents and write to the output buffer with readfile.

You can drop this in your functions.php or in a plugin to allow access regardless of theme.

Descriptions are in the code comments.

<?php

if ( ! class_exists( 'FileEndpoint' ) ):

    class FileEndpoint {
        const ENDPOINT_QUERY_NAME  = 'api/files';
        const ENDPOINT_QUERY_PARAM = '__api_files';

        // WordPress hooks

        public function init() {
            add_filter( 'query_vars', array ( $this, 'add_query_vars' ), 0 );
            add_action( 'parse_request', array ( $this, 'sniff_requests' ), 0 );
            add_action( 'init', array ( $this, 'add_endpoint' ), 0 );
        }

        // Add public query vars

        public function add_query_vars( $vars ) {

            // add all the things we know we'll use

            $vars[] = static::ENDPOINT_QUERY_PARAM;
            $vars[] = 'file';

            return $vars;
        }

        // Add API Endpoint

        public function add_endpoint() {
            add_rewrite_rule( '^' . static::ENDPOINT_QUERY_NAME . '/([^/]*)/?', 'index.php?' . static::ENDPOINT_QUERY_PARAM . '=1&file=$matches[1]', 'top' );

            //////////////////////////////////
            flush_rewrite_rules( false ); //// <---------- REMOVE THIS WHEN DONE
            //////////////////////////////////
        }

        // Sniff Requests

        public function sniff_requests( $wp_query ) {
            global $wp;

            if ( isset(
                $wp->query_vars[ static::ENDPOINT_QUERY_PARAM ],
                $wp->query_vars[ 'file' ] ) ) {
                $this->handle_file_request(); // handle it
            }
        }

        // Handle Requests

        protected function handle_file_request() {
            global $wp;

            $file     = $wp->query_vars[ 'file' ];
            $filepath="";
 
            switch ( $file ) {

                // example.com/api/files/xyz
                case 'xyz':
                    $filepath = __DIR__ . '/filename.txt';
                    break;
            }

            if ( ! empty( $filepath ) ) {

                // Make sure this is an accessible file
                // If we can't read it throw an Error
                if ( ! is_readable( $filepath ) ) {

                    $err = new WP_Error( "Forbidden", "Access is not allowed for this request.", 403 );
                    wp_die( $err->get_error_message(), $err->get_error_code() );
                }

                // We can read it, so let's render it
                $this->serve_file( $filepath );
            }

            // Nothing happened, just give some feedback
            $err = new WP_Error( "Bad Request", "Invalid Request.", 400 );
            wp_die( $err->get_error_message(), $err->get_error_code() );
        }

        // Output the file

        protected function serve_file( $filepath, $force_download = false ) {
 
            if ( ! empty ( $filepath ) ) {

                // Write some headers

                header( "Cache-control: private" );
                if ( $force_download ) {

                    // Allow a forced download
                    header( "Content-type: application/force-download" );
                    header( "Content-disposition: attachment; filename=\"filename.txt\"" );
                }
                header( "Content-transfer-encoding: binary\n" );
                header( "Content-Length: " . filesize( $filepath ) );

                // render the contents of the file
                readfile( $filepath );

                // kill the request. Nothing else to do now.
                exit;
            }

            // nothing happened, :(
            return false;
        }
    }

    $wpFileEndpoint = new FileEndpoint();
    $wpFileEndpoint->init();

endif; // FileEndpoint