├── .dockerignore ├── .editorconfig ├── .gitattributes ├── .github ├── dependabot.yml ├── repository-open-graph.png └── workflows │ ├── codeql.yml │ ├── dependabot.yml │ ├── docker.yml │ ├── go.yml │ ├── golangci-lint.yml │ └── release.yml ├── .gitignore ├── CONTRIBUTING.md ├── Dockerfile ├── LICENSE ├── Makefile ├── README.md ├── cmd └── serve │ └── main.go ├── compose.yml ├── docs └── logo.svg ├── examples └── basic │ └── main.go ├── fixtures ├── cert.pem └── key.pem ├── go.mod ├── go.sum ├── internal ├── commands │ ├── server.go │ ├── server_test.go │ ├── version.go │ └── version_test.go ├── config │ ├── flags.go │ └── flags_test.go └── middleware │ ├── auth.go │ ├── auth_test.go │ ├── cors.go │ ├── cors_test.go │ ├── logger.go │ ├── logger_test.go │ ├── recover.go │ ├── recover_test.go │ └── statuswriter.go ├── mock └── http.go ├── serve.go ├── serve_test.go └── static └── index.html /.dockerignore: -------------------------------------------------------------------------------- 1 | *.yml 2 | *.yaml 3 | *.json 4 | *.md 5 | 6 | .git* 7 | bin 8 | dist 9 | build 10 | docs 11 | examples 12 | tmp 13 | vendor 14 | 15 | .editorconfig 16 | Dockerfile 17 | CODEOWNERS 18 | LICENSE 19 | Makefile 20 | .env 21 | compose.yml 22 | compose.override.yml 23 | README.md 24 | -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | root = true 2 | 3 | [*] 4 | charset = utf-8 5 | end_of_line = lf 6 | indent_style = space 7 | max_line_length = 120 8 | insert_final_newline = true 9 | trim_trailing_whitespace = true 10 | 11 | [*.{json,yaml,yml}] 12 | indent_style = space 13 | indent_size = 2 14 | 15 | [*.{sh,bash,envrc}] 16 | indent_style = space 17 | indent_size = 4 18 | 19 | [*.go] 20 | indent_style = tab 21 | indent_size = 4 22 | 23 | [{Makefile,makefile,GNUmakefile}] 24 | indent_style = tab 25 | indent_size = 4 26 | 27 | [*.md] 28 | trim_trailing_whitespace = false 29 | -------------------------------------------------------------------------------- /.gitattributes: -------------------------------------------------------------------------------- 1 | * text=auto 2 | -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | updates: 3 | - package-ecosystem: github-actions 4 | directory: "/" 5 | schedule: 6 | interval: weekly 7 | - package-ecosystem: gomod 8 | directory: "/" 9 | schedule: 10 | interval: weekly 11 | -------------------------------------------------------------------------------- /.github/repository-open-graph.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/syntaqx/serve/1906104fbe220d057e041a05b30a14dca9de12a5/.github/repository-open-graph.png -------------------------------------------------------------------------------- /.github/workflows/codeql.yml: -------------------------------------------------------------------------------- 1 | # For most projects, this workflow file will not need changing; you simply need 2 | # to commit it to your repository. 3 | # 4 | # You may wish to alter this file to override the set of languages analyzed, 5 | # or to provide custom queries or build logic. 6 | # 7 | # ******** NOTE ******** 8 | # We have attempted to detect the languages in your repository. Please check 9 | # the `language` matrix defined below to confirm you have the correct set of 10 | # supported CodeQL languages. 11 | # 12 | name: "CodeQL" 13 | 14 | on: 15 | push: 16 | branches: [ "main" ] 17 | pull_request: 18 | # The branches below must be a subset of the branches above 19 | branches: [ "main" ] 20 | schedule: 21 | - cron: '37 6 * * 0' 22 | 23 | jobs: 24 | analyze: 25 | name: Analyze 26 | runs-on: ${{ (matrix.language == 'swift' && 'macos-latest') || 'ubuntu-latest' }} 27 | permissions: 28 | actions: read 29 | contents: read 30 | security-events: write 31 | 32 | strategy: 33 | fail-fast: false 34 | matrix: 35 | language: [ 'go' ] 36 | # CodeQL supports [ 'cpp', 'csharp', 'go', 'java', 'javascript', 'python', 'ruby' ] 37 | # Use only 'java' to analyze code written in Java, Kotlin or both 38 | # Use only 'javascript' to analyze code written in JavaScript, TypeScript or both 39 | # Learn more about CodeQL language support at https://aka.ms/codeql-docs/language-support 40 | 41 | steps: 42 | - name: Checkout repository 43 | uses: actions/checkout@v4 44 | 45 | # Initializes the CodeQL tools for scanning. 46 | - name: Initialize CodeQL 47 | uses: github/codeql-action/init@v3 48 | with: 49 | languages: ${{ matrix.language }} 50 | # If you wish to specify custom queries, you can do so here or in a config file. 51 | # By default, queries listed here will override any specified in a config file. 52 | # Prefix the list here with "+" to use these queries and those in the config file. 53 | 54 | # For more details on CodeQL's query packs, refer to: https://docs.github.com/en/code-security/code-scanning/automatically-scanning-your-code-for-vulnerabilities-and-errors/configuring-code-scanning#using-queries-in-ql-packs 55 | # queries: security-extended,security-and-quality 56 | 57 | 58 | # Autobuild attempts to build any compiled languages (C/C++, C#, Go, or Java). 59 | # If this step fails, then you should remove it and run the build manually (see below) 60 | - name: Autobuild 61 | uses: github/codeql-action/autobuild@v3 62 | 63 | # ℹ️ Command-line programs to run using the OS shell. 64 | # 📚 See https://docs.github.com/en/actions/using-workflows/workflow-syntax-for-github-actions#jobsjob_idstepsrun 65 | 66 | # If the Autobuild fails above, remove it and uncomment the following three lines. 67 | # modify them (or add more) to build your code if your project, please refer to the EXAMPLE below for guidance. 68 | 69 | # - run: | 70 | # echo "Run, Build Application using script" 71 | # ./location_of_script_within_repo/buildscript.sh 72 | 73 | - name: Perform CodeQL Analysis 74 | uses: github/codeql-action/analyze@v3 75 | with: 76 | category: "/language:${{matrix.language}}" 77 | -------------------------------------------------------------------------------- /.github/workflows/dependabot.yml: -------------------------------------------------------------------------------- 1 | name: Dependabot Approve & Auto-Merge 2 | on: 3 | pull_request: 4 | branches: [ main ] 5 | 6 | permissions: 7 | contents: write 8 | pull-requests: write 9 | 10 | jobs: 11 | 12 | dependabot: 13 | runs-on: ubuntu-latest 14 | if: ${{ github.actor == 'dependabot[bot]' }} 15 | steps: 16 | - 17 | name: Dependabot metadata 18 | id: metadata 19 | uses: dependabot/fetch-metadata@v2 20 | with: 21 | github-token: "${{ secrets.GITHUB_TOKEN }}" 22 | - 23 | name: Output Metadata 24 | run: | 25 | echo "${{ steps.metadata.outputs.dependency-names }}" 26 | echo "${{ steps.metadata.outputs.dependency-type }}" 27 | echo "${{ steps.metadata.outputs.update-type }}" 28 | - 29 | name: Approve a PR 30 | run: gh pr review --approve "$PR_URL" 31 | env: 32 | PR_URL: ${{ github.event.pull_request.html_url }} 33 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 34 | - 35 | name: Enable auto-merge for Dependabot PRs 36 | # if: ${{ steps.metadata.outputs.update-type == 'version-update:semver-patch' }} 37 | run: gh pr merge --auto --merge --delete-branch "$PR_URL" 38 | env: 39 | PR_URL: ${{ github.event.pull_request.html_url }} 40 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 41 | -------------------------------------------------------------------------------- /.github/workflows/docker.yml: -------------------------------------------------------------------------------- 1 | name: Docker 2 | 3 | on: 4 | workflow_call: 5 | inputs: 6 | push: 7 | description: "Push the image to the registry" 8 | type: boolean 9 | default: false 10 | required: false 11 | outputs: 12 | image: 13 | description: "Output Image" 14 | value: ${{ github.repository }}:sha-${{ jobs.build.outputs.version }} 15 | 16 | jobs: 17 | 18 | build: 19 | runs-on: ubuntu-latest 20 | outputs: 21 | version: ${{ steps.vars.outputs.sha_short }} 22 | steps: 23 | - 24 | name: Checkout 25 | uses: actions/checkout@v4 26 | - name: Set output vars 27 | id: vars 28 | run: echo "::set-output name=sha_short::$(git rev-parse --short HEAD)" 29 | - 30 | name: Docker meta 31 | id: meta 32 | uses: docker/metadata-action@v5 33 | with: 34 | images: ${{ github.repository }} 35 | tags: | 36 | type=schedule 37 | type=raw,value=latest,enable={{is_default_branch}} 38 | type=ref,event=branch 39 | type=ref,event=pr 40 | type=semver,pattern={{version}} 41 | type=semver,pattern={{major}}.{{minor}} 42 | type=semver,pattern={{major}} 43 | type=sha 44 | - 45 | name: Set up QEMU 46 | uses: docker/setup-qemu-action@v3 47 | - 48 | name: Set up Docker Buildx 49 | uses: docker/setup-buildx-action@v3 50 | - 51 | name: Login to DockerHub 52 | uses: docker/login-action@v3 53 | with: 54 | username: ${{ secrets.DOCKERHUB_USERNAME }} 55 | password: ${{ secrets.DOCKERHUB_TOKEN }} 56 | - 57 | name: Build and push 58 | uses: docker/build-push-action@v6 59 | with: 60 | context: . 61 | push: ${{ inputs.push }} 62 | tags: ${{ steps.meta.outputs.tags }} 63 | labels: ${{ steps.meta.outputs.labels }} 64 | cache-from: type=registry,ref=${{ github.repository }}:buildcache 65 | cache-to: type=registry,ref=${{ github.repository }}:buildcache,mode=max 66 | - 67 | if: ${{ github.event_name != 'pull_request' }} 68 | name: Update Docker Hub Description 69 | uses: peter-evans/dockerhub-description@v4 70 | with: 71 | username: ${{ secrets.DOCKERHUB_USERNAME }} 72 | password: ${{ secrets.DOCKERHUB_TOKEN }} 73 | repository: ${{ github.repository }} 74 | -------------------------------------------------------------------------------- /.github/workflows/go.yml: -------------------------------------------------------------------------------- 1 | name: Go 2 | 3 | on: 4 | push: 5 | branches: [ main ] 6 | pull_request: 7 | branches: [ main ] 8 | 9 | jobs: 10 | 11 | test: 12 | runs-on: ubuntu-latest 13 | steps: 14 | - uses: actions/checkout@v4 15 | - 16 | name: Set up Go 17 | uses: actions/setup-go@v5 18 | with: 19 | cache: true 20 | - 21 | name: Build 22 | run: go build -v ./... 23 | - 24 | name: Test 25 | run: go test -race -coverprofile=coverage.out -covermode=atomic ./... 26 | - 27 | name: Upload coverage reports to Codecov 28 | uses: codecov/codecov-action@v5 29 | -------------------------------------------------------------------------------- /.github/workflows/golangci-lint.yml: -------------------------------------------------------------------------------- 1 | name: golangci-lint 2 | on: 3 | push: 4 | tags: 5 | - v* 6 | branches: 7 | - main 8 | pull_request: 9 | 10 | permissions: 11 | contents: read 12 | # Optional: allow read access to pull request. Use with `only-new-issues` option. 13 | # pull-requests: read 14 | jobs: 15 | 16 | golangci: 17 | name: lint 18 | runs-on: ubuntu-latest 19 | steps: 20 | - uses: actions/setup-go@v5 21 | with: 22 | cache: false 23 | - uses: actions/checkout@v4 24 | - name: golangci-lint 25 | uses: golangci/golangci-lint-action@v6 26 | with: 27 | # Optional: version of golangci-lint to use in form of v1.2 or v1.2.3 or `latest` to use the latest version 28 | version: latest 29 | 30 | # Optional: working directory, useful for monorepos 31 | # working-directory: somedir 32 | 33 | # Optional: golangci-lint command line arguments. 34 | # args: --issues-exit-code=0 35 | 36 | # Optional: show only new issues if it's a pull request. The default value is `false`. 37 | # only-new-issues: true 38 | 39 | # Optional: if set to true then the all caching functionality will be complete disabled, 40 | # takes precedence over all other caching options. 41 | # skip-cache: true 42 | 43 | # Optional: if set to true then the action don't cache or restore ~/go/pkg. 44 | # skip-pkg-cache: true 45 | 46 | # Optional: if set to true then the action don't cache or restore ~/.cache/go-build. 47 | # skip-build-cache: true 48 | -------------------------------------------------------------------------------- /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | name: cd 2 | 3 | on: 4 | push: 5 | tags: 6 | - v* 7 | workflow_dispatch: 8 | 9 | jobs: 10 | 11 | docker: 12 | uses: ./.github/workflows/docker.yml 13 | with: 14 | push: true 15 | secrets: inherit 16 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Binaries for programs and plugins 2 | *.exe 3 | *.dll 4 | *.so 5 | *.dylib 6 | 7 | # Test binary, build with `go test -c` 8 | *.test 9 | 10 | # SQLite Databases 11 | *.db 12 | 13 | # Log files 14 | *.log 15 | 16 | # Output of the go coverage tool, specifically when used with LiteIDE 17 | coverage* 18 | *.out 19 | 20 | # Dependency directories 21 | vendor/ 22 | _vendor-*/ 23 | 24 | # Build artifacts 25 | bin/ 26 | dist/ 27 | 28 | # Local configurations 29 | .env* 30 | compose.override.yml 31 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # Contributing 2 | 3 | [semver]: http://semver.org/ 4 | 5 | When contributing to this repository, please first discuss the change you wish 6 | to make via issue, email, or any other method with the owners of this repository 7 | before making a change. 8 | 9 | Please note we have a code of conduct, please follow it in all your interactions 10 | with the project. 11 | 12 | ## Pull Request Process 13 | 14 | 1. Ensure any install or build dependencies are removed before the end of the 15 | layer when performing a build. 16 | 2. Update the `README.md` or `docs` with details of change to the project, this 17 | includes new flags, environment variables, exposed ports, useful file 18 | locations and container parameters. 19 | 3. Specify how your change should affect our versioning scheme when merged. For 20 | more information on how we implement versioning, check out the [semver][] 21 | documentation. PRs will be grouped into logical version groups so that we 22 | aren't incrementing the version on every merge. 23 | 4. You may merge the Pull Request in once you have the sign-off of other 24 | developers, or if you do not have permission to do that, you may request a 25 | reviewer to merge it for you. 26 | 27 | ## Code of Conduct 28 | 29 | ### Our Pledge 30 | 31 | In the interest of fostering an open and welcoming environment, we as 32 | contributors and maintainers pledge to making participation in our project and 33 | our community a harassment-free experience for everyone, regardless of age, body 34 | size, disability, ethnicity, gender identity and expression, level of experience, 35 | nationality, personal appearance, race, religion, or sexual identity and 36 | orientation. 37 | 38 | ### Our Standards 39 | 40 | Examples of behavior that contributes to creating a positive environment 41 | include: 42 | 43 | * Using welcoming and inclusive language 44 | * Being respectful of differing viewpoints and experiences 45 | * Gracefully accepting constructive criticism 46 | * Focusing on what is best for the community 47 | * Showing empathy towards other community members 48 | 49 | Examples of unacceptable behavior by participants include: 50 | 51 | * The use of sexualized language or imagery and unwelcome sexual attention or 52 | advances 53 | * Trolling, insulting/derogatory comments, and personal or political attacks 54 | * Public or private harassment 55 | * Publishing others' private information, such as a physical or electronic 56 | address, without explicit permission 57 | * Other conduct which could reasonably be considered inappropriate in a 58 | professional setting 59 | 60 | ### Our Responsibilities 61 | 62 | Project maintainers are responsible for clarifying the standards of acceptable 63 | behavior and are expected to take appropriate and fair corrective action in 64 | response to any instances of unacceptable behavior. 65 | 66 | Project maintainers have the right and responsibility to remove, edit, or 67 | reject comments, commits, code, wiki edits, issues, and other contributions 68 | that are not aligned to this Code of Conduct, or to ban temporarily or 69 | permanently any contributor for other behaviors that they deem inappropriate, 70 | threatening, offensive, or harmful. 71 | 72 | ### Scope 73 | 74 | This Code of Conduct applies both within project spaces and in public spaces 75 | when an individual is representing the project or its community. Examples of 76 | representing a project or community include using an official project e-mail 77 | address, posting via an official social media account, or acting as an appointed 78 | representative at an online or offline event. Representation of a project may be 79 | further defined and clarified by project maintainers. 80 | 81 | ### Enforcement 82 | 83 | Instances of abusive, harassing, or otherwise unacceptable behavior may be 84 | reported by contacting the project maintainer at syntaqx [at] gmail.com. All 85 | complaints will be reviewed and investigated and will result in a response that 86 | is deemed necessary and appropriate to the circumstances. The project team is 87 | obligated to maintain confidentiality with regard to the reporter of an incident. 88 | Further details of specific enforcement policies may be posted separately. 89 | 90 | Project maintainers who do not follow or enforce the Code of Conduct in good 91 | faith may face temporary or permanent repercussions as determined by other 92 | members of the project's leadership. 93 | 94 | ### Attribution 95 | 96 | This Code of Conduct is adapted from the [Contributor Covenant][homepage], 97 | version 1.4, available at [http://contributor-covenant.org/version/1/4][version] 98 | 99 | [homepage]: http://contributor-covenant.org 100 | [version]: http://contributor-covenant.org/version/1/4/ 101 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM golang:1.20-alpine AS builder 2 | 3 | ARG VERSION="0.0.0-docker" 4 | 5 | RUN apk add --update --no-cache \ 6 | ca-certificates tzdata openssh git mercurial && update-ca-certificates \ 7 | && rm -rf /var/cache/apk/* 8 | 9 | WORKDIR /src 10 | 11 | COPY go.mod* go.sum* ./ 12 | RUN --mount=type=cache,target=/go/pkg/mod go mod download 13 | 14 | COPY . . 15 | RUN --mount=type=cache,target=/go/pkg/mod \ 16 | --mount=type=cache,target=/root/.cache/go-build \ 17 | CGO_ENABLED=0 go install -ldflags "-X main.version=$VERSION" ./cmd/... 18 | 19 | FROM alpine 20 | 21 | RUN adduser -S -D -H -h /app appuser 22 | USER appuser 23 | 24 | COPY --from=builder /usr/share/zoneinfo /usr/share/zoneinfo 25 | COPY --from=builder /etc/ssl/certs/ca-certificates.crt /etc/ssl/certs/ 26 | COPY --from=builder /go/bin/* /bin/ 27 | 28 | ENV PORT=8080 29 | EXPOSE $PORT 30 | 31 | VOLUME ["/var/www"] 32 | 33 | CMD ["serve", "--dir", "/var/www"] 34 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) Chase Pierce 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in 13 | all copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 21 | THE SOFTWARE. 22 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | VERSION=`git --no-pager describe --tags --always` 2 | 3 | LDFLAGS+= 4 | LDFLAGS+=-X main.version=${VERSION} 5 | 6 | build: 7 | go build -ldflags "${LDFLAGS}" -o bin/serve ./cmd/serve 8 | 9 | install: 10 | go install -ldflags "${LDFLAGS}" ./cmd/serve 11 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # 2 | 3 | `serve` is a static http server anywhere you need one. 4 | 5 | [homebrew]: https://brew.sh/ 6 | [git]: https://git-scm.com/ 7 | [golang]: https://golang.org/ 8 | [releases]: https://github.com/syntaqx/serve/releases 9 | [modules]: https://github.com/golang/go/wiki/Modules 10 | [docker-hub]: https://hub.docker.com/r/syntaqx/serve 11 | 12 | [![Mentioned in Awesome Go](https://awesome.re/mentioned-badge.svg)](https://github.com/avelino/awesome-go) 13 | 14 | [![codecov](https://codecov.io/gh/syntaqx/serve/branch/main/graph/badge.svg?token=FGkU1ntp8z)](https://codecov.io/gh/syntaqx/serve) 15 | [![Go Report Card](https://goreportcard.com/badge/github.com/syntaqx/serve)](https://goreportcard.com/report/github.com/syntaqx/serve) 16 | [![Go Reference](https://pkg.go.dev/badge/github.com/syntaqx/serve.svg)](https://pkg.go.dev/github.com/syntaqx/serve) 17 | 18 | [![GitHub Release](https://img.shields.io/github/release-pre/syntaqx/serve.svg)][releases] 19 | [![Docker Pulls](https://img.shields.io/docker/pulls/syntaqx/serve.svg)][docker-hub] 20 | 21 | > 🚨 The `main` branch is currently in active R&D for the next release of `serve`. 22 | > To use `serve`, please be sure to download a previous [release](https://github.com/syntaqx/serve/releases) as no stability guarantees 23 | > are being made further progress has been made towards a release candidate. 24 | 25 | ## TL;DR 26 | 27 | > It's basically `python -m SimpleHTTPServer 8080` written in Go, because who 28 | > can remember that many letters? 29 | 30 | ### Features 31 | 32 | * HTTPS (TLS) 33 | * CORS support 34 | * Request logging 35 | * `net/http` compatible 36 | * Support for [BasicAuth](https://en.wikipedia.org/wiki/Basic_access_authentication) via `users.json` 37 | 38 | ## Installation 39 | 40 | `serve` can be installed in a handful of ways: 41 | 42 | ### Homebrew on macOS 43 | 44 | If you are using [Homebrew][] on macOS, you can install `serve` with the 45 | following command: 46 | 47 | ```sh 48 | brew install syntaqx/tap/serve 49 | ``` 50 | 51 | ### Docker 52 | 53 | The official [syntaqx/serve][docker-hub] image is available on Docker Hub. 54 | 55 | To get started, try hosting a directory from your docker host: 56 | 57 | ```sh 58 | docker run -v .:/var/www:ro -d syntaqx/serve 59 | ``` 60 | 61 | Alternatively, a simple `Dockerfile` can be used to generate a new image that 62 | includes the necessary content: 63 | 64 | ```dockerfile 65 | FROM syntaqx/serve 66 | COPY . /var/www 67 | ``` 68 | 69 | Place this in the same directory as your content, then `build` and `run` the 70 | container: 71 | 72 | ```sh 73 | docker build -t some-content-serve . 74 | docker run --name some-serve -d some-content-serve 75 | ``` 76 | 77 | #### Exposing an external port 78 | 79 | ```sh 80 | docker run --name some-serve -d -p 8080:8080 some-content-serve 81 | ``` 82 | 83 | Then you can navigate to http://localhost:8080/ or http://host-ip:8080/ in your 84 | browser. 85 | 86 | #### Using environment variables for configuration 87 | 88 | [12-factor-config]: https://12factor.net/config 89 | 90 | Currently, `serve` only supports using the `PORT` environment variable for 91 | setting the listening port. All other configurations are available as CLI flags. 92 | 93 | > In future releases, most configurations will be settable from both the CLI 94 | > flag as well as a compatible environment variable, aligning with the 95 | > expectations of a [12factor app][12-factor-config]. But, that will require a 96 | > fair amount of work before the functionality is made available. 97 | 98 | Here's an example using `compose.yml` to configure `serve` to use HTTPS: 99 | 100 | ```yaml 101 | version: '3' 102 | services: 103 | web: 104 | image: syntaqx/serve 105 | volumes: 106 | - ./static:/var/www 107 | - ./fixtures:/etc/ssl 108 | environment: 109 | - PORT=1234 110 | ports: 111 | - 1234 112 | command: serve -ssl -cert=/etc/ssl/cert.pem -key=/etc/ssl/key.pem -dir=/var/www 113 | ``` 114 | 115 | The project repository provides an example [compose](./compose.yml) that 116 | implements a variety of common use-cases for `serve`. Feel free to use those to 117 | help you get started. 118 | 119 | ### Download the binary 120 | 121 | Quickly download install the latest release: 122 | 123 | ```sh 124 | curl -sfL https://install.goreleaser.com/github.com/syntaqx/serve.sh | sh 125 | ``` 126 | 127 | Or manually download the [latest release][releases] binary for your system and 128 | architecture and install it into your `$PATH`. 129 | 130 | ### From source 131 | 132 | To build from source, check out the instructions on getting started with 133 | [development](#development). 134 | 135 | ## Usage 136 | 137 | ```sh 138 | serve [options] [path] 139 | ``` 140 | 141 | > `[path]` defaults to `.` (relative path to the current directory) 142 | 143 | Then simply open your browser to http://localhost:8080 to view your server. 144 | 145 | ### Options 146 | 147 | The following configuration options are available: 148 | 149 | * `--host` host address to bind to (defaults to `0.0.0.0`) 150 | * `--port` listening port (defaults to `8080`) 151 | * `--ssl` enable https (defaults to `false`) 152 | * `--cert` path to the ssl cert file (defaults to `cert.pem`) 153 | * `--key` path to the ssl key file (defaults to `key.pem`) 154 | * `--dir` directory path to serve (defaults to `.`, also configurable by `arg[0]`) 155 | * `--users` path to users file (defaults to `users.dat`); file should contain lines of username:password in plain text 156 | 157 | ## Development 158 | 159 | To develop `serve` or interact with its source code in any meaningful way, be 160 | sure you have the following installed: 161 | 162 | ### Prerequisites 163 | 164 | * [Git][git] 165 | * [Go][golang] 166 | 167 | ### Install 168 | 169 | You can download and install the project from GitHub by simply running: 170 | 171 | ```sh 172 | git clone git@github.com:syntaqx/serve.git && cd $(basename $_ .git) 173 | make install 174 | ``` 175 | 176 | This will install `serve` into your `$GOPATH/bin` directory, which assuming is 177 | properly appended to your `$PATH`, can now be used: 178 | 179 | ```sh 180 | $ serve version 181 | serve version v0.0.6-8-g5074d63 windows/amd64 182 | ``` 183 | 184 | ## Using `serve` manually 185 | 186 | Besides running `serve` using the provided binary, you can also embed a 187 | `serve.FileServer` into your own Go program: 188 | 189 | ```go 190 | package main 191 | 192 | import ( 193 | "log" 194 | "net/http" 195 | 196 | "github.com/syntaqx/serve" 197 | ) 198 | 199 | func main() { 200 | fs := serve.NewFileServer() 201 | log.Fatal(http.ListenAndServe(":8080", fs)) 202 | } 203 | ``` 204 | 205 | ## License 206 | 207 | [MIT]: https://opensource.org/licenses/MIT 208 | 209 | `serve` is open source software released under the [MIT license][MIT]. 210 | 211 | As with all Docker images, these likely also contain other software which may be 212 | under other licenses (such as Bash, etc from the base distribution, along with 213 | any direct or indirect dependencies of the primary software being contained). 214 | 215 | As for any pre-built image usage, it is the image user's responsibility to 216 | ensure that any use of this image complies with any relevant licenses for all 217 | software contained within. 218 | -------------------------------------------------------------------------------- /cmd/serve/main.go: -------------------------------------------------------------------------------- 1 | // Package main implements the runtime for the serve binary. 2 | package main 3 | 4 | import ( 5 | "flag" 6 | "log" 7 | "os" 8 | 9 | "github.com/syntaqx/serve/internal/commands" 10 | "github.com/syntaqx/serve/internal/config" 11 | ) 12 | 13 | var version = "0.0.0-develop" 14 | 15 | func main() { 16 | var opt config.Flags 17 | flag.BoolVar(&opt.Debug, "debug", false, "enable debug output") 18 | flag.StringVar(&opt.Host, "host", "", "host address to bind to") 19 | flag.StringVar(&opt.Port, "port", "8080", "listening port") 20 | flag.BoolVar(&opt.EnableSSL, "ssl", false, "enable https") 21 | flag.StringVar(&opt.CertFile, "cert", "cert.pem", "path to the ssl cert file") 22 | flag.StringVar(&opt.KeyFile, "key", "key.pem", "path to the ssl key file") 23 | flag.StringVar(&opt.Directory, "dir", "", "directory path to serve") 24 | flag.StringVar(&opt.UsersFile, "users", "users.dat", "path to users file") 25 | flag.Parse() 26 | 27 | log := log.New(os.Stderr, "[serve] ", log.LstdFlags) 28 | 29 | // Allow port to be configured via the environment variable PORT. 30 | // This is both better for configuration, and required for Heroku. 31 | if port, ok := os.LookupEnv("PORT"); ok { 32 | opt.Port = port 33 | } 34 | 35 | cmd := flag.Arg(0) 36 | 37 | dir, err := config.SanitizeDir(opt.Directory, cmd) 38 | if err != nil { 39 | log.Printf("sanitize directory: %v", err) 40 | os.Exit(1) 41 | } 42 | 43 | switch cmd { 44 | case "version": 45 | err = commands.Version(version, os.Stderr) 46 | default: 47 | err = commands.Server(log, opt, dir) 48 | } 49 | 50 | if err != nil { 51 | log.Printf("cmd.%s: %v", cmd, err) 52 | os.Exit(1) 53 | } 54 | } 55 | -------------------------------------------------------------------------------- /compose.yml: -------------------------------------------------------------------------------- 1 | services: 2 | 3 | # Note: You probably will want to remove the `build: .` lines if you copy 4 | # these into your project. That is used to be able to rebuild the image 5 | # directly in the project repsitory. 6 | 7 | basic: 8 | build: . 9 | image: syntaqx/serve 10 | volumes: 11 | - ./static:/var/www 12 | ports: 13 | - 8080:8080 14 | 15 | basic_ssl: 16 | build: . 17 | image: syntaqx/serve 18 | volumes: 19 | - ./static:/var/www 20 | - ./fixtures:/etc/ssl 21 | ports: 22 | - 8888:8080 23 | command: serve -ssl -cert=/etc/ssl/cert.pem -key=/etc/ssl/key.pem -dir=/var/www 24 | -------------------------------------------------------------------------------- /docs/logo.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | Asset 2 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | -------------------------------------------------------------------------------- /examples/basic/main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "log" 5 | "net/http" 6 | 7 | "github.com/syntaqx/serve" 8 | ) 9 | 10 | func main() { 11 | fs := serve.NewFileServer(serve.Options{ 12 | Directory: "../../static", 13 | }) 14 | 15 | log.Print("serve started at http://localhost:8080/") 16 | log.Fatal(http.ListenAndServe(":8080", fs)) 17 | } 18 | -------------------------------------------------------------------------------- /fixtures/cert.pem: -------------------------------------------------------------------------------- 1 | -----BEGIN CERTIFICATE----- 2 | MIIEIDCCAwigAwIBAgIUG4x9A3w/n65jwz3y7Wo8MDrU6QEwDQYJKoZIhvcNAQEL 3 | BQAweTELMAkGA1UEBhMCVVMxCzAJBgNVBAgMAk5ZMREwDwYDVQQHDAhOZXcgWW9y 4 | azEVMBMGA1UECgwMRXhhbXBsZSwgTExDMRIwEAYDVQQDDAlzaXRlLnRlc3QxHzAd 5 | BgkqhkiG9w0BCQEWEHRlc3RAZXhhbXBsZS5jb20wHhcNMTkwMTE3MTA0NDM0WhcN 6 | MjAwMTE3MTA0NDM0WjB5MQswCQYDVQQGEwJVUzELMAkGA1UECAwCTlkxETAPBgNV 7 | BAcMCE5ldyBZb3JrMRUwEwYDVQQKDAxFeGFtcGxlLCBMTEMxEjAQBgNVBAMMCXNp 8 | dGUudGVzdDEfMB0GCSqGSIb3DQEJARYQdGVzdEBleGFtcGxlLmNvbTCCASIwDQYJ 9 | KoZIhvcNAQEBBQADggEPADCCAQoCggEBALLlVETDAxfpbMrL9vlTKu2y+G8y7qNv 10 | KIdp5FllHAtZVPMis1xV9U4xvpy7baKTKKPtKEYZGcy/gW4fEN9KlvHZSUqrLj7T 11 | X0ySTNkwGItZy+gm1gbwvbQGtL4atgu0jPsJB662DIzq4dLL1OAFMV6VfmY9r2Hs 12 | ARhe0XjGtXKlX+Fyqnbxsot02C01CtFDcEftHR5KUZeUHkoIHmO+5ZtRAgAIfhV/ 13 | DQfyn+GfXOfM7PWGfy7RdyyLMrD+SwdfJFpkeeqQTi7p3PIIuHmieGOBjIOUhRv2 14 | IEA7PbMNwoernE3Ey6iwErPjshWhSdLFG4NfAPs/KxDKe0qByRLOfZECAwEAAaOB 15 | nzCBnDAdBgNVHQ4EFgQUWlS44ZoMP/8IkJhHwxzJcfZ7IuIwHwYDVR0jBBgwFoAU 16 | WlS44ZoMP/8IkJhHwxzJcfZ7IuIwCQYDVR0TBAIwADALBgNVHQ8EBAMCBaAwFAYD 17 | VR0RBA0wC4IJc2l0ZS50ZXN0MCwGCWCGSAGG+EIBDQQfFh1PcGVuU1NMIEdlbmVy 18 | YXRlZCBDZXJ0aWZpY2F0ZTANBgkqhkiG9w0BAQsFAAOCAQEASQ/wPIrRSsIEewDg 19 | t6dehznWR+iBMGWGMpDEVw/IpRSN1zxLJp3i/4Yjcr98bEIP4tW27OODSJSKz11R 20 | 6/Kb/B04g3s7N4iSAehpeQXPGktNlgGojZSXi7u2y5ON6QBAle5csFxIkuOWDVwH 21 | qM/lsVlNHGyM0BGVMm5VLi2OWSqspz6Lr6yguT7U/AJ/hPe+YjSU5Kc+OnCZ4IH0 22 | NcdVG5aPpDFeZ7c9v1uHa7b725lyXUYO8xfWR3QV6CsTLgRFWhwYBXF51sZbBBsr 23 | fu78txegVWnYau4uh/nytqPoOnjoP4BAMKlynPfIpJ9TLWxosWeXro2xY5zvdFkp 24 | XH/+0g== 25 | -----END CERTIFICATE----- 26 | -------------------------------------------------------------------------------- /fixtures/key.pem: -------------------------------------------------------------------------------- 1 | -----BEGIN PRIVATE KEY----- 2 | MIIEvgIBADANBgkqhkiG9w0BAQEFAASCBKgwggSkAgEAAoIBAQCy5VREwwMX6WzK 3 | y/b5UyrtsvhvMu6jbyiHaeRZZRwLWVTzIrNcVfVOMb6cu22ikyij7ShGGRnMv4Fu 4 | HxDfSpbx2UlKqy4+019MkkzZMBiLWcvoJtYG8L20BrS+GrYLtIz7CQeutgyM6uHS 5 | y9TgBTFelX5mPa9h7AEYXtF4xrVypV/hcqp28bKLdNgtNQrRQ3BH7R0eSlGXlB5K 6 | CB5jvuWbUQIACH4Vfw0H8p/hn1znzOz1hn8u0XcsizKw/ksHXyRaZHnqkE4u6dzy 7 | CLh5onhjgYyDlIUb9iBAOz2zDcKHq5xNxMuosBKz47IVoUnSxRuDXwD7PysQyntK 8 | gckSzn2RAgMBAAECggEBAIJ5/q80KHJtPnrermAER6AcU1QPKrwq271//xswQncI 9 | jYvTeEvVKdgBMgvwK7NSb2a4FxKhRg7ucgEWSWECbvsvxmPeXBlYYv5fCguyJ4Sj 10 | VrQYdyuStFm0Nmkc5D+/TL/fQyoq/xZcTZ5IKhfF0c8xa4I4ZU0fK2FR7qePDlHx 11 | kAjInhIAPxCh7vhKk35duhr8r7IDQ33jVyPQ7DgsEIKRh85CVxkcwrtV1sY3LM/O 12 | xmrYWxHzpke06qZBJROjAFKv1kV7NT3eKzgKg16yDkFqYdh38RnFsTB6/zgZ+rko 13 | Jj23tynefYRx3e3feAvhnDQzY32HwKCA4fNm0brJrf0CgYEA7cdXzN0QLwvhvjem 14 | t0gNdcfk0f9pM0wcYh0n7ESANsKAkjAOBqlvJ6tRV1LaqeIX+y1yeBnUIVH+dNfA 15 | tM2nTiilvaasR1Er40c3eeyIhWJ8nC+wBGexxDg3Ys4B0azzcakCYkG6BuVdsAWD 16 | aYdqWf6Tl80l7HwonCVFsu8nX+cCgYEAwJrX3agdZWTuAcFcdGIXWK1m8+4yGv6t 17 | fvwh9X/rkDQHJ5HXDsHmTc8yh/Qa35OzcZJxBooW5azmzVpEbgE/HjnBpNDjp0VT 18 | Xk5k+bZkWgp6wN8BFrh2Me8hliRs93vsUZ+fnFJWgxMTPMpOvhcw9YjucG6lGpwk 19 | ynGkJ0/bZ8cCgYAs8hVioBbDDdfqANL+qhwBO3vBRio4jBaBZUl6m6gwsatj9rlw 20 | AO8F7Jg/jWXP3vDxhbGxihBTDBCxPWcrxgPt/jj2FF9US7+kAn42CcP0kp1DWLBI 21 | 5ODxWj796jrly29o+K1+rTXgv9Jpx2EDvZkY0cpMU3brsLxsZ485N4OV2QKBgQCV 22 | G0rinrOjO2/GjBs3Pnk0fYmmblD79Q37sNXZaR7ElIK1b4I+On5A3pcQCTqEu6O/ 23 | 2M8HcQAo7qH/eFJhlzV2AOCY595WMKVJ7QbfCwTFcDd3+Syumj9miOpHgguZzKY2 24 | yoyWSGgRMUNDXJt5LhsI+ukcwYuv/hG9aBzdEkWZIQKBgGLj5nwaJZWPJ381adJX 25 | JhwQcnS7cZIKrAifCay1oOaOcdQq/07QdBEjR6YT/X7oZCPtiDOdat9vzWKLNEY/ 26 | nYY+XFijSz2CKvT+CScjJSxmrsCtiNBQRtaTSKWAcgCpSqN5S+mocWmInZBVtZev 27 | 1OueDMUyPAsCabIR4HiTgAIs 28 | -----END PRIVATE KEY----- 29 | -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module github.com/syntaqx/serve 2 | 3 | go 1.20 4 | 5 | require github.com/stretchr/testify v1.10.0 6 | 7 | require ( 8 | github.com/davecgh/go-spew v1.1.1 // indirect 9 | github.com/pmezard/go-difflib v1.0.0 // indirect 10 | gopkg.in/yaml.v3 v3.0.1 // indirect 11 | ) 12 | -------------------------------------------------------------------------------- /go.sum: -------------------------------------------------------------------------------- 1 | github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= 2 | github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 3 | github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= 4 | github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= 5 | github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA= 6 | github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= 7 | gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= 8 | gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= 9 | gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= 10 | gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= 11 | -------------------------------------------------------------------------------- /internal/commands/server.go: -------------------------------------------------------------------------------- 1 | package commands 2 | 3 | import ( 4 | "bufio" 5 | "io" 6 | "log" 7 | "net" 8 | "net/http" 9 | "os" 10 | "strings" 11 | "time" 12 | 13 | "github.com/syntaqx/serve" 14 | "github.com/syntaqx/serve/internal/config" 15 | "github.com/syntaqx/serve/internal/middleware" 16 | ) 17 | 18 | var getHTTPServerFunc = GetStdHTTPServer 19 | 20 | // HTTPServer defines a returnable interface type for http.Server 21 | type HTTPServer interface { 22 | ListenAndServe() error 23 | ListenAndServeTLS(certFile, keyFile string) error 24 | } 25 | 26 | // GetStdHTTPServer returns a standard net/http.Server configured for a given 27 | // address and handler, and other sane defaults. 28 | func GetStdHTTPServer(addr string, h http.Handler) HTTPServer { 29 | return &http.Server{ 30 | Addr: addr, 31 | Handler: h, 32 | ReadTimeout: 15 * time.Second, 33 | WriteTimeout: 15 * time.Second, 34 | ReadHeaderTimeout: 60 * time.Second, 35 | } 36 | } 37 | 38 | // GetAuthUsers returns a map of users from a given io.Reader 39 | func GetAuthUsers(r io.Reader) map[string]string { 40 | users := make(map[string]string) 41 | 42 | if r != nil { 43 | scanner := bufio.NewScanner(r) 44 | for scanner.Scan() { 45 | if line := strings.Split(scanner.Text(), ":"); len(line) == 2 { // use only if correct format 46 | users[line[0]] = line[1] 47 | } 48 | } 49 | 50 | if err := scanner.Err(); err != nil { 51 | log.Fatalf("error occurred during reading users file") 52 | } 53 | } 54 | 55 | return users 56 | } 57 | 58 | // Server implements the static http server command. 59 | func Server(log *log.Logger, opt config.Flags, dir string) error { 60 | fs := serve.NewFileServer(serve.Options{ 61 | Directory: dir, 62 | }) 63 | 64 | // Authorization 65 | var f io.Reader 66 | if _, err := os.Stat(opt.UsersFile); !os.IsNotExist(err) { 67 | // Config file exists, load data 68 | f, err = os.Open(opt.UsersFile) 69 | if err != nil { 70 | log.Fatalf("unable to open users file %s", opt.UsersFile) 71 | } 72 | } else if opt.Debug { 73 | log.Printf("%s does not exist, authentication skipped", opt.UsersFile) 74 | } 75 | 76 | fs.Use( 77 | middleware.Logger(log), 78 | middleware.Recover(), 79 | middleware.CORS(), 80 | middleware.Auth(GetAuthUsers(f)), 81 | ) 82 | 83 | addr := net.JoinHostPort(opt.Host, opt.Port) 84 | server := getHTTPServerFunc(addr, fs) 85 | 86 | if opt.EnableSSL { 87 | log.Printf("https server listening at %s", addr) 88 | return server.ListenAndServeTLS(opt.CertFile, opt.KeyFile) 89 | } 90 | 91 | log.Printf("http server listening at %s", addr) 92 | return server.ListenAndServe() 93 | } 94 | -------------------------------------------------------------------------------- /internal/commands/server_test.go: -------------------------------------------------------------------------------- 1 | package commands 2 | 3 | import ( 4 | "bytes" 5 | "log" 6 | "net/http" 7 | "strings" 8 | "testing" 9 | "time" 10 | 11 | "github.com/stretchr/testify/assert" 12 | "github.com/syntaqx/serve/internal/config" 13 | "github.com/syntaqx/serve/mock" 14 | ) 15 | 16 | func getMockHTTPServerFunc(shouldError bool) func(addr string, h http.Handler) HTTPServer { 17 | return func(addr string, h http.Handler) HTTPServer { 18 | return &mock.HTTPServer{ShouldError: shouldError} 19 | } 20 | } 21 | 22 | func TestGetStdHTTPServer(t *testing.T) { 23 | _, ok := GetStdHTTPServer("", http.DefaultServeMux).(*http.Server) 24 | assert.True(t, ok) 25 | } 26 | 27 | func TestServer(t *testing.T) { 28 | getHTTPServerFunc = getMockHTTPServerFunc(false) 29 | 30 | assert := assert.New(t) 31 | 32 | var b bytes.Buffer 33 | log := log.New(&b, "[test] ", 0) 34 | opt := config.Flags{} 35 | 36 | assert.NoError(Server(log, opt, ".")) 37 | assert.Contains(b.String(), "http server listening at") 38 | 39 | getHTTPServerFunc = GetStdHTTPServer 40 | } 41 | 42 | func TestServerErr(t *testing.T) { 43 | getHTTPServerFunc = getMockHTTPServerFunc(true) 44 | 45 | assert := assert.New(t) 46 | 47 | var b bytes.Buffer 48 | log := log.New(&b, "[test] ", 0) 49 | opt := config.Flags{} 50 | 51 | time.Sleep(200 * time.Millisecond) 52 | 53 | assert.Error(Server(log, opt, ".")) 54 | time.Sleep(200 * time.Millisecond) 55 | 56 | getHTTPServerFunc = GetStdHTTPServer 57 | } 58 | 59 | func TestServerHTTPS(t *testing.T) { 60 | getHTTPServerFunc = getMockHTTPServerFunc(false) 61 | 62 | assert := assert.New(t) 63 | 64 | var b bytes.Buffer 65 | log := log.New(&b, "[test] ", 0) 66 | 67 | opt := config.Flags{ 68 | EnableSSL: true, 69 | CertFile: "../../fixtures/cert.pem", 70 | KeyFile: "../../fixtures/key.pem", 71 | } 72 | 73 | assert.NoError(Server(log, opt, ".")) 74 | assert.Contains(b.String(), "https server listening at") 75 | 76 | getHTTPServerFunc = GetStdHTTPServer 77 | } 78 | 79 | func TestGetAuthUsers(t *testing.T) { 80 | tests := []struct { 81 | input string 82 | output map[string]string 83 | }{ 84 | { // Single user 85 | "user1:pass1", map[string]string{ 86 | "user1": "pass1", 87 | }, 88 | }, 89 | { // Multiple users 90 | "user1:pass1\nuser2:pass2", map[string]string{ 91 | "user1": "pass1", 92 | "user2": "pass2", 93 | }, 94 | }, 95 | { // Empty file 96 | "", map[string]string{}, 97 | }, 98 | { // Incorrect structure 99 | "user1:pass1:field1", map[string]string{}, 100 | }, 101 | } 102 | 103 | for _, test := range tests { 104 | mockFile := strings.NewReader(test.input) 105 | assert.Equal(t, GetAuthUsers(mockFile), test.output) 106 | } 107 | 108 | } 109 | -------------------------------------------------------------------------------- /internal/commands/version.go: -------------------------------------------------------------------------------- 1 | package commands 2 | 3 | import ( 4 | "fmt" 5 | "io" 6 | "runtime" 7 | ) 8 | 9 | // Version implements the command `version` which outputs the current binary 10 | // release version, if any. 11 | func Version(version string, w io.Writer) error { 12 | fmt.Fprintf(w, "serve version %s %s/%s\n", version, runtime.GOOS, runtime.GOARCH) 13 | return nil 14 | } 15 | -------------------------------------------------------------------------------- /internal/commands/version_test.go: -------------------------------------------------------------------------------- 1 | package commands 2 | 3 | import ( 4 | "bytes" 5 | "testing" 6 | 7 | "github.com/stretchr/testify/assert" 8 | ) 9 | 10 | func TestVersion(t *testing.T) { 11 | t.Parallel() 12 | assert := assert.New(t) 13 | 14 | var b bytes.Buffer 15 | err := Version("mock", &b) 16 | 17 | assert.NoError(err) 18 | assert.Contains(b.String(), "version mock") 19 | } 20 | -------------------------------------------------------------------------------- /internal/config/flags.go: -------------------------------------------------------------------------------- 1 | package config 2 | 3 | import ( 4 | "fmt" 5 | "os" 6 | ) 7 | 8 | var getwd = os.Getwd 9 | 10 | // Flags are the expose configuration flags available to the serve binary. 11 | type Flags struct { 12 | Debug bool 13 | Host string 14 | Port string 15 | EnableSSL bool 16 | CertFile string 17 | KeyFile string 18 | Directory string 19 | UsersFile string 20 | } 21 | 22 | // SanitizeDir allows a directory source to be set from multiple values. If any 23 | // value is defined, that value is used. If none are defined, the current 24 | // working directory is retrieved. 25 | func SanitizeDir(dirs ...string) (string, error) { 26 | for _, dir := range dirs { 27 | if len(dir) > 0 { 28 | return dir, nil 29 | } 30 | } 31 | 32 | cwd, err := getwd() 33 | if err != nil { 34 | return "", fmt.Errorf("cannot determine cwd: %v", err) 35 | } 36 | 37 | return cwd, nil 38 | } 39 | -------------------------------------------------------------------------------- /internal/config/flags_test.go: -------------------------------------------------------------------------------- 1 | package config 2 | 3 | import ( 4 | "errors" 5 | "os" 6 | "testing" 7 | 8 | "github.com/stretchr/testify/assert" 9 | ) 10 | 11 | func TestSanitizeDir(t *testing.T) { 12 | t.Parallel() 13 | assert := assert.New(t) 14 | 15 | cwd, err := os.Getwd() 16 | assert.NoError(err) 17 | 18 | var tests = []struct { 19 | dirs []string 20 | expected string 21 | }{ 22 | {[]string{"foo", "bar"}, "foo"}, 23 | {[]string{"", "bar"}, "bar"}, 24 | {[]string{"", ""}, cwd}, 25 | } 26 | 27 | for _, tt := range tests { 28 | tt := tt 29 | t.Run("", func(t *testing.T) { 30 | t.Parallel() 31 | dir, err := SanitizeDir(tt.dirs...) 32 | assert.Equal(tt.expected, dir) 33 | assert.NoError(err) 34 | }) 35 | } 36 | } 37 | 38 | func TestSanitizeDirCwdErr(t *testing.T) { 39 | assert := assert.New(t) 40 | 41 | getwd = func() (string, error) { 42 | return "", errors.New("mock") 43 | } 44 | 45 | dir, err := SanitizeDir() 46 | assert.Empty(dir) 47 | assert.Error(err) 48 | 49 | getwd = os.Getwd 50 | } 51 | -------------------------------------------------------------------------------- /internal/middleware/auth.go: -------------------------------------------------------------------------------- 1 | package middleware 2 | 3 | import "net/http" 4 | 5 | // Auth sets basic HTTP authorization 6 | func Auth(users map[string]string) func(next http.Handler) http.Handler { 7 | return func(next http.Handler) http.Handler { 8 | fn := func(w http.ResponseWriter, r *http.Request) { 9 | // Only require auth if we have any users 10 | if len(users) > 0 { 11 | authUser, authPass, ok := r.BasicAuth() 12 | if !ok { 13 | // No username/password received 14 | w.Header().Set("WWW-Authenticate", "Basic realm=Authenticate") 15 | w.WriteHeader(http.StatusUnauthorized) 16 | } else { 17 | if pass, ok := users[authUser]; ok { 18 | // User exists 19 | if pass == authPass { 20 | // Authentication successful 21 | next.ServeHTTP(w, r) 22 | } else { 23 | http.Error(w, "Incorrect login details", http.StatusUnauthorized) 24 | return 25 | } 26 | } else { 27 | http.Error(w, "Incorrect login details", http.StatusUnauthorized) 28 | return 29 | } 30 | } 31 | } else { 32 | next.ServeHTTP(w, r) 33 | } 34 | } 35 | 36 | return http.HandlerFunc(fn) 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /internal/middleware/auth_test.go: -------------------------------------------------------------------------------- 1 | package middleware 2 | 3 | import ( 4 | "net/http" 5 | "net/http/httptest" 6 | "testing" 7 | 8 | "github.com/stretchr/testify/assert" 9 | ) 10 | 11 | func TestAuth(t *testing.T) { 12 | t.Parallel() 13 | assert := assert.New(t) 14 | 15 | req, err := http.NewRequest(http.MethodGet, "/", nil) 16 | assert.NoError(err) 17 | res := httptest.NewRecorder() 18 | 19 | // No users 20 | testHandler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {}) 21 | Auth(nil)(testHandler).ServeHTTP(res, req) 22 | assert.Equal("", res.Header().Get("WWW-Authenticate")) 23 | 24 | // Some users 25 | testUsers := map[string]string{ 26 | "user1": "pass1", 27 | "user2": "pass2", 28 | } 29 | Auth(testUsers)(testHandler).ServeHTTP(res, req) 30 | assert.Equal("Basic realm=Authenticate", res.Header().Get("WWW-Authenticate")) 31 | assert.Equal(http.StatusUnauthorized, res.Result().StatusCode) 32 | 33 | // Correct password 34 | // Recreate new environment 35 | testHandler = http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {}) 36 | req, err = http.NewRequest(http.MethodGet, "/", nil) 37 | assert.NoError(err) 38 | res = httptest.NewRecorder() 39 | 40 | req.SetBasicAuth("user1", "pass1") 41 | Auth(testUsers)(testHandler).ServeHTTP(res, req) 42 | assert.Equal("", res.Header().Get("WWW-Authenticate")) 43 | assert.Equal(http.StatusOK, res.Result().StatusCode) 44 | 45 | // Incorrect password 46 | // Recreate new environment 47 | testHandler = http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {}) 48 | req, err = http.NewRequest(http.MethodGet, "/", nil) 49 | assert.NoError(err) 50 | res = httptest.NewRecorder() 51 | 52 | req.SetBasicAuth("user1", "pass2") 53 | Auth(testUsers)(testHandler).ServeHTTP(res, req) 54 | assert.Equal(http.StatusUnauthorized, res.Result().StatusCode) 55 | } 56 | -------------------------------------------------------------------------------- /internal/middleware/cors.go: -------------------------------------------------------------------------------- 1 | package middleware 2 | 3 | import ( 4 | "net/http" 5 | "strings" 6 | ) 7 | 8 | // CORS sets permissive cross-origin resource sharing rules. 9 | func CORS() func(next http.Handler) http.Handler { 10 | return func(next http.Handler) http.Handler { 11 | fn := func(w http.ResponseWriter, r *http.Request) { 12 | w.Header().Set("Access-Control-Allow-Origin", "*") 13 | w.Header().Set("Access-Control-Allow-Methods", strings.Join([]string{ 14 | http.MethodHead, 15 | http.MethodOptions, 16 | http.MethodGet, 17 | http.MethodPost, 18 | http.MethodPut, 19 | http.MethodPatch, 20 | http.MethodDelete, 21 | }, ", ")) 22 | next.ServeHTTP(w, r) 23 | } 24 | return http.HandlerFunc(fn) 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /internal/middleware/cors_test.go: -------------------------------------------------------------------------------- 1 | package middleware 2 | 3 | import ( 4 | "net/http" 5 | "net/http/httptest" 6 | "testing" 7 | 8 | "github.com/stretchr/testify/assert" 9 | ) 10 | 11 | func TestCORS(t *testing.T) { 12 | t.Parallel() 13 | assert := assert.New(t) 14 | 15 | req, err := http.NewRequest(http.MethodGet, "/", nil) 16 | assert.NoError(err) 17 | res := httptest.NewRecorder() 18 | 19 | testHandler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {}) 20 | CORS()(testHandler).ServeHTTP(res, req) 21 | 22 | assert.Equal("*", res.Header().Get("Access-Control-ALlow-Origin")) 23 | assert.Contains(res.Header().Get("Access-Control-Allow-Methods"), http.MethodGet) 24 | } 25 | -------------------------------------------------------------------------------- /internal/middleware/logger.go: -------------------------------------------------------------------------------- 1 | package middleware 2 | 3 | import ( 4 | "log" 5 | "net/http" 6 | ) 7 | 8 | // Logger is a middleware that logs each request, along with some useful data 9 | // about what was requested, and what the response was. 10 | func Logger(log *log.Logger) func(next http.Handler) http.Handler { 11 | return func(next http.Handler) http.Handler { 12 | fn := func(w http.ResponseWriter, r *http.Request) { 13 | sw := statusWriter{ResponseWriter: w} 14 | 15 | defer func() { 16 | log.Println(r.Method, r.URL.Path, sw.status, r.RemoteAddr, r.UserAgent()) 17 | }() 18 | next.ServeHTTP(&sw, r) 19 | } 20 | return http.HandlerFunc(fn) 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /internal/middleware/logger_test.go: -------------------------------------------------------------------------------- 1 | package middleware 2 | 3 | import ( 4 | "bytes" 5 | "log" 6 | "net/http" 7 | "net/http/httptest" 8 | "strings" 9 | "testing" 10 | 11 | "github.com/stretchr/testify/assert" 12 | ) 13 | 14 | var logTests = []struct { 15 | in func(w http.ResponseWriter, r *http.Request) 16 | out string 17 | }{ 18 | { 19 | in: func(w http.ResponseWriter, _ *http.Request) { 20 | _, err := w.Write([]byte{}) 21 | if err != nil { 22 | panic(err) 23 | } 24 | }, 25 | out: "[test] GET / 200", 26 | }, 27 | { 28 | in: func(w http.ResponseWriter, _ *http.Request) { 29 | w.WriteHeader(http.StatusNotFound) 30 | }, 31 | out: "[test] GET / 404", 32 | }, 33 | } 34 | 35 | func TestLogger(t *testing.T) { 36 | t.Parallel() 37 | 38 | for _, tt := range logTests { 39 | assert := assert.New(t) 40 | 41 | var b bytes.Buffer 42 | log := log.New(&b, "[test] ", 0) 43 | 44 | req, err := http.NewRequest(http.MethodGet, "/", nil) 45 | assert.NoError(err) 46 | res := httptest.NewRecorder() 47 | 48 | testHandler := http.HandlerFunc(tt.in) 49 | Logger(log)(testHandler).ServeHTTP(res, req) 50 | 51 | assert.Equal(tt.out, strings.TrimSpace(b.String())) 52 | } 53 | } 54 | -------------------------------------------------------------------------------- /internal/middleware/recover.go: -------------------------------------------------------------------------------- 1 | package middleware 2 | 3 | import ( 4 | "fmt" 5 | "net/http" 6 | ) 7 | 8 | // Recover is a middleware that recovers from panics that occur for a request. 9 | func Recover() func(next http.Handler) http.Handler { 10 | return func(next http.Handler) http.Handler { 11 | fn := func(w http.ResponseWriter, r *http.Request) { 12 | defer func() { 13 | if err := recover(); err != nil { 14 | http.Error(w, fmt.Sprintf("[PANIC RECOVERED] %v", err), http.StatusInternalServerError) 15 | } 16 | }() 17 | next.ServeHTTP(w, r) 18 | } 19 | return http.HandlerFunc(fn) 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /internal/middleware/recover_test.go: -------------------------------------------------------------------------------- 1 | package middleware 2 | 3 | import ( 4 | "net/http" 5 | "net/http/httptest" 6 | "strings" 7 | "testing" 8 | 9 | "github.com/stretchr/testify/assert" 10 | ) 11 | 12 | func TestRecover(t *testing.T) { 13 | 14 | t.Parallel() 15 | assert := assert.New(t) 16 | 17 | req, err := http.NewRequest(http.MethodGet, "/", nil) 18 | assert.NoError(err) 19 | res := httptest.NewRecorder() 20 | 21 | testHandler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 22 | panic("test") 23 | }) 24 | 25 | Recover()(testHandler).ServeHTTP(res, req) 26 | 27 | assert.Equal(http.StatusInternalServerError, res.Code) 28 | assert.Equal("[PANIC RECOVERED] test", strings.TrimSpace(res.Body.String())) 29 | } 30 | -------------------------------------------------------------------------------- /internal/middleware/statuswriter.go: -------------------------------------------------------------------------------- 1 | package middleware 2 | 3 | import "net/http" 4 | 5 | type statusWriter struct { 6 | http.ResponseWriter 7 | status int 8 | } 9 | 10 | func (w *statusWriter) WriteHeader(status int) { 11 | w.status = status 12 | w.ResponseWriter.WriteHeader(status) 13 | } 14 | 15 | func (w *statusWriter) Write(b []byte) (int, error) { 16 | if w.status == 0 { 17 | w.status = http.StatusOK 18 | } 19 | return w.ResponseWriter.Write(b) 20 | } 21 | -------------------------------------------------------------------------------- /mock/http.go: -------------------------------------------------------------------------------- 1 | package mock 2 | 3 | import "errors" 4 | 5 | // ErrMock is a mock error 6 | var ErrMock = errors.New("mock error") 7 | 8 | // HTTPServer is a mock http server 9 | type HTTPServer struct { 10 | ShouldError bool 11 | } 12 | 13 | // ListenAndServe is a mock http server method 14 | func (s *HTTPServer) ListenAndServe() error { 15 | if s.ShouldError { 16 | return ErrMock 17 | } 18 | return nil 19 | } 20 | 21 | // ListenAndServeTLS is a mock http server method 22 | func (s *HTTPServer) ListenAndServeTLS(certFile, keyFile string) error { 23 | if s.ShouldError { 24 | return ErrMock 25 | } 26 | return nil 27 | } 28 | -------------------------------------------------------------------------------- /serve.go: -------------------------------------------------------------------------------- 1 | // Package serve provides a static http server anywhere you need one. 2 | package serve 3 | 4 | import "net/http" 5 | 6 | // Options is a struct for specifying configuration options for a FileServer. 7 | type Options struct { 8 | // Directory is the root directory from which to serve files. 9 | Directory string 10 | 11 | // Prefix is a filepath prefix that should be ignored by the FileServer. 12 | Prefix string 13 | } 14 | 15 | // FileServer wraps an http.FileServer. 16 | type FileServer struct { 17 | opt Options 18 | handler http.Handler 19 | } 20 | 21 | // NewFileServer initializes a FileServer. 22 | func NewFileServer(options ...Options) *FileServer { 23 | var opt Options 24 | if len(options) > 0 { 25 | opt = options[0] 26 | } 27 | 28 | fs := &FileServer{ 29 | opt: opt, 30 | } 31 | 32 | fs.handler = http.StripPrefix(opt.Prefix, http.FileServer(http.Dir(opt.Directory))) 33 | return fs 34 | } 35 | 36 | // Use wraps the Handler with middleware(s). 37 | func (fs *FileServer) Use(mws ...func(http.Handler) http.Handler) { 38 | for _, h := range mws { 39 | fs.handler = h(fs.handler) 40 | } 41 | } 42 | 43 | // ServeHTTP implements the net/http.Handler interface. 44 | func (fs *FileServer) ServeHTTP(w http.ResponseWriter, r *http.Request) { 45 | fs.handler.ServeHTTP(w, r) 46 | } 47 | -------------------------------------------------------------------------------- /serve_test.go: -------------------------------------------------------------------------------- 1 | package serve 2 | 3 | import ( 4 | "net/http" 5 | "net/http/httptest" 6 | "testing" 7 | 8 | "github.com/stretchr/testify/assert" 9 | ) 10 | 11 | func TestFileServerDefaults(t *testing.T) { 12 | fs := NewFileServer() 13 | _ = fs 14 | } 15 | 16 | func TestFileServerOptions(t *testing.T) { 17 | fs := NewFileServer(Options{Directory: "test"}) 18 | _ = fs 19 | } 20 | 21 | func TestFileServerUse(t *testing.T) { 22 | t.Parallel() 23 | assert := assert.New(t) 24 | 25 | req, err := http.NewRequest(http.MethodGet, "/", nil) 26 | assert.NoError(err) 27 | res := httptest.NewRecorder() 28 | 29 | testMiddleware1 := func(next http.Handler) http.Handler { 30 | fn := func(w http.ResponseWriter, r *http.Request) { 31 | _, _ = w.Write([]byte("start\n")) 32 | next.ServeHTTP(w, r) 33 | } 34 | return http.HandlerFunc(fn) 35 | } 36 | 37 | testMiddleware2 := func(next http.Handler) http.Handler { 38 | fn := func(w http.ResponseWriter, _ *http.Request) { 39 | _, _ = w.Write([]byte("end\n")) 40 | } 41 | return http.HandlerFunc(fn) 42 | } 43 | 44 | fs := &FileServer{ 45 | handler: http.HandlerFunc(func(_ http.ResponseWriter, _ *http.Request) { 46 | t.Fail() 47 | }), 48 | } 49 | 50 | fs.Use(testMiddleware2, testMiddleware1) 51 | 52 | fs.ServeHTTP(res, req) 53 | 54 | assert.Equal("start\nend\n", res.Body.String()) 55 | } 56 | 57 | func TestFileServerServeHTTP(t *testing.T) { 58 | t.Parallel() 59 | assert := assert.New(t) 60 | 61 | req, err := http.NewRequest(http.MethodGet, "/", nil) 62 | assert.NoError(err) 63 | res := httptest.NewRecorder() 64 | 65 | fs := &FileServer{ 66 | handler: http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { 67 | _, _ = w.Write([]byte("expected")) 68 | }), 69 | } 70 | 71 | fs.ServeHTTP(res, req) 72 | 73 | assert.Equal("expected", res.Body.String()) 74 | } 75 | -------------------------------------------------------------------------------- /static/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | Welcome to your server! 8 | 9 | 10 | 99 | 100 | 101 | 102 |
103 |
104 |
105 | 106 |

serve

107 |

Your web server is working correctly!

108 |
109 |

You can edit this file at /var/www/index.html

110 | 115 |
116 |
117 | 118 | 119 | 120 | --------------------------------------------------------------------------------