├── .github └── workflows │ ├── docker.yaml │ └── hydrun.yaml ├── .gitignore ├── CODE_OF_CONDUCT.md ├── Dockerfile ├── Hydrunfile ├── LICENSE ├── Makefile ├── README.md ├── api └── proto │ └── v1 │ ├── events.proto │ └── metadata.proto ├── cmd ├── bofied-backend │ └── main.go └── bofied-frontend │ └── main.go ├── docs ├── demo.mp4 ├── icon.svg ├── logo.svg ├── screenshot-about-modal.png ├── screenshot-file-operations-2.png ├── screenshot-file-operations-3.png ├── screenshot-gnome-files-webdav-listing.png ├── screenshot-gnome-files-webdav-mounting.png ├── screenshot-initial.png ├── screenshot-monitoring.png ├── screenshot-mount-directory.png ├── screenshot-setup.png ├── screenshot-sharing.png ├── screenshot-syntax-validation.png └── screenshot-text-editor.png ├── examples └── bofied-backend-config.yaml ├── go.mod ├── go.sum ├── pkg ├── api │ └── proto │ │ └── v1 │ │ ├── events.pb.go │ │ ├── events_grpc.pb.go │ │ ├── metadata.pb.go │ │ └── metadata_grpc.pb.go ├── authorization │ └── oidc_over_basic_auth.go ├── components │ ├── about_modal.go │ ├── autofocused.go │ ├── breadcrumbs.go │ ├── copyable_input.go │ ├── data_shell.go │ ├── empty_state.go │ ├── expandable_section.go │ ├── file_explorer.go │ ├── file_grid.go │ ├── form_group.go │ ├── home.go │ ├── modal.go │ ├── navbar.go │ ├── notification_drawer.go │ ├── path_picker_toolbar.go │ ├── setup_form.go │ ├── setup_shell.go │ ├── status.go │ ├── switch.go │ ├── text_editor.go │ ├── text_editor_wrapper.go │ └── update_notification.go ├── config │ ├── architecture_types.go │ └── yaegi.go ├── constants │ ├── authorization.go │ └── config.go ├── eventing │ ├── event.go │ └── http_logging.go ├── providers │ ├── data_provider.go │ ├── identity_provider.go │ └── setup_provider.go ├── servers │ ├── dhcp.go │ ├── extended_http.go │ ├── file_server.go │ ├── grpc.go │ ├── proxy_dhcp.go │ ├── tftp.go │ └── udp_server.go ├── services │ ├── events.go │ └── metadata.go ├── transcoding │ ├── dhcp.go │ ├── proxy_dhcp.go │ └── pxe_class_identifier.go ├── utils │ └── ip.go ├── validators │ ├── check_syntax.go │ ├── context.go │ ├── format.go │ └── oidc.go └── websocketproxy │ ├── client.go │ └── server.go └── web ├── icon.png ├── index.css ├── logo-dark.png └── logo-light.png /.github/workflows/docker.yaml: -------------------------------------------------------------------------------- 1 | name: Docker CI 2 | 3 | on: 4 | push: 5 | pull_request: 6 | schedule: 7 | - cron: "0 0 * * 0" 8 | 9 | jobs: 10 | build-oci-images: 11 | runs-on: ${{ matrix.target.runner }} 12 | permissions: 13 | contents: read 14 | packages: write 15 | id-token: write 16 | strategy: 17 | matrix: 18 | target: 19 | - id: bofied-backend-linux-amd64 20 | src: . 21 | file: Dockerfile 22 | image: ghcr.io/pojntfx/bofied-backend 23 | arch: "linux/amd64,linux/arm/v7,linux/386,linux/s390x" # linux/mips64le,linux/ppc64le,linux/arm/v5 24 | runner: ubuntu-latest 25 | - id: bofied-backend-linux-arm64-v8 26 | src: . 27 | file: Dockerfile 28 | image: ghcr.io/pojntfx/bofied-backend 29 | arch: "linux/arm64/v8" 30 | runner: ubicloud-standard-4-arm 31 | 32 | steps: 33 | - name: Checkout 34 | uses: actions/checkout@v4 35 | - name: Set up QEMU 36 | uses: docker/setup-qemu-action@v3 37 | - name: Set up Docker Buildx 38 | uses: docker/setup-buildx-action@v3 39 | - name: Login to registry 40 | uses: docker/login-action@v3 41 | with: 42 | registry: ghcr.io 43 | username: ${{ github.actor }} 44 | password: ${{ secrets.GITHUB_TOKEN }} 45 | - name: Set up metadata 46 | id: meta 47 | uses: docker/metadata-action@v5 48 | with: 49 | images: ${{ matrix.target.image }} 50 | - name: Build and push image by digest to registry 51 | id: build 52 | uses: docker/build-push-action@v5 53 | with: 54 | context: ${{ matrix.target.src }} 55 | file: ${{ matrix.target.src }}/${{ matrix.target.file }} 56 | platforms: ${{ matrix.target.arch }} 57 | labels: ${{ steps.meta.outputs.labels }} 58 | outputs: type=image,name=${{ matrix.target.image }},push-by-digest=true,name-canonical=true,push=true 59 | cache-from: type=gha 60 | cache-to: type=gha,mode=max 61 | - name: Export digest 62 | run: | 63 | mkdir -p "/tmp/digests" 64 | export DIGEST="${{ steps.build.outputs.digest }}" 65 | touch "/tmp/digests/${DIGEST#sha256:}" 66 | - name: Upload digest 67 | uses: actions/upload-artifact@v4 68 | with: 69 | name: digests-${{ matrix.target.id }} 70 | path: /tmp/digests/* 71 | if-no-files-found: error 72 | retention-days: 1 73 | 74 | merge-oci-images: 75 | runs-on: ubuntu-latest 76 | permissions: 77 | contents: read 78 | packages: write 79 | id-token: write 80 | needs: build-oci-images 81 | strategy: 82 | matrix: 83 | target: 84 | - idprefix: bofied-backend-linux- 85 | image: ghcr.io/pojntfx/bofied-backend 86 | 87 | steps: 88 | - name: Checkout 89 | uses: actions/checkout@v4 90 | - name: Set up QEMU 91 | uses: docker/setup-qemu-action@v3 92 | - name: Set up Docker Buildx 93 | uses: docker/setup-buildx-action@v3 94 | - name: Login to registry 95 | uses: docker/login-action@v3 96 | with: 97 | registry: ghcr.io 98 | username: ${{ github.actor }} 99 | password: ${{ secrets.GITHUB_TOKEN }} 100 | - name: Set up metadata 101 | id: meta 102 | uses: docker/metadata-action@v5 103 | with: 104 | images: ${{ matrix.target.image }} 105 | tags: type=semver,pattern={{version}} 106 | - name: Download digests 107 | uses: actions/download-artifact@v4 108 | with: 109 | path: /tmp/digests 110 | pattern: digests-${{ matrix.target.idprefix }}* 111 | merge-multiple: true 112 | - name: Create pre-release manifest list and push to registry 113 | working-directory: /tmp/digests 114 | run: | 115 | docker buildx imagetools create --tag "${{ matrix.target.image }}:${{ github.ref_name }}" $(printf '${{ matrix.target.image }}@sha256:%s ' *) 116 | - name: Create release manifest list and push to registry 117 | if: startsWith(github.ref, 'refs/tags/v') 118 | working-directory: /tmp/digests 119 | run: | 120 | TAGS=$(echo "${{ steps.meta.outputs.tags }}" | tr '\n' ' ') 121 | for TAG in $TAGS; do 122 | docker buildx imagetools create --tag "$TAG" $(printf '${{ matrix.target.image }}@sha256:%s ' *); 123 | done 124 | -------------------------------------------------------------------------------- /.github/workflows/hydrun.yaml: -------------------------------------------------------------------------------- 1 | name: hydrun CI 2 | 3 | on: 4 | push: 5 | pull_request: 6 | schedule: 7 | - cron: "0 0 * * 0" 8 | 9 | jobs: 10 | build-linux: 11 | runs-on: ${{ matrix.target.runner }} 12 | permissions: 13 | contents: read 14 | strategy: 15 | matrix: 16 | target: 17 | # Tests 18 | - id: test-cli 19 | src: . 20 | os: golang:bookworm 21 | flags: -e '-v /tmp/ccache:/root/.cache/go-build' 22 | cmd: GOFLAGS="-short" ./Hydrunfile test/cli 23 | dst: out/nonexistent 24 | runner: ubuntu-latest 25 | - id: test-pwa 26 | src: . 27 | os: golang:bookworm 28 | flags: -e '-v /tmp/ccache:/root/.cache/go-build' 29 | cmd: GOFLAGS="-short" ./Hydrunfile test/pwa 30 | dst: out/nonexistent 31 | runner: ubuntu-latest 32 | 33 | # Binaries 34 | - id: go.bofied-backend 35 | src: . 36 | os: golang:bookworm 37 | flags: -e '-v /tmp/ccache:/root/.cache/go-build' 38 | cmd: ./Hydrunfile build/cli bofied-backend 39 | dst: out/* 40 | runner: ubuntu-latest 41 | 42 | # PWAs 43 | - id: pwa.bofied 44 | src: . 45 | os: golang:bookworm 46 | flags: -e '-v /tmp/ccache:/root/.cache/go-build' 47 | cmd: ./Hydrunfile build/pwa 48 | dst: out/* 49 | runner: ubuntu-latest 50 | - id: pwa.bofied-github-pages 51 | src: . 52 | os: golang:bookworm 53 | flags: -e '-v /tmp/ccache:/root/.cache/go-build' 54 | cmd: ./Hydrunfile build/pwa-github-pages && mv out/frontend.tar.gz out/frontend-github-pages.tar.gz 55 | dst: out/* 56 | runner: ubuntu-latest 57 | 58 | steps: 59 | - name: Checkout 60 | uses: actions/checkout@v4 61 | - name: Restore ccache 62 | uses: actions/cache/restore@v4 63 | with: 64 | path: | 65 | /tmp/ccache 66 | key: cache-ccache-${{ matrix.target.id }} 67 | - name: Set up QEMU 68 | uses: docker/setup-qemu-action@v3 69 | - name: Set up Docker Buildx 70 | uses: docker/setup-buildx-action@v3 71 | - name: Set up hydrun 72 | run: | 73 | curl -L -o /tmp/hydrun "https://github.com/pojntfx/hydrun/releases/latest/download/hydrun.linux-$(uname -m)" 74 | sudo install /tmp/hydrun /usr/local/bin 75 | - name: Build with hydrun 76 | working-directory: ${{ matrix.target.src }} 77 | run: hydrun -o ${{ matrix.target.os }} ${{ matrix.target.flags }} "${{ matrix.target.cmd }}" 78 | - name: Fix permissions for output 79 | run: sudo chown -R $USER . 80 | - name: Save ccache 81 | uses: actions/cache/save@v4 82 | with: 83 | path: | 84 | /tmp/ccache 85 | key: cache-ccache-${{ matrix.target.id }} 86 | - name: Upload output 87 | uses: actions/upload-artifact@v4 88 | with: 89 | name: ${{ matrix.target.id }} 90 | path: ${{ matrix.target.dst }} 91 | 92 | publish-linux: 93 | runs-on: ubuntu-latest 94 | permissions: 95 | contents: write 96 | pages: write 97 | id-token: write 98 | needs: build-linux 99 | environment: 100 | name: github-pages 101 | url: ${{ steps.publish.outputs.page_url }} 102 | 103 | steps: 104 | - name: Checkout 105 | uses: actions/checkout@v4 106 | - name: Download output 107 | uses: actions/download-artifact@v4 108 | with: 109 | path: /tmp/out 110 | - name: Isolate the frontend for GitHub pages 111 | run: | 112 | mkdir -p /tmp/github-pages 113 | tar -xzvf /tmp/out/pwa.bofied-github-pages/frontend-github-pages.tar.gz -C /tmp/github-pages 114 | touch /tmp/github-pages/.nojekyll 115 | - name: Extract branch name 116 | id: extract_branch 117 | run: echo "##[set-output name=branch;]$(echo ${GITHUB_REF#refs/heads/})" 118 | - name: Publish pre-release to GitHub releases 119 | if: ${{ github.ref == 'refs/heads/main' }} 120 | uses: softprops/action-gh-release@v2 121 | with: 122 | tag_name: release-${{ steps.extract_branch.outputs.branch }} 123 | prerelease: true 124 | files: | 125 | /tmp/out/*/* 126 | - name: Publish release to GitHub releases 127 | if: startsWith(github.ref, 'refs/tags/v') 128 | uses: softprops/action-gh-release@v2 129 | with: 130 | prerelease: false 131 | files: | 132 | /tmp/out/*/* 133 | - name: Setup GitHub Pages 134 | uses: actions/configure-pages@v5 135 | - name: Upload GitHub Pages artifact 136 | uses: actions/upload-pages-artifact@v3 137 | with: 138 | path: /tmp/github-pages/ 139 | - name: Publish to GitHub pages 140 | # if: startsWith(github.ref, 'refs/tags/v') 141 | if: ${{ github.ref == 'refs/heads/main' }} 142 | id: publish 143 | uses: actions/deploy-pages@v4 144 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | web/*.wasm 2 | out 3 | -------------------------------------------------------------------------------- /CODE_OF_CONDUCT.md: -------------------------------------------------------------------------------- 1 | # Contributor Covenant Code of Conduct 2 | 3 | ## Our Pledge 4 | 5 | We as members, contributors, and leaders pledge to make participation in our 6 | community a harassment-free experience for everyone, regardless of age, body 7 | size, visible or invisible disability, ethnicity, sex characteristics, gender 8 | identity and expression, level of experience, education, socio-economic status, 9 | nationality, personal appearance, race, religion, or sexual identity 10 | and orientation. 11 | 12 | We pledge to act and interact in ways that contribute to an open, welcoming, 13 | diverse, inclusive, and healthy community. 14 | 15 | ## Our Standards 16 | 17 | Examples of behavior that contributes to a positive environment for our 18 | community include: 19 | 20 | * Demonstrating empathy and kindness toward other people 21 | * Being respectful of differing opinions, viewpoints, and experiences 22 | * Giving and gracefully accepting constructive feedback 23 | * Accepting responsibility and apologizing to those affected by our mistakes, 24 | and learning from the experience 25 | * Focusing on what is best not just for us as individuals, but for the 26 | overall community 27 | 28 | Examples of unacceptable behavior include: 29 | 30 | * The use of sexualized language or imagery, and sexual attention or 31 | advances of any kind 32 | * Trolling, insulting or derogatory comments, and personal or political attacks 33 | * Public or private harassment 34 | * Publishing others' private information, such as a physical or email 35 | address, without their explicit permission 36 | * Other conduct which could reasonably be considered inappropriate in a 37 | professional setting 38 | 39 | ## Enforcement Responsibilities 40 | 41 | Community leaders are responsible for clarifying and enforcing our standards of 42 | acceptable behavior and will take appropriate and fair corrective action in 43 | response to any behavior that they deem inappropriate, threatening, offensive, 44 | or harmful. 45 | 46 | Community leaders have the right and responsibility to remove, edit, or reject 47 | comments, commits, code, wiki edits, issues, and other contributions that are 48 | not aligned to this Code of Conduct, and will communicate reasons for moderation 49 | decisions when appropriate. 50 | 51 | ## Scope 52 | 53 | This Code of Conduct applies within all community spaces, and also applies when 54 | an individual is officially representing the community in public spaces. 55 | Examples of representing our community include using an official e-mail address, 56 | posting via an official social media account, or acting as an appointed 57 | representative at an online or offline event. 58 | 59 | ## Enforcement 60 | 61 | Instances of abusive, harassing, or otherwise unacceptable behavior may be 62 | reported to the community leaders responsible for enforcement at 63 | felicitas@pojtinger.com. 64 | All complaints will be reviewed and investigated promptly and fairly. 65 | 66 | All community leaders are obligated to respect the privacy and security of the 67 | reporter of any incident. 68 | 69 | ## Enforcement Guidelines 70 | 71 | Community leaders will follow these Community Impact Guidelines in determining 72 | the consequences for any action they deem in violation of this Code of Conduct: 73 | 74 | ### 1. Correction 75 | 76 | **Community Impact**: Use of inappropriate language or other behavior deemed 77 | unprofessional or unwelcome in the community. 78 | 79 | **Consequence**: A private, written warning from community leaders, providing 80 | clarity around the nature of the violation and an explanation of why the 81 | behavior was inappropriate. A public apology may be requested. 82 | 83 | ### 2. Warning 84 | 85 | **Community Impact**: A violation through a single incident or series 86 | of actions. 87 | 88 | **Consequence**: A warning with consequences for continued behavior. No 89 | interaction with the people involved, including unsolicited interaction with 90 | those enforcing the Code of Conduct, for a specified period of time. This 91 | includes avoiding interactions in community spaces as well as external channels 92 | like social media. Violating these terms may lead to a temporary or 93 | permanent ban. 94 | 95 | ### 3. Temporary Ban 96 | 97 | **Community Impact**: A serious violation of community standards, including 98 | sustained inappropriate behavior. 99 | 100 | **Consequence**: A temporary ban from any sort of interaction or public 101 | communication with the community for a specified period of time. No public or 102 | private interaction with the people involved, including unsolicited interaction 103 | with those enforcing the Code of Conduct, is allowed during this period. 104 | Violating these terms may lead to a permanent ban. 105 | 106 | ### 4. Permanent Ban 107 | 108 | **Community Impact**: Demonstrating a pattern of violation of community 109 | standards, including sustained inappropriate behavior, harassment of an 110 | individual, or aggression toward or disparagement of classes of individuals. 111 | 112 | **Consequence**: A permanent ban from any sort of public interaction within 113 | the community. 114 | 115 | ## Attribution 116 | 117 | This Code of Conduct is adapted from the [Contributor Covenant][homepage], 118 | version 2.0, available at 119 | https://www.contributor-covenant.org/version/2/0/code_of_conduct.html. 120 | 121 | Community Impact Guidelines were inspired by [Mozilla's code of conduct 122 | enforcement ladder](https://github.com/mozilla/diversity). 123 | 124 | [homepage]: https://www.contributor-covenant.org 125 | 126 | For answers to common questions about this code of conduct, see the FAQ at 127 | https://www.contributor-covenant.org/faq. Translations are available at 128 | https://www.contributor-covenant.org/translations. 129 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | # Build container 2 | FROM golang:bookworm AS build 3 | 4 | # Setup environment 5 | RUN mkdir -p /data 6 | WORKDIR /data 7 | 8 | # Install native dependencies 9 | RUN apt update 10 | RUN apt install -y protobuf-compiler 11 | 12 | # Build the release 13 | COPY . . 14 | RUN make depend 15 | RUN make build/cli 16 | 17 | # Extract the release 18 | RUN mkdir -p /out 19 | RUN cp out/bofied-backend /out/bofied-backend 20 | 21 | # Release container 22 | FROM debian:bookworm 23 | 24 | # Add certificates 25 | RUN apt update 26 | RUN apt install -y ca-certificates 27 | 28 | # Add the release 29 | COPY --from=build /out/bofied-backend /usr/local/bin/bofied-backend 30 | 31 | CMD /usr/local/bin/bofied-backend 32 | -------------------------------------------------------------------------------- /Hydrunfile: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | set -e 4 | 5 | # Test CLI 6 | if [ "$1" = "test/cli" ]; then 7 | # Install native dependencies 8 | apt update 9 | apt install -y make git protobuf-compiler 10 | 11 | # Configure Git 12 | git config --global --add safe.directory '*' 13 | 14 | # Generate dependencies 15 | make depend/cli 16 | 17 | # Run tests 18 | make test/cli 19 | 20 | exit 0 21 | fi 22 | 23 | # Test PWA 24 | if [ "$1" = "test/pwa" ]; then 25 | # Install native dependencies 26 | apt update 27 | apt install -y make git protobuf-compiler 28 | 29 | # Configure Git 30 | git config --global --add safe.directory '*' 31 | 32 | # Generate dependencies 33 | make depend/pwa 34 | 35 | # Run tests 36 | make test/pwa 37 | 38 | exit 0 39 | fi 40 | 41 | # Build CLI 42 | if [ "$1" = "build/cli" ]; then 43 | # Install native dependencies 44 | apt update 45 | apt install -y curl make git protobuf-compiler 46 | 47 | # Install bagop 48 | curl -L -o /tmp/bagop "https://github.com/pojntfx/bagop/releases/latest/download/bagop.linux-$(uname -m)" 49 | install /tmp/bagop /usr/local/bin 50 | 51 | # Configure Git 52 | git config --global --add safe.directory '*' 53 | 54 | # Generate dependencies 55 | make depend/cli 56 | 57 | # Build 58 | CGO_ENABLED=0 bagop -j "$(nproc)" -b "$2" -x '(android/*|ios/*|plan9/*|aix/*|linux/loong64|freebsd/riscv64|wasip1/wasm)' -p "make build/cli/$2 DST=\$DST" -d out 59 | 60 | exit 0 61 | fi 62 | 63 | # Build PWA 64 | if [ "$1" = "build/pwa" ]; then 65 | # Install native dependencies 66 | apt update 67 | apt install -y make git protobuf-compiler 68 | 69 | # Configure Git 70 | git config --global --add safe.directory '*' 71 | 72 | # Generate dependencies 73 | make depend/pwa 74 | 75 | # Build 76 | make build/pwa 77 | 78 | exit 0 79 | fi 80 | 81 | # Build PWA (GitHub pages) 82 | if [ "$1" = "build/pwa-github-pages" ]; then 83 | # Install native dependencies 84 | apt update 85 | apt install -y make git protobuf-compiler 86 | 87 | # Configure Git 88 | git config --global --add safe.directory '*' 89 | 90 | # Generate dependencies 91 | make depend/pwa 92 | 93 | # Build 94 | make build/pwa-github-pages 95 | 96 | exit 0 97 | fi 98 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | # Public variables 2 | DESTDIR ?= 3 | PREFIX ?= /usr/local 4 | OUTPUT_DIR ?= out 5 | DST ?= 6 | 7 | WWWROOT ?= /var/www/html 8 | WWWPREFIX ?= /bofied 9 | 10 | # Private variables 11 | clis = bofied-backend 12 | pwas = frontend 13 | all: build 14 | 15 | # Build 16 | build: build/cli build/pwa 17 | 18 | build/cli: $(addprefix build/cli/,$(clis)) 19 | $(addprefix build/cli/,$(clis)): 20 | ifdef DST 21 | go build -o $(DST) ./cmd/$(subst build/cli/,,$@) 22 | else 23 | go build -o $(OUTPUT_DIR)/$(subst build/cli/,,$@) ./cmd/$(subst build/cli/,,$@) 24 | endif 25 | 26 | build/pwa: 27 | GOOS=js GOARCH=wasm go build -o web/app.wasm cmd/bofied-frontend/main.go 28 | rm -rf $(OUTPUT_DIR)/bofied-frontend/ 29 | mkdir -p $(OUTPUT_DIR)/bofied-frontend/web 30 | go run ./cmd/bofied-frontend/main.go --build 31 | cp -r web/* $(OUTPUT_DIR)/bofied-frontend/web 32 | tar -cvzf $(OUTPUT_DIR)/frontend.tar.gz -C $(OUTPUT_DIR)/bofied-frontend . 33 | 34 | # Special target for GitHub pages builds 35 | build/pwa-github-pages: 36 | GOOS=js GOARCH=wasm go build -o web/app.wasm cmd/bofied-frontend/main.go 37 | rm -rf $(OUTPUT_DIR)/bofied-frontend/ 38 | mkdir -p $(OUTPUT_DIR)/bofied-frontend/web 39 | go run ./cmd/bofied-frontend/main.go --build --path bofied 40 | cp -r web/* $(OUTPUT_DIR)/bofied-frontend/web 41 | tar -cvzf $(OUTPUT_DIR)/frontend.tar.gz -C $(OUTPUT_DIR)/bofied-frontend . 42 | 43 | # Install 44 | install: install/cli install/pwa 45 | 46 | install/cli: $(addprefix install/cli/,$(clis)) 47 | $(addprefix install/cli/,$(clis)): 48 | install -D -m 0755 $(OUTPUT_DIR)/$(subst install/cli/,,$@) $(DESTDIR)$(PREFIX)/bin/$(subst install/cli/,,$@) 49 | 50 | install/pwa: 51 | mkdir -p $(DESTDIR)$(WWWROOT)$(WWWPREFIX) 52 | tar -xvf out/frontend.tar.gz -C $(DESTDIR)$(WWWROOT)$(WWWPREFIX) 53 | 54 | # Uninstall 55 | uninstall: uninstall/cli uninstall/pwa 56 | 57 | uninstall/cli: $(addprefix uninstall/cli/,$(clis)) 58 | $(addprefix uninstall/cli/,$(clis)): 59 | rm $(DESTDIR)$(PREFIX)/bin/$(subst uninstall/cli/,,$@) 60 | 61 | uninstall/pwa: 62 | rm -rf $(DESTDIR)$(WWWROOT)$(WWWPREFIX) 63 | 64 | # Run 65 | run: run/cli run/pwa 66 | 67 | run/cli: $(addprefix run/cli/,$(clis)) 68 | $(addprefix run/cli/,$(clis)): 69 | $(subst run/cli/,,$@) $(ARGS) 70 | 71 | run/pwa: 72 | go run ./cmd/bofied-frontend/ --serve 73 | 74 | # Dev 75 | dev: dev/cli dev/pwa 76 | 77 | dev/cli: $(addprefix dev/cli/,$(clis)) 78 | $(addprefix dev/cli/,$(clis)): $(addprefix build/cli/,$(clis)) 79 | sudo setcap cap_net_bind_service+ep $(OUTPUT_DIR)/$(subst dev/cli/,,$@) 80 | $(OUTPUT_DIR)/$(subst dev/cli/,,$@) $(ARGS) 81 | 82 | dev/pwa: build/pwa 83 | go run ./cmd/bofied-frontend/ --serve 84 | 85 | # Test 86 | test: test/cli test/pwa 87 | 88 | test/cli: 89 | go test -timeout 3600s -parallel $(shell nproc) ./... 90 | 91 | test/pwa: 92 | true 93 | 94 | # Benchmark 95 | benchmark: benchmark/cli benchmark/pwa 96 | 97 | benchmark/cli: 98 | go test -timeout 3600s -bench=./... ./... 99 | 100 | benchmark/pwa: 101 | true 102 | 103 | # Clean 104 | clean: clean/cli clean/pwa 105 | 106 | clean/cli: 107 | rm -rf out pkg/models pkg/api/proto/v1 rm -rf ~/.local/share/bofied 108 | 109 | clean/pwa: 110 | rm -rf out web/app.wasm 111 | 112 | # Dependencies 113 | depend: depend/cli depend/pwa 114 | 115 | depend/cli: 116 | GO111MODULE=on go install google.golang.org/protobuf/cmd/protoc-gen-go@latest 117 | GO111MODULE=on go install google.golang.org/grpc/cmd/protoc-gen-go-grpc@latest 118 | GO111MODULE=on go install github.com/fullstorydev/grpcurl/cmd/grpcurl@latest 119 | 120 | go generate ./... 121 | 122 | depend/pwa: 123 | true 124 | -------------------------------------------------------------------------------- /api/proto/v1/events.proto: -------------------------------------------------------------------------------- 1 | syntax = "proto3"; 2 | 3 | package com.pojtinger.felicitas.bofied; 4 | 5 | option go_package = "github.com/pojntfx/bofied/pkg/api/proto/v1"; 6 | 7 | import "metadata.proto"; 8 | 9 | service EventsService { 10 | rpc SubscribeToEvents(Empty) returns (stream EventMessage); 11 | } 12 | 13 | message EventMessage { 14 | string CreatedAt = 1; 15 | string Message = 2; 16 | } 17 | -------------------------------------------------------------------------------- /api/proto/v1/metadata.proto: -------------------------------------------------------------------------------- 1 | syntax = "proto3"; 2 | 3 | package com.pojtinger.felicitas.bofied; 4 | 5 | option go_package = "github.com/pojntfx/bofied/pkg/api/proto/v1"; 6 | 7 | message Empty {} 8 | 9 | service MetadataService { rpc GetMetadata(Empty) returns (MetadataMessage); } 10 | 11 | message MetadataMessage { 12 | string AdvertisedIP = 1; 13 | int32 TFTPPort = 2; 14 | int32 HTTPPort = 3; 15 | } 16 | -------------------------------------------------------------------------------- /cmd/bofied-backend/main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "log" 5 | "net" 6 | "os" 7 | "path/filepath" 8 | "strconv" 9 | 10 | "github.com/pojntfx/bofied/pkg/config" 11 | "github.com/pojntfx/bofied/pkg/constants" 12 | "github.com/pojntfx/bofied/pkg/eventing" 13 | "github.com/pojntfx/bofied/pkg/servers" 14 | "github.com/pojntfx/bofied/pkg/services" 15 | "github.com/pojntfx/bofied/pkg/validators" 16 | "github.com/spf13/cobra" 17 | "github.com/spf13/viper" 18 | ) 19 | 20 | const ( 21 | configFileKey = "configFile" 22 | workingDirKey = "workingDir" 23 | advertisedIPKey = "advertisedIP" 24 | dhcpListenAddressKey = "dhcpListenAddress" 25 | proxyDHCPListenAddressKey = "proxyDHCPListenAddress" 26 | tftpListenAddressKey = "tftpListenAddress" 27 | webDAVAndHTTPListenAddressKey = "extendedHTTPListenAddress" 28 | oidcIssuerKey = "oidcIssuer" 29 | oidcClientIDKey = "oidcClientID" 30 | grpcListenAddressKey = "grpcListenAddress" 31 | pureConfigKey = "pureConfig" 32 | starterURLKey = "starterURL" 33 | skipStarterDownloadKey = "skipStarterDownload" 34 | ) 35 | 36 | func main() { 37 | // Create command 38 | cmd := &cobra.Command{ 39 | Use: "bofied-backend", 40 | Short: "Modern network boot server.", 41 | Long: `bofied is a network boot server. It provides everything you need to PXE boot a node, from a (proxy)DHCP server for PXE service to a TFTP and HTTP server to serve boot files. 42 | 43 | For more information, please visit https://github.com/pojntfx/bofied.`, 44 | RunE: func(cmd *cobra.Command, args []string) error { 45 | // Bind config file 46 | if !(viper.GetString(configFileKey) == "") { 47 | viper.SetConfigFile(viper.GetString(configFileKey)) 48 | 49 | if err := viper.ReadInConfig(); err != nil { 50 | return err 51 | } 52 | } 53 | 54 | // Initialize the working directory 55 | configFilePath := filepath.Join(viper.GetString(workingDirKey), constants.BootConfigFileName) 56 | if viper.GetBool(skipStarterDownloadKey) { 57 | // Initialize with just a config file 58 | if err := config.CreateConfigIfNotExists(configFilePath); err != nil { 59 | log.Fatal(err) 60 | } 61 | } else { 62 | // Initialize with a starter 63 | if err := config.GetStarterIfNotExists(configFilePath, viper.GetString(starterURLKey), viper.GetString(workingDirKey)); err != nil { 64 | log.Fatal(err) 65 | } 66 | } 67 | 68 | // Parse flags 69 | _, tftpPortRaw, err := net.SplitHostPort(viper.GetString(tftpListenAddressKey)) 70 | if err != nil { 71 | return err 72 | } 73 | 74 | tftpPort, err := strconv.Atoi(tftpPortRaw) 75 | if err != nil { 76 | return err 77 | } 78 | 79 | _, httpPortRaw, err := net.SplitHostPort(viper.GetString(webDAVAndHTTPListenAddressKey)) 80 | if err != nil { 81 | return err 82 | } 83 | 84 | httpPort, err := strconv.Atoi(httpPortRaw) 85 | if err != nil { 86 | return err 87 | } 88 | 89 | // Create eventing utilities 90 | eventsHandler := eventing.NewEventHandler() 91 | 92 | // Create auth utilities 93 | oidcValidator := validators.NewOIDCValidator(viper.GetString(oidcIssuerKey), viper.GetString(oidcClientIDKey)) 94 | if err := oidcValidator.Open(); err != nil { 95 | log.Fatal(err) 96 | } 97 | contextValidator := validators.NewContextValidator(services.AuthorizationMetadataKey, oidcValidator) 98 | 99 | // Create services 100 | eventsService := services.NewEventsService(eventsHandler, contextValidator) 101 | metadataService := services.NewMetadataService( 102 | viper.GetString(advertisedIPKey), 103 | int32(tftpPort), 104 | int32(httpPort), 105 | contextValidator, 106 | ) 107 | 108 | // Create servers 109 | dhcpServer := servers.NewDHCPServer( 110 | viper.GetString(dhcpListenAddressKey), 111 | viper.GetString(advertisedIPKey), 112 | eventsHandler, 113 | ) 114 | proxyDHCPServer := servers.NewProxyDHCPServer( 115 | viper.GetString(proxyDHCPListenAddressKey), 116 | viper.GetString(advertisedIPKey), 117 | filepath.Join(viper.GetString(workingDirKey), constants.BootConfigFileName), 118 | eventsHandler, 119 | viper.GetBool(pureConfigKey), 120 | ) 121 | tftpServer := servers.NewTFTPServer( 122 | viper.GetString(workingDirKey), 123 | viper.GetString(tftpListenAddressKey), 124 | eventsHandler, 125 | ) 126 | grpcServer, grpcServerHandler := servers.NewGRPCServer(viper.GetString(grpcListenAddressKey), eventsService, metadataService) 127 | extendedHTTPServer := servers.NewExtendedHTTPServer(viper.GetString(workingDirKey), viper.GetString(webDAVAndHTTPListenAddressKey), oidcValidator, grpcServerHandler, eventsHandler) 128 | 129 | // Start servers 130 | log.Printf( 131 | "bofied backend listening on %v (DHCP), %v (proxyDHCP), %v (TFTP), %v (WebDAV on %v, HTTP on %v and gRPC-Web on %v) and %v (gRPC), advertising IP %v to DHCP clients\n", 132 | viper.GetString(dhcpListenAddressKey), 133 | viper.GetString(proxyDHCPListenAddressKey), 134 | viper.GetString(tftpListenAddressKey), 135 | viper.GetString(webDAVAndHTTPListenAddressKey), 136 | servers.WebDAVPrefix, 137 | servers.HTTPPrefix, 138 | servers.GRPCPrefix, 139 | viper.GetString(grpcListenAddressKey), 140 | viper.GetString(advertisedIPKey), 141 | ) 142 | 143 | go func() { 144 | log.Fatal(dhcpServer.ListenAndServe()) 145 | }() 146 | 147 | go func() { 148 | log.Fatal(proxyDHCPServer.ListenAndServe()) 149 | }() 150 | 151 | go func() { 152 | log.Fatal(tftpServer.ListenAndServe()) 153 | }() 154 | 155 | go func() { 156 | log.Fatal(grpcServer.ListenAndServe()) 157 | }() 158 | 159 | return extendedHTTPServer.ListenAndServe() 160 | }, 161 | } 162 | 163 | // Get default working dir 164 | home, err := os.UserHomeDir() 165 | if err != nil { 166 | log.Fatal("could not get home directory", err) 167 | } 168 | workingDirDefault := filepath.Join(home, ".local", "share", "bofied", "var", "lib", "bofied") 169 | 170 | // Bind flags 171 | cmd.PersistentFlags().StringP(configFileKey, "c", "", "Config file to use") 172 | cmd.PersistentFlags().StringP(workingDirKey, "d", workingDirDefault, "Working directory") 173 | cmd.PersistentFlags().String(advertisedIPKey, "100.64.154.246", "IP to advertise for DHCP clients") 174 | 175 | cmd.PersistentFlags().String(dhcpListenAddressKey, ":67", "Listen address for DHCP server") 176 | cmd.PersistentFlags().String(proxyDHCPListenAddressKey, ":4011", "Listen address for proxyDHCP server") 177 | cmd.PersistentFlags().String(tftpListenAddressKey, ":69", "Listen address for TFTP server") 178 | cmd.PersistentFlags().String(webDAVAndHTTPListenAddressKey, ":15256", "Listen address for WebDAV, HTTP and gRPC-Web server") 179 | cmd.PersistentFlags().String(grpcListenAddressKey, ":15257", "Listen address for gRPC server") 180 | 181 | cmd.PersistentFlags().StringP(oidcIssuerKey, "i", "https://pojntfx.eu.auth0.com/", "OIDC issuer") 182 | cmd.PersistentFlags().StringP(oidcClientIDKey, "t", "myoidcclientid", "OIDC client ID") 183 | 184 | cmd.PersistentFlags().BoolP(pureConfigKey, "p", false, "Prevent usage of stdlib in configuration file, even if enabled in `Configuration` function") 185 | 186 | cmd.PersistentFlags().String(starterURLKey, "https://github.com/pojntfx/ipxe-binaries/releases/latest/download/ipxe.tar.gz", "Download URL to a starter .tar.gz archive; the default chainloads https://netboot.xyz/") 187 | cmd.PersistentFlags().BoolP(skipStarterDownloadKey, "s", false, "Don't initialize by downloading the starter on the first run") 188 | 189 | // Bind env variables 190 | if err := viper.BindPFlags(cmd.PersistentFlags()); err != nil { 191 | log.Fatal(err) 192 | } 193 | viper.SetEnvPrefix("bofied_backend") 194 | viper.AutomaticEnv() 195 | 196 | // Run command 197 | if err := cmd.Execute(); err != nil { 198 | log.Fatal(err) 199 | } 200 | } 201 | -------------------------------------------------------------------------------- /cmd/bofied-frontend/main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "flag" 5 | "log" 6 | "net/http" 7 | 8 | "github.com/kataras/compress" 9 | "github.com/maxence-charriere/go-app/v10/pkg/app" 10 | "github.com/pojntfx/bofied/pkg/components" 11 | ) 12 | 13 | func main() { 14 | // Client-side code 15 | { 16 | // Define the routes 17 | app.Route("/", func() app.Composer { 18 | return &components.Home{} 19 | }) 20 | 21 | // Start the app 22 | app.RunWhenOnBrowser() 23 | } 24 | 25 | // Server-/build-side code 26 | { 27 | // Parse the flags 28 | build := flag.Bool("build", false, "Create static build") 29 | out := flag.String("out", "out/bofied-frontend", "Out directory for static build") 30 | path := flag.String("path", "", "Base path for static build") 31 | serve := flag.Bool("serve", false, "Build and serve the frontend") 32 | laddr := flag.String("laddr", "localhost:15255", "Address to serve the frontend on") 33 | 34 | flag.Parse() 35 | 36 | // Define the handler 37 | h := &app.Handler{ 38 | Author: "Felicitas Pojtinger", 39 | BackgroundColor: "#151515", 40 | Description: "Modern network boot server.", 41 | Icon: app.Icon{ 42 | Default: "/web/icon.png", 43 | }, 44 | Keywords: []string{ 45 | "pxe-boot", 46 | "ipxe", 47 | "netboot", 48 | "network-boot", 49 | "http-server", 50 | "dhcp-server", 51 | "pxe", 52 | "webdav-server", 53 | "tftp-server", 54 | "proxy-dhcp", 55 | }, 56 | LoadingLabel: "Modern network boot server.", 57 | Name: "bofied", 58 | RawHeaders: []string{ 59 | ``, 60 | ``, 61 | ``, 62 | ``, 63 | }, 64 | Styles: []string{ 65 | `https://unpkg.com/@patternfly/patternfly@6.0.0/patternfly.css`, 66 | `https://unpkg.com/@patternfly/patternfly@6.0.0/patternfly-addons.css`, 67 | `/web/index.css`, 68 | }, 69 | ThemeColor: "#151515", 70 | Title: "bofied", 71 | } 72 | 73 | // Create static build if specified 74 | if *build { 75 | // Deploy under a path 76 | if *path != "" { 77 | h.Resources = app.GitHubPages(*path) 78 | } 79 | 80 | if err := app.GenerateStaticWebsite(*out, h); err != nil { 81 | log.Fatalf("could not build: %v\n", err) 82 | } 83 | } 84 | 85 | // Serve if specified 86 | if *serve { 87 | log.Printf("bofied frontend listening on %v\n", *laddr) 88 | 89 | if err := http.ListenAndServe(*laddr, compress.Handler(h)); err != nil { 90 | log.Fatalf("could not open bofied frontend: %v\n", err) 91 | } 92 | } 93 | } 94 | } 95 | -------------------------------------------------------------------------------- /docs/demo.mp4: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/pojntfx/bofied/c07c42f01e293bb8dbc2db0b01b6dd9cdd3ed1ab/docs/demo.mp4 -------------------------------------------------------------------------------- /docs/screenshot-about-modal.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/pojntfx/bofied/c07c42f01e293bb8dbc2db0b01b6dd9cdd3ed1ab/docs/screenshot-about-modal.png -------------------------------------------------------------------------------- /docs/screenshot-file-operations-2.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/pojntfx/bofied/c07c42f01e293bb8dbc2db0b01b6dd9cdd3ed1ab/docs/screenshot-file-operations-2.png -------------------------------------------------------------------------------- /docs/screenshot-file-operations-3.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/pojntfx/bofied/c07c42f01e293bb8dbc2db0b01b6dd9cdd3ed1ab/docs/screenshot-file-operations-3.png -------------------------------------------------------------------------------- /docs/screenshot-gnome-files-webdav-listing.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/pojntfx/bofied/c07c42f01e293bb8dbc2db0b01b6dd9cdd3ed1ab/docs/screenshot-gnome-files-webdav-listing.png -------------------------------------------------------------------------------- /docs/screenshot-gnome-files-webdav-mounting.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/pojntfx/bofied/c07c42f01e293bb8dbc2db0b01b6dd9cdd3ed1ab/docs/screenshot-gnome-files-webdav-mounting.png -------------------------------------------------------------------------------- /docs/screenshot-initial.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/pojntfx/bofied/c07c42f01e293bb8dbc2db0b01b6dd9cdd3ed1ab/docs/screenshot-initial.png -------------------------------------------------------------------------------- /docs/screenshot-monitoring.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/pojntfx/bofied/c07c42f01e293bb8dbc2db0b01b6dd9cdd3ed1ab/docs/screenshot-monitoring.png -------------------------------------------------------------------------------- /docs/screenshot-mount-directory.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/pojntfx/bofied/c07c42f01e293bb8dbc2db0b01b6dd9cdd3ed1ab/docs/screenshot-mount-directory.png -------------------------------------------------------------------------------- /docs/screenshot-setup.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/pojntfx/bofied/c07c42f01e293bb8dbc2db0b01b6dd9cdd3ed1ab/docs/screenshot-setup.png -------------------------------------------------------------------------------- /docs/screenshot-sharing.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/pojntfx/bofied/c07c42f01e293bb8dbc2db0b01b6dd9cdd3ed1ab/docs/screenshot-sharing.png -------------------------------------------------------------------------------- /docs/screenshot-syntax-validation.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/pojntfx/bofied/c07c42f01e293bb8dbc2db0b01b6dd9cdd3ed1ab/docs/screenshot-syntax-validation.png -------------------------------------------------------------------------------- /docs/screenshot-text-editor.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/pojntfx/bofied/c07c42f01e293bb8dbc2db0b01b6dd9cdd3ed1ab/docs/screenshot-text-editor.png -------------------------------------------------------------------------------- /examples/bofied-backend-config.yaml: -------------------------------------------------------------------------------- 1 | advertisedIP: "100.64.154.246" 2 | dhcpListenAddress: ":67" 3 | extendedHTTPListenAddress: ":15256" 4 | grpcListenAddress: ":15257" 5 | oidcClientID: myoidcclientid 6 | oidcIssuer: https://pojntfx.eu.auth0.com/ 7 | proxyDHCPListenAddress: ":4011" 8 | pureConfig: false 9 | tftpListenAddress: ":69" 10 | workingDir: /home/pojntfx/.local/share/bofied/var/lib/bofied 11 | starterURL: https://github.com/pojntfx/ipxe-binaries/releases/download/latest/ipxe.tar.gz 12 | skipStarterDownload: false 13 | -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module github.com/pojntfx/bofied 2 | 3 | go 1.22.7 4 | 5 | require ( 6 | github.com/codeclysm/extract/v3 v3.1.1 7 | github.com/coreos/go-oidc/v3 v3.11.0 8 | github.com/google/gopacket v1.1.19 9 | github.com/kataras/compress v0.0.6 10 | github.com/maxence-charriere/go-app/v10 v10.0.8 11 | github.com/pin/tftp/v3 v3.1.0 12 | github.com/rs/cors v1.11.1 13 | github.com/spf13/cobra v1.8.1 14 | github.com/spf13/viper v1.19.0 15 | github.com/studio-b12/gowebdav v0.9.0 16 | github.com/traefik/yaegi v0.16.1 17 | github.com/ugjka/messenger v1.1.3 18 | golang.org/x/net v0.30.0 19 | golang.org/x/oauth2 v0.23.0 20 | google.golang.org/grpc v1.67.1 21 | google.golang.org/protobuf v1.35.1 22 | nhooyr.io/websocket v1.8.17 23 | ) 24 | 25 | require ( 26 | github.com/andybalholm/brotli v1.1.1 // indirect 27 | github.com/fsnotify/fsnotify v1.7.0 // indirect 28 | github.com/go-jose/go-jose/v4 v4.0.4 // indirect 29 | github.com/google/uuid v1.6.0 // indirect 30 | github.com/h2non/filetype v1.1.3 // indirect 31 | github.com/hashicorp/hcl v1.0.0 // indirect 32 | github.com/inconshreveable/mousetrap v1.1.0 // indirect 33 | github.com/juju/errors v1.0.0 // indirect 34 | github.com/klauspost/compress v1.17.11 // indirect 35 | github.com/magiconair/properties v1.8.7 // indirect 36 | github.com/mitchellh/mapstructure v1.5.0 // indirect 37 | github.com/pelletier/go-toml/v2 v2.2.3 // indirect 38 | github.com/sagikazarmark/locafero v0.6.0 // indirect 39 | github.com/sagikazarmark/slog-shim v0.1.0 // indirect 40 | github.com/sourcegraph/conc v0.3.0 // indirect 41 | github.com/spf13/afero v1.11.0 // indirect 42 | github.com/spf13/cast v1.7.0 // indirect 43 | github.com/spf13/pflag v1.0.5 // indirect 44 | github.com/subosito/gotenv v1.6.0 // indirect 45 | github.com/ulikunitz/xz v0.5.12 // indirect 46 | go.uber.org/multierr v1.11.0 // indirect 47 | golang.org/x/crypto v0.28.0 // indirect 48 | golang.org/x/exp v0.0.0-20241009180824-f66d83c29e7c // indirect 49 | golang.org/x/sys v0.26.0 // indirect 50 | golang.org/x/text v0.19.0 // indirect 51 | google.golang.org/genproto/googleapis/rpc v0.0.0-20241021214115-324edc3d5d38 // indirect 52 | gopkg.in/ini.v1 v1.67.0 // indirect 53 | gopkg.in/yaml.v3 v3.0.1 // indirect 54 | ) 55 | -------------------------------------------------------------------------------- /go.sum: -------------------------------------------------------------------------------- 1 | github.com/andybalholm/brotli v1.0.0/go.mod h1:loMXtMfwqflxFJPmdbJO0a3KNoPuLBgiu3qAvBg8x/Y= 2 | github.com/andybalholm/brotli v1.1.1 h1:PR2pgnyFznKEugtsUo0xLdDop5SKXd5Qf5ysW+7XdTA= 3 | github.com/andybalholm/brotli v1.1.1/go.mod h1:05ib4cKhjx3OQYUY22hTVd34Bc8upXjOLL2rKwwZBoA= 4 | github.com/arduino/go-paths-helper v1.2.0 h1:qDW93PR5IZUN/jzO4rCtexiwF8P4OIcOmcSgAYLZfY4= 5 | github.com/arduino/go-paths-helper v1.2.0/go.mod h1:HpxtKph+g238EJHq4geEPv9p+gl3v5YYu35Yb+w31Ck= 6 | github.com/codeclysm/extract/v3 v3.1.1 h1:iHZtdEAwSTqPrd+1n4jfhr1qBhUWtHlMTjT90+fJVXg= 7 | github.com/codeclysm/extract/v3 v3.1.1/go.mod h1:ZJi80UG2JtfHqJI+lgJSCACttZi++dHxfWuPaMhlOfQ= 8 | github.com/coreos/go-oidc/v3 v3.11.0 h1:Ia3MxdwpSw702YW0xgfmP1GVCMA9aEFWu12XUZ3/OtI= 9 | github.com/coreos/go-oidc/v3 v3.11.0/go.mod h1:gE3LgjOgFoHi9a4ce4/tJczr0Ai2/BoDhf0r5lltWI0= 10 | github.com/cpuguy83/go-md2man/v2 v2.0.4/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o= 11 | github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 12 | github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM= 13 | github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 14 | github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8= 15 | github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0= 16 | github.com/fsnotify/fsnotify v1.7.0 h1:8JEhPFa5W2WU7YfeZzPNqzMP6Lwt7L2715Ggo0nosvA= 17 | github.com/fsnotify/fsnotify v1.7.0/go.mod h1:40Bi/Hjc2AVfZrqy+aj+yEI+/bRxZnMJyTJwOpGvigM= 18 | github.com/go-jose/go-jose/v4 v4.0.4 h1:VsjPI33J0SB9vQM6PLmNjoHqMQNGPiZ0rHL7Ni7Q6/E= 19 | github.com/go-jose/go-jose/v4 v4.0.4/go.mod h1:NKb5HO1EZccyMpiZNbdUw/14tiXNyUJh188dfnMCAfc= 20 | github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI= 21 | github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= 22 | github.com/google/gopacket v1.1.19 h1:ves8RnFZPGiFnTS0uPQStjwru6uO6h+nlr9j6fL7kF8= 23 | github.com/google/gopacket v1.1.19/go.mod h1:iJ8V8n6KS+z2U1A8pUwu8bW5SyEMkXJB8Yo/Vo+TKTo= 24 | github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= 25 | github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= 26 | github.com/h2non/filetype v1.1.3 h1:FKkx9QbD7HR/zjK1Ia5XiBsq9zdLi5Kf3zGyFTAFkGg= 27 | github.com/h2non/filetype v1.1.3/go.mod h1:319b3zT68BvV+WRj7cwy856M2ehB3HqNOt6sy1HndBY= 28 | github.com/hashicorp/hcl v1.0.0 h1:0Anlzjpi4vEasTeNFn2mLJgTSwt0+6sfsiTG8qcWGx4= 29 | github.com/hashicorp/hcl v1.0.0/go.mod h1:E5yfLk+7swimpb2L/Alb/PJmXilQ/rhwaUYs4T20WEQ= 30 | github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= 31 | github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= 32 | github.com/juju/errors v1.0.0 h1:yiq7kjCLll1BiaRuNY53MGI0+EQ3rF6GB+wvboZDefM= 33 | github.com/juju/errors v1.0.0/go.mod h1:B5x9thDqx0wIMH3+aLIMP9HjItInYWObRovoCFM5Qe8= 34 | github.com/kataras/compress v0.0.6 h1:EMFR0GuyrLaTp3BmqKciVcyyb6By+dVJgY4deP1sz+A= 35 | github.com/kataras/compress v0.0.6/go.mod h1:xru59oerl89gl/p3nzbmGR12C9+XMdlZ8jNF43XyEPA= 36 | github.com/klauspost/compress v1.10.10/go.mod h1:aoV0uJVorq1K+umq18yTdKaF57EivdYsUV+/s2qKfXs= 37 | github.com/klauspost/compress v1.17.11 h1:In6xLpyWOi1+C7tXUUWv2ot1QvBjxevKAaI6IXrJmUc= 38 | github.com/klauspost/compress v1.17.11/go.mod h1:pMDklpSncoRMuLFrf1W9Ss9KT+0rH90U12bZKk7uwG0= 39 | github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= 40 | github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= 41 | github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= 42 | github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= 43 | github.com/magiconair/properties v1.8.7 h1:IeQXZAiQcpL9mgcAe1Nu6cX9LLw6ExEHKjN0VQdvPDY= 44 | github.com/magiconair/properties v1.8.7/go.mod h1:Dhd985XPs7jluiymwWYZ0G4Z61jb3vdS329zhj2hYo0= 45 | github.com/maxence-charriere/go-app/v10 v10.0.8 h1:ZbHTaIN1nMTzMvWmx5/wAMioDxnftNmkYfcX3O4lleE= 46 | github.com/maxence-charriere/go-app/v10 v10.0.8/go.mod h1:VyjGLeTiK6hfAQ/Q5ZVcLbuJ9vOZhKcewSC/ZwI+6WM= 47 | github.com/mitchellh/mapstructure v1.5.0 h1:jeMsZIYE/09sWLaz43PL7Gy6RuMjD2eJVyuac5Z2hdY= 48 | github.com/mitchellh/mapstructure v1.5.0/go.mod h1:bFUtVrKA4DC2yAKiSyO/QUcy7e+RRV2QTWOzhPopBRo= 49 | github.com/pelletier/go-toml/v2 v2.2.3 h1:YmeHyLY8mFWbdkNWwpr+qIL2bEqT0o95WSdkNHvL12M= 50 | github.com/pelletier/go-toml/v2 v2.2.3/go.mod h1:MfCQTFTvCcUyyvvwm1+G6H/jORL20Xlb6rzQu9GuUkc= 51 | github.com/pin/tftp/v3 v3.1.0 h1:rQaxd4pGwcAJnpId8zC+O2NX3B2/NscjDZQaqEjuE7c= 52 | github.com/pin/tftp/v3 v3.1.0/go.mod h1:xwQaN4viYL019tM4i8iecm++5cGxSqen6AJEOEyEI0w= 53 | github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U= 54 | github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= 55 | github.com/rogpeppe/go-internal v1.9.0 h1:73kH8U+JUqXU8lRuOHeVHaa/SZPifC7BkcraZVejAe8= 56 | github.com/rogpeppe/go-internal v1.9.0/go.mod h1:WtVeX8xhTBvf0smdhujwtBcq4Qrzq/fJaraNFVN+nFs= 57 | github.com/rs/cors v1.11.1 h1:eU3gRzXLRK57F5rKMGMZURNdIG4EoAmX8k94r9wXWHA= 58 | github.com/rs/cors v1.11.1/go.mod h1:XyqrcTp5zjWr1wsJ8PIRZssZ8b/WMcMf71DJnit4EMU= 59 | github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= 60 | github.com/sagikazarmark/locafero v0.6.0 h1:ON7AQg37yzcRPU69mt7gwhFEBwxI6P9T4Qu3N51bwOk= 61 | github.com/sagikazarmark/locafero v0.6.0/go.mod h1:77OmuIc6VTraTXKXIs/uvUxKGUXjE1GbemJYHqdNjX0= 62 | github.com/sagikazarmark/slog-shim v0.1.0 h1:diDBnUNK9N/354PgrxMywXnAwEr1QZcOr6gto+ugjYE= 63 | github.com/sagikazarmark/slog-shim v0.1.0/go.mod h1:SrcSrq8aKtyuqEI1uvTDTK1arOWRIczQRv+GVI1AkeQ= 64 | github.com/sourcegraph/conc v0.3.0 h1:OQTbbt6P72L20UqAkXXuLOj79LfEanQ+YQFNpLA9ySo= 65 | github.com/sourcegraph/conc v0.3.0/go.mod h1:Sdozi7LEKbFPqYX2/J+iBAM6HpqSLTASQIKqDmF7Mt0= 66 | github.com/spf13/afero v1.11.0 h1:WJQKhtpdm3v2IzqG8VMqrr6Rf3UYpEF239Jy9wNepM8= 67 | github.com/spf13/afero v1.11.0/go.mod h1:GH9Y3pIexgf1MTIWtNGyogA5MwRIDXGUr+hbWNoBjkY= 68 | github.com/spf13/cast v1.7.0 h1:ntdiHjuueXFgm5nzDRdOS4yfT43P5Fnud6DH50rz/7w= 69 | github.com/spf13/cast v1.7.0/go.mod h1:ancEpBxwJDODSW/UG4rDrAqiKolqNNh2DX3mk86cAdo= 70 | github.com/spf13/cobra v1.8.1 h1:e5/vxKd/rZsfSJMUX1agtjeTDf+qv1/JdBF8gg5k9ZM= 71 | github.com/spf13/cobra v1.8.1/go.mod h1:wHxEcudfqmLYa8iTfL+OuZPbBZkmvliBWKIezN3kD9Y= 72 | github.com/spf13/pflag v1.0.5 h1:iy+VFUOCP1a+8yFto/drg2CJ5u0yRoB7fZw3DKv/JXA= 73 | github.com/spf13/pflag v1.0.5/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= 74 | github.com/spf13/viper v1.19.0 h1:RWq5SEjt8o25SROyN3z2OrDB9l7RPd3lwTWU8EcEdcI= 75 | github.com/spf13/viper v1.19.0/go.mod h1:GQUN9bilAbhU/jgc1bKs99f/suXKeUMct8Adx5+Ntkg= 76 | github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg= 77 | github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= 78 | github.com/studio-b12/gowebdav v0.9.0 h1:1j1sc9gQnNxbXXM4M/CebPOX4aXYtr7MojAVcN4dHjU= 79 | github.com/studio-b12/gowebdav v0.9.0/go.mod h1:bHA7t77X/QFExdeAnDzK6vKM34kEZAcE1OX4MfiwjkE= 80 | github.com/subosito/gotenv v1.6.0 h1:9NlTDc1FTs4qu0DDq7AEtTPNw6SVm7uBMsUCUjABIf8= 81 | github.com/subosito/gotenv v1.6.0/go.mod h1:Dk4QP5c2W3ibzajGcXpNraDfq2IrhjMIvMSWPKKo0FU= 82 | github.com/traefik/yaegi v0.16.1 h1:f1De3DVJqIDKmnasUF6MwmWv1dSEEat0wcpXhD2On3E= 83 | github.com/traefik/yaegi v0.16.1/go.mod h1:4eVhbPb3LnD2VigQjhYbEJ69vDRFdT2HQNrXx8eEwUY= 84 | github.com/ugjka/messenger v1.1.3 h1:8Eum8yF2CeGEj0KpldA/cmDCw4JH1mQK40817wxAP7w= 85 | github.com/ugjka/messenger v1.1.3/go.mod h1:yjM7cZkSgCqFGKvQ7z1egQHzweas2pstCAdP1chzeCQ= 86 | github.com/ulikunitz/xz v0.5.12 h1:37Nm15o69RwBkXM0J6A5OlE67RZTfzUxTj8fB3dfcsc= 87 | github.com/ulikunitz/xz v0.5.12/go.mod h1:nbz6k7qbPmH4IRqmfOplQw/tblSgqTqBwxkY0oWt/14= 88 | github.com/xyproto/randomstring v1.0.5 h1:YtlWPoRdgMu3NZtP45drfy1GKoojuR7hmRcnhZqKjWU= 89 | github.com/xyproto/randomstring v1.0.5/go.mod h1:rgmS5DeNXLivK7YprL0pY+lTuhNQW3iGxZ18UQApw/E= 90 | go.uber.org/multierr v1.11.0 h1:blXXJkSxSSfBVBlC76pxqeO+LN3aDfLQo+309xJstO0= 91 | go.uber.org/multierr v1.11.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y= 92 | golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= 93 | golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= 94 | golang.org/x/crypto v0.28.0 h1:GBDwsMXVQi34v5CCYUm2jkJvu4cbtru2U4TN2PSyQnw= 95 | golang.org/x/crypto v0.28.0/go.mod h1:rmgy+3RHxRZMyY0jjAJShp2zgEdOqj2AO7U0pYmeQ7U= 96 | golang.org/x/exp v0.0.0-20241009180824-f66d83c29e7c h1:7dEasQXItcW1xKJ2+gg5VOiBnqWrJc+rq0DPKyvvdbY= 97 | golang.org/x/exp v0.0.0-20241009180824-f66d83c29e7c/go.mod h1:NQtJDoLvd6faHhE7m4T/1IY708gDefGGjR/iUW8yQQ8= 98 | golang.org/x/lint v0.0.0-20200302205851-738671d3881b/go.mod h1:3xt1FjdF8hUf6vQPIChWIBhFzV8gjjsPE/fR3IyQdNY= 99 | golang.org/x/mod v0.1.1-0.20191105210325-c90efee705ee/go.mod h1:QqPTAvyqsEbceGzBzNggFXnrqF1CaUcvgkdR5Ot7KZg= 100 | golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= 101 | golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= 102 | golang.org/x/net v0.0.0-20200202094626-16171245cfb2/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= 103 | golang.org/x/net v0.30.0 h1:AcW1SDZMkb8IpzCdQUaIq2sP4sZ4zw+55h6ynffypl4= 104 | golang.org/x/net v0.30.0/go.mod h1:2wGyMJ5iFasEhkwi13ChkO/t1ECNC4X4eBKkVFyYFlU= 105 | golang.org/x/oauth2 v0.23.0 h1:PbgcYx2W7i4LvjJWEbf0ngHV6qJYr86PkAV3bXdLEbs= 106 | golang.org/x/oauth2 v0.23.0/go.mod h1:XYTD2NtWslqkgxebSiOHnXEap4TF09sJSc7H1sXbhtI= 107 | golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= 108 | golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= 109 | golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 110 | golang.org/x/sys v0.26.0 h1:KHjCJyddX0LoSTb3J+vWpupP9p0oznkqVk/IfjymZbo= 111 | golang.org/x/sys v0.26.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= 112 | golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= 113 | golang.org/x/text v0.19.0 h1:kTxAhCbGbxhK0IwgSKiMO5awPoDQ0RpfiVYBfK860YM= 114 | golang.org/x/text v0.19.0/go.mod h1:BuEKDfySbSR4drPmRPG/7iBdf8hvFMuRexcpahXilzY= 115 | golang.org/x/tools v0.0.0-20200130002326-2f3ba24bd6e7/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28= 116 | golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= 117 | google.golang.org/genproto/googleapis/rpc v0.0.0-20241021214115-324edc3d5d38 h1:zciRKQ4kBpFgpfC5QQCVtnnNAcLIqweL7plyZRQHVpI= 118 | google.golang.org/genproto/googleapis/rpc v0.0.0-20241021214115-324edc3d5d38/go.mod h1:GX3210XPVPUjJbTUbvwI8f2IpZDMZuPJWDzDuebbviI= 119 | google.golang.org/grpc v1.67.1 h1:zWnc1Vrcno+lHZCOofnIMvycFcc0QRGIzm9dhnDX68E= 120 | google.golang.org/grpc v1.67.1/go.mod h1:1gLDyUQU7CTLJI90u3nXZ9ekeghjeM7pTDZlqFNg2AA= 121 | google.golang.org/protobuf v1.35.1 h1:m3LfL6/Ca+fqnjnlqQXNpFPABW1UD7mjh8KO2mKFytA= 122 | google.golang.org/protobuf v1.35.1/go.mod h1:9fA7Ob0pmnwhb644+1+CVWFRbNajQ6iRojtC/QF5bRE= 123 | gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= 124 | gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= 125 | gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= 126 | gopkg.in/ini.v1 v1.67.0 h1:Dgnx+6+nfE+IfzjUEISNeydPJh9AXNNsWbGP9KzCsOA= 127 | gopkg.in/ini.v1 v1.67.0/go.mod h1:pNLf8WUiyNEtQjuu5G5vTm06TEv9tsIgeAvK8hOrP4k= 128 | gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= 129 | gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= 130 | nhooyr.io/websocket v1.8.17 h1:KEVeLJkUywCKVsnLIDlD/5gtayKp8VoCkksHCGGfT9Y= 131 | nhooyr.io/websocket v1.8.17/go.mod h1:rN9OFWIUwuxg4fR5tELlYC04bXYowCP9GX47ivo2l+c= 132 | -------------------------------------------------------------------------------- /pkg/api/proto/v1/events.pb.go: -------------------------------------------------------------------------------- 1 | // Code generated by protoc-gen-go. DO NOT EDIT. 2 | // versions: 3 | // protoc-gen-go v1.35.2 4 | // protoc v3.19.6 5 | // source: events.proto 6 | 7 | package v1 8 | 9 | import ( 10 | protoreflect "google.golang.org/protobuf/reflect/protoreflect" 11 | protoimpl "google.golang.org/protobuf/runtime/protoimpl" 12 | reflect "reflect" 13 | sync "sync" 14 | ) 15 | 16 | const ( 17 | // Verify that this generated code is sufficiently up-to-date. 18 | _ = protoimpl.EnforceVersion(20 - protoimpl.MinVersion) 19 | // Verify that runtime/protoimpl is sufficiently up-to-date. 20 | _ = protoimpl.EnforceVersion(protoimpl.MaxVersion - 20) 21 | ) 22 | 23 | type EventMessage struct { 24 | state protoimpl.MessageState 25 | sizeCache protoimpl.SizeCache 26 | unknownFields protoimpl.UnknownFields 27 | 28 | CreatedAt string `protobuf:"bytes,1,opt,name=CreatedAt,proto3" json:"CreatedAt,omitempty"` 29 | Message string `protobuf:"bytes,2,opt,name=Message,proto3" json:"Message,omitempty"` 30 | } 31 | 32 | func (x *EventMessage) Reset() { 33 | *x = EventMessage{} 34 | mi := &file_events_proto_msgTypes[0] 35 | ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) 36 | ms.StoreMessageInfo(mi) 37 | } 38 | 39 | func (x *EventMessage) String() string { 40 | return protoimpl.X.MessageStringOf(x) 41 | } 42 | 43 | func (*EventMessage) ProtoMessage() {} 44 | 45 | func (x *EventMessage) ProtoReflect() protoreflect.Message { 46 | mi := &file_events_proto_msgTypes[0] 47 | if x != nil { 48 | ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) 49 | if ms.LoadMessageInfo() == nil { 50 | ms.StoreMessageInfo(mi) 51 | } 52 | return ms 53 | } 54 | return mi.MessageOf(x) 55 | } 56 | 57 | // Deprecated: Use EventMessage.ProtoReflect.Descriptor instead. 58 | func (*EventMessage) Descriptor() ([]byte, []int) { 59 | return file_events_proto_rawDescGZIP(), []int{0} 60 | } 61 | 62 | func (x *EventMessage) GetCreatedAt() string { 63 | if x != nil { 64 | return x.CreatedAt 65 | } 66 | return "" 67 | } 68 | 69 | func (x *EventMessage) GetMessage() string { 70 | if x != nil { 71 | return x.Message 72 | } 73 | return "" 74 | } 75 | 76 | var File_events_proto protoreflect.FileDescriptor 77 | 78 | var file_events_proto_rawDesc = []byte{ 79 | 0x0a, 0x0c, 0x65, 0x76, 0x65, 0x6e, 0x74, 0x73, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x12, 0x1e, 80 | 0x63, 0x6f, 0x6d, 0x2e, 0x70, 0x6f, 0x6a, 0x74, 0x69, 0x6e, 0x67, 0x65, 0x72, 0x2e, 0x66, 0x65, 81 | 0x6c, 0x69, 0x63, 0x69, 0x74, 0x61, 0x73, 0x2e, 0x62, 0x6f, 0x66, 0x69, 0x65, 0x64, 0x1a, 0x0e, 82 | 0x6d, 0x65, 0x74, 0x61, 0x64, 0x61, 0x74, 0x61, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x22, 0x46, 83 | 0x0a, 0x0c, 0x45, 0x76, 0x65, 0x6e, 0x74, 0x4d, 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, 0x12, 0x1c, 84 | 0x0a, 0x09, 0x43, 0x72, 0x65, 0x61, 0x74, 0x65, 0x64, 0x41, 0x74, 0x18, 0x01, 0x20, 0x01, 0x28, 85 | 0x09, 0x52, 0x09, 0x43, 0x72, 0x65, 0x61, 0x74, 0x65, 0x64, 0x41, 0x74, 0x12, 0x18, 0x0a, 0x07, 86 | 0x4d, 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x52, 0x07, 0x4d, 87 | 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, 0x32, 0x7b, 0x0a, 0x0d, 0x45, 0x76, 0x65, 0x6e, 0x74, 0x73, 88 | 0x53, 0x65, 0x72, 0x76, 0x69, 0x63, 0x65, 0x12, 0x6a, 0x0a, 0x11, 0x53, 0x75, 0x62, 0x73, 0x63, 89 | 0x72, 0x69, 0x62, 0x65, 0x54, 0x6f, 0x45, 0x76, 0x65, 0x6e, 0x74, 0x73, 0x12, 0x25, 0x2e, 0x63, 90 | 0x6f, 0x6d, 0x2e, 0x70, 0x6f, 0x6a, 0x74, 0x69, 0x6e, 0x67, 0x65, 0x72, 0x2e, 0x66, 0x65, 0x6c, 91 | 0x69, 0x63, 0x69, 0x74, 0x61, 0x73, 0x2e, 0x62, 0x6f, 0x66, 0x69, 0x65, 0x64, 0x2e, 0x45, 0x6d, 92 | 0x70, 0x74, 0x79, 0x1a, 0x2c, 0x2e, 0x63, 0x6f, 0x6d, 0x2e, 0x70, 0x6f, 0x6a, 0x74, 0x69, 0x6e, 93 | 0x67, 0x65, 0x72, 0x2e, 0x66, 0x65, 0x6c, 0x69, 0x63, 0x69, 0x74, 0x61, 0x73, 0x2e, 0x62, 0x6f, 94 | 0x66, 0x69, 0x65, 0x64, 0x2e, 0x45, 0x76, 0x65, 0x6e, 0x74, 0x4d, 0x65, 0x73, 0x73, 0x61, 0x67, 95 | 0x65, 0x30, 0x01, 0x42, 0x2c, 0x5a, 0x2a, 0x67, 0x69, 0x74, 0x68, 0x75, 0x62, 0x2e, 0x63, 0x6f, 96 | 0x6d, 0x2f, 0x70, 0x6f, 0x6a, 0x6e, 0x74, 0x66, 0x78, 0x2f, 0x62, 0x6f, 0x66, 0x69, 0x65, 0x64, 97 | 0x2f, 0x70, 0x6b, 0x67, 0x2f, 0x61, 0x70, 0x69, 0x2f, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x2f, 0x76, 98 | 0x31, 0x62, 0x06, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x33, 99 | } 100 | 101 | var ( 102 | file_events_proto_rawDescOnce sync.Once 103 | file_events_proto_rawDescData = file_events_proto_rawDesc 104 | ) 105 | 106 | func file_events_proto_rawDescGZIP() []byte { 107 | file_events_proto_rawDescOnce.Do(func() { 108 | file_events_proto_rawDescData = protoimpl.X.CompressGZIP(file_events_proto_rawDescData) 109 | }) 110 | return file_events_proto_rawDescData 111 | } 112 | 113 | var file_events_proto_msgTypes = make([]protoimpl.MessageInfo, 1) 114 | var file_events_proto_goTypes = []any{ 115 | (*EventMessage)(nil), // 0: com.pojtinger.felicitas.bofied.EventMessage 116 | (*Empty)(nil), // 1: com.pojtinger.felicitas.bofied.Empty 117 | } 118 | var file_events_proto_depIdxs = []int32{ 119 | 1, // 0: com.pojtinger.felicitas.bofied.EventsService.SubscribeToEvents:input_type -> com.pojtinger.felicitas.bofied.Empty 120 | 0, // 1: com.pojtinger.felicitas.bofied.EventsService.SubscribeToEvents:output_type -> com.pojtinger.felicitas.bofied.EventMessage 121 | 1, // [1:2] is the sub-list for method output_type 122 | 0, // [0:1] is the sub-list for method input_type 123 | 0, // [0:0] is the sub-list for extension type_name 124 | 0, // [0:0] is the sub-list for extension extendee 125 | 0, // [0:0] is the sub-list for field type_name 126 | } 127 | 128 | func init() { file_events_proto_init() } 129 | func file_events_proto_init() { 130 | if File_events_proto != nil { 131 | return 132 | } 133 | file_metadata_proto_init() 134 | type x struct{} 135 | out := protoimpl.TypeBuilder{ 136 | File: protoimpl.DescBuilder{ 137 | GoPackagePath: reflect.TypeOf(x{}).PkgPath(), 138 | RawDescriptor: file_events_proto_rawDesc, 139 | NumEnums: 0, 140 | NumMessages: 1, 141 | NumExtensions: 0, 142 | NumServices: 1, 143 | }, 144 | GoTypes: file_events_proto_goTypes, 145 | DependencyIndexes: file_events_proto_depIdxs, 146 | MessageInfos: file_events_proto_msgTypes, 147 | }.Build() 148 | File_events_proto = out.File 149 | file_events_proto_rawDesc = nil 150 | file_events_proto_goTypes = nil 151 | file_events_proto_depIdxs = nil 152 | } 153 | -------------------------------------------------------------------------------- /pkg/api/proto/v1/events_grpc.pb.go: -------------------------------------------------------------------------------- 1 | // Code generated by protoc-gen-go-grpc. DO NOT EDIT. 2 | // versions: 3 | // - protoc-gen-go-grpc v1.5.1 4 | // - protoc v3.19.6 5 | // source: events.proto 6 | 7 | package v1 8 | 9 | import ( 10 | context "context" 11 | grpc "google.golang.org/grpc" 12 | codes "google.golang.org/grpc/codes" 13 | status "google.golang.org/grpc/status" 14 | ) 15 | 16 | // This is a compile-time assertion to ensure that this generated file 17 | // is compatible with the grpc package it is being compiled against. 18 | // Requires gRPC-Go v1.64.0 or later. 19 | const _ = grpc.SupportPackageIsVersion9 20 | 21 | const ( 22 | EventsService_SubscribeToEvents_FullMethodName = "/com.pojtinger.felicitas.bofied.EventsService/SubscribeToEvents" 23 | ) 24 | 25 | // EventsServiceClient is the client API for EventsService service. 26 | // 27 | // For semantics around ctx use and closing/ending streaming RPCs, please refer to https://pkg.go.dev/google.golang.org/grpc/?tab=doc#ClientConn.NewStream. 28 | type EventsServiceClient interface { 29 | SubscribeToEvents(ctx context.Context, in *Empty, opts ...grpc.CallOption) (grpc.ServerStreamingClient[EventMessage], error) 30 | } 31 | 32 | type eventsServiceClient struct { 33 | cc grpc.ClientConnInterface 34 | } 35 | 36 | func NewEventsServiceClient(cc grpc.ClientConnInterface) EventsServiceClient { 37 | return &eventsServiceClient{cc} 38 | } 39 | 40 | func (c *eventsServiceClient) SubscribeToEvents(ctx context.Context, in *Empty, opts ...grpc.CallOption) (grpc.ServerStreamingClient[EventMessage], error) { 41 | cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) 42 | stream, err := c.cc.NewStream(ctx, &EventsService_ServiceDesc.Streams[0], EventsService_SubscribeToEvents_FullMethodName, cOpts...) 43 | if err != nil { 44 | return nil, err 45 | } 46 | x := &grpc.GenericClientStream[Empty, EventMessage]{ClientStream: stream} 47 | if err := x.ClientStream.SendMsg(in); err != nil { 48 | return nil, err 49 | } 50 | if err := x.ClientStream.CloseSend(); err != nil { 51 | return nil, err 52 | } 53 | return x, nil 54 | } 55 | 56 | // This type alias is provided for backwards compatibility with existing code that references the prior non-generic stream type by name. 57 | type EventsService_SubscribeToEventsClient = grpc.ServerStreamingClient[EventMessage] 58 | 59 | // EventsServiceServer is the server API for EventsService service. 60 | // All implementations must embed UnimplementedEventsServiceServer 61 | // for forward compatibility. 62 | type EventsServiceServer interface { 63 | SubscribeToEvents(*Empty, grpc.ServerStreamingServer[EventMessage]) error 64 | mustEmbedUnimplementedEventsServiceServer() 65 | } 66 | 67 | // UnimplementedEventsServiceServer must be embedded to have 68 | // forward compatible implementations. 69 | // 70 | // NOTE: this should be embedded by value instead of pointer to avoid a nil 71 | // pointer dereference when methods are called. 72 | type UnimplementedEventsServiceServer struct{} 73 | 74 | func (UnimplementedEventsServiceServer) SubscribeToEvents(*Empty, grpc.ServerStreamingServer[EventMessage]) error { 75 | return status.Errorf(codes.Unimplemented, "method SubscribeToEvents not implemented") 76 | } 77 | func (UnimplementedEventsServiceServer) mustEmbedUnimplementedEventsServiceServer() {} 78 | func (UnimplementedEventsServiceServer) testEmbeddedByValue() {} 79 | 80 | // UnsafeEventsServiceServer may be embedded to opt out of forward compatibility for this service. 81 | // Use of this interface is not recommended, as added methods to EventsServiceServer will 82 | // result in compilation errors. 83 | type UnsafeEventsServiceServer interface { 84 | mustEmbedUnimplementedEventsServiceServer() 85 | } 86 | 87 | func RegisterEventsServiceServer(s grpc.ServiceRegistrar, srv EventsServiceServer) { 88 | // If the following call pancis, it indicates UnimplementedEventsServiceServer was 89 | // embedded by pointer and is nil. This will cause panics if an 90 | // unimplemented method is ever invoked, so we test this at initialization 91 | // time to prevent it from happening at runtime later due to I/O. 92 | if t, ok := srv.(interface{ testEmbeddedByValue() }); ok { 93 | t.testEmbeddedByValue() 94 | } 95 | s.RegisterService(&EventsService_ServiceDesc, srv) 96 | } 97 | 98 | func _EventsService_SubscribeToEvents_Handler(srv interface{}, stream grpc.ServerStream) error { 99 | m := new(Empty) 100 | if err := stream.RecvMsg(m); err != nil { 101 | return err 102 | } 103 | return srv.(EventsServiceServer).SubscribeToEvents(m, &grpc.GenericServerStream[Empty, EventMessage]{ServerStream: stream}) 104 | } 105 | 106 | // This type alias is provided for backwards compatibility with existing code that references the prior non-generic stream type by name. 107 | type EventsService_SubscribeToEventsServer = grpc.ServerStreamingServer[EventMessage] 108 | 109 | // EventsService_ServiceDesc is the grpc.ServiceDesc for EventsService service. 110 | // It's only intended for direct use with grpc.RegisterService, 111 | // and not to be introspected or modified (even as a copy) 112 | var EventsService_ServiceDesc = grpc.ServiceDesc{ 113 | ServiceName: "com.pojtinger.felicitas.bofied.EventsService", 114 | HandlerType: (*EventsServiceServer)(nil), 115 | Methods: []grpc.MethodDesc{}, 116 | Streams: []grpc.StreamDesc{ 117 | { 118 | StreamName: "SubscribeToEvents", 119 | Handler: _EventsService_SubscribeToEvents_Handler, 120 | ServerStreams: true, 121 | }, 122 | }, 123 | Metadata: "events.proto", 124 | } 125 | -------------------------------------------------------------------------------- /pkg/api/proto/v1/metadata.pb.go: -------------------------------------------------------------------------------- 1 | // Code generated by protoc-gen-go. DO NOT EDIT. 2 | // versions: 3 | // protoc-gen-go v1.35.2 4 | // protoc v3.19.6 5 | // source: metadata.proto 6 | 7 | package v1 8 | 9 | import ( 10 | protoreflect "google.golang.org/protobuf/reflect/protoreflect" 11 | protoimpl "google.golang.org/protobuf/runtime/protoimpl" 12 | reflect "reflect" 13 | sync "sync" 14 | ) 15 | 16 | const ( 17 | // Verify that this generated code is sufficiently up-to-date. 18 | _ = protoimpl.EnforceVersion(20 - protoimpl.MinVersion) 19 | // Verify that runtime/protoimpl is sufficiently up-to-date. 20 | _ = protoimpl.EnforceVersion(protoimpl.MaxVersion - 20) 21 | ) 22 | 23 | type Empty struct { 24 | state protoimpl.MessageState 25 | sizeCache protoimpl.SizeCache 26 | unknownFields protoimpl.UnknownFields 27 | } 28 | 29 | func (x *Empty) Reset() { 30 | *x = Empty{} 31 | mi := &file_metadata_proto_msgTypes[0] 32 | ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) 33 | ms.StoreMessageInfo(mi) 34 | } 35 | 36 | func (x *Empty) String() string { 37 | return protoimpl.X.MessageStringOf(x) 38 | } 39 | 40 | func (*Empty) ProtoMessage() {} 41 | 42 | func (x *Empty) ProtoReflect() protoreflect.Message { 43 | mi := &file_metadata_proto_msgTypes[0] 44 | if x != nil { 45 | ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) 46 | if ms.LoadMessageInfo() == nil { 47 | ms.StoreMessageInfo(mi) 48 | } 49 | return ms 50 | } 51 | return mi.MessageOf(x) 52 | } 53 | 54 | // Deprecated: Use Empty.ProtoReflect.Descriptor instead. 55 | func (*Empty) Descriptor() ([]byte, []int) { 56 | return file_metadata_proto_rawDescGZIP(), []int{0} 57 | } 58 | 59 | type MetadataMessage struct { 60 | state protoimpl.MessageState 61 | sizeCache protoimpl.SizeCache 62 | unknownFields protoimpl.UnknownFields 63 | 64 | AdvertisedIP string `protobuf:"bytes,1,opt,name=AdvertisedIP,proto3" json:"AdvertisedIP,omitempty"` 65 | TFTPPort int32 `protobuf:"varint,2,opt,name=TFTPPort,proto3" json:"TFTPPort,omitempty"` 66 | HTTPPort int32 `protobuf:"varint,3,opt,name=HTTPPort,proto3" json:"HTTPPort,omitempty"` 67 | } 68 | 69 | func (x *MetadataMessage) Reset() { 70 | *x = MetadataMessage{} 71 | mi := &file_metadata_proto_msgTypes[1] 72 | ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) 73 | ms.StoreMessageInfo(mi) 74 | } 75 | 76 | func (x *MetadataMessage) String() string { 77 | return protoimpl.X.MessageStringOf(x) 78 | } 79 | 80 | func (*MetadataMessage) ProtoMessage() {} 81 | 82 | func (x *MetadataMessage) ProtoReflect() protoreflect.Message { 83 | mi := &file_metadata_proto_msgTypes[1] 84 | if x != nil { 85 | ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) 86 | if ms.LoadMessageInfo() == nil { 87 | ms.StoreMessageInfo(mi) 88 | } 89 | return ms 90 | } 91 | return mi.MessageOf(x) 92 | } 93 | 94 | // Deprecated: Use MetadataMessage.ProtoReflect.Descriptor instead. 95 | func (*MetadataMessage) Descriptor() ([]byte, []int) { 96 | return file_metadata_proto_rawDescGZIP(), []int{1} 97 | } 98 | 99 | func (x *MetadataMessage) GetAdvertisedIP() string { 100 | if x != nil { 101 | return x.AdvertisedIP 102 | } 103 | return "" 104 | } 105 | 106 | func (x *MetadataMessage) GetTFTPPort() int32 { 107 | if x != nil { 108 | return x.TFTPPort 109 | } 110 | return 0 111 | } 112 | 113 | func (x *MetadataMessage) GetHTTPPort() int32 { 114 | if x != nil { 115 | return x.HTTPPort 116 | } 117 | return 0 118 | } 119 | 120 | var File_metadata_proto protoreflect.FileDescriptor 121 | 122 | var file_metadata_proto_rawDesc = []byte{ 123 | 0x0a, 0x0e, 0x6d, 0x65, 0x74, 0x61, 0x64, 0x61, 0x74, 0x61, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 124 | 0x12, 0x1e, 0x63, 0x6f, 0x6d, 0x2e, 0x70, 0x6f, 0x6a, 0x74, 0x69, 0x6e, 0x67, 0x65, 0x72, 0x2e, 125 | 0x66, 0x65, 0x6c, 0x69, 0x63, 0x69, 0x74, 0x61, 0x73, 0x2e, 0x62, 0x6f, 0x66, 0x69, 0x65, 0x64, 126 | 0x22, 0x07, 0x0a, 0x05, 0x45, 0x6d, 0x70, 0x74, 0x79, 0x22, 0x6d, 0x0a, 0x0f, 0x4d, 0x65, 0x74, 127 | 0x61, 0x64, 0x61, 0x74, 0x61, 0x4d, 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, 0x12, 0x22, 0x0a, 0x0c, 128 | 0x41, 0x64, 0x76, 0x65, 0x72, 0x74, 0x69, 0x73, 0x65, 0x64, 0x49, 0x50, 0x18, 0x01, 0x20, 0x01, 129 | 0x28, 0x09, 0x52, 0x0c, 0x41, 0x64, 0x76, 0x65, 0x72, 0x74, 0x69, 0x73, 0x65, 0x64, 0x49, 0x50, 130 | 0x12, 0x1a, 0x0a, 0x08, 0x54, 0x46, 0x54, 0x50, 0x50, 0x6f, 0x72, 0x74, 0x18, 0x02, 0x20, 0x01, 131 | 0x28, 0x05, 0x52, 0x08, 0x54, 0x46, 0x54, 0x50, 0x50, 0x6f, 0x72, 0x74, 0x12, 0x1a, 0x0a, 0x08, 132 | 0x48, 0x54, 0x54, 0x50, 0x50, 0x6f, 0x72, 0x74, 0x18, 0x03, 0x20, 0x01, 0x28, 0x05, 0x52, 0x08, 133 | 0x48, 0x54, 0x54, 0x50, 0x50, 0x6f, 0x72, 0x74, 0x32, 0x78, 0x0a, 0x0f, 0x4d, 0x65, 0x74, 0x61, 134 | 0x64, 0x61, 0x74, 0x61, 0x53, 0x65, 0x72, 0x76, 0x69, 0x63, 0x65, 0x12, 0x65, 0x0a, 0x0b, 0x47, 135 | 0x65, 0x74, 0x4d, 0x65, 0x74, 0x61, 0x64, 0x61, 0x74, 0x61, 0x12, 0x25, 0x2e, 0x63, 0x6f, 0x6d, 136 | 0x2e, 0x70, 0x6f, 0x6a, 0x74, 0x69, 0x6e, 0x67, 0x65, 0x72, 0x2e, 0x66, 0x65, 0x6c, 0x69, 0x63, 137 | 0x69, 0x74, 0x61, 0x73, 0x2e, 0x62, 0x6f, 0x66, 0x69, 0x65, 0x64, 0x2e, 0x45, 0x6d, 0x70, 0x74, 138 | 0x79, 0x1a, 0x2f, 0x2e, 0x63, 0x6f, 0x6d, 0x2e, 0x70, 0x6f, 0x6a, 0x74, 0x69, 0x6e, 0x67, 0x65, 139 | 0x72, 0x2e, 0x66, 0x65, 0x6c, 0x69, 0x63, 0x69, 0x74, 0x61, 0x73, 0x2e, 0x62, 0x6f, 0x66, 0x69, 140 | 0x65, 0x64, 0x2e, 0x4d, 0x65, 0x74, 0x61, 0x64, 0x61, 0x74, 0x61, 0x4d, 0x65, 0x73, 0x73, 0x61, 141 | 0x67, 0x65, 0x42, 0x2c, 0x5a, 0x2a, 0x67, 0x69, 0x74, 0x68, 0x75, 0x62, 0x2e, 0x63, 0x6f, 0x6d, 142 | 0x2f, 0x70, 0x6f, 0x6a, 0x6e, 0x74, 0x66, 0x78, 0x2f, 0x62, 0x6f, 0x66, 0x69, 0x65, 0x64, 0x2f, 143 | 0x70, 0x6b, 0x67, 0x2f, 0x61, 0x70, 0x69, 0x2f, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x2f, 0x76, 0x31, 144 | 0x62, 0x06, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x33, 145 | } 146 | 147 | var ( 148 | file_metadata_proto_rawDescOnce sync.Once 149 | file_metadata_proto_rawDescData = file_metadata_proto_rawDesc 150 | ) 151 | 152 | func file_metadata_proto_rawDescGZIP() []byte { 153 | file_metadata_proto_rawDescOnce.Do(func() { 154 | file_metadata_proto_rawDescData = protoimpl.X.CompressGZIP(file_metadata_proto_rawDescData) 155 | }) 156 | return file_metadata_proto_rawDescData 157 | } 158 | 159 | var file_metadata_proto_msgTypes = make([]protoimpl.MessageInfo, 2) 160 | var file_metadata_proto_goTypes = []any{ 161 | (*Empty)(nil), // 0: com.pojtinger.felicitas.bofied.Empty 162 | (*MetadataMessage)(nil), // 1: com.pojtinger.felicitas.bofied.MetadataMessage 163 | } 164 | var file_metadata_proto_depIdxs = []int32{ 165 | 0, // 0: com.pojtinger.felicitas.bofied.MetadataService.GetMetadata:input_type -> com.pojtinger.felicitas.bofied.Empty 166 | 1, // 1: com.pojtinger.felicitas.bofied.MetadataService.GetMetadata:output_type -> com.pojtinger.felicitas.bofied.MetadataMessage 167 | 1, // [1:2] is the sub-list for method output_type 168 | 0, // [0:1] is the sub-list for method input_type 169 | 0, // [0:0] is the sub-list for extension type_name 170 | 0, // [0:0] is the sub-list for extension extendee 171 | 0, // [0:0] is the sub-list for field type_name 172 | } 173 | 174 | func init() { file_metadata_proto_init() } 175 | func file_metadata_proto_init() { 176 | if File_metadata_proto != nil { 177 | return 178 | } 179 | type x struct{} 180 | out := protoimpl.TypeBuilder{ 181 | File: protoimpl.DescBuilder{ 182 | GoPackagePath: reflect.TypeOf(x{}).PkgPath(), 183 | RawDescriptor: file_metadata_proto_rawDesc, 184 | NumEnums: 0, 185 | NumMessages: 2, 186 | NumExtensions: 0, 187 | NumServices: 1, 188 | }, 189 | GoTypes: file_metadata_proto_goTypes, 190 | DependencyIndexes: file_metadata_proto_depIdxs, 191 | MessageInfos: file_metadata_proto_msgTypes, 192 | }.Build() 193 | File_metadata_proto = out.File 194 | file_metadata_proto_rawDesc = nil 195 | file_metadata_proto_goTypes = nil 196 | file_metadata_proto_depIdxs = nil 197 | } 198 | -------------------------------------------------------------------------------- /pkg/api/proto/v1/metadata_grpc.pb.go: -------------------------------------------------------------------------------- 1 | // Code generated by protoc-gen-go-grpc. DO NOT EDIT. 2 | // versions: 3 | // - protoc-gen-go-grpc v1.5.1 4 | // - protoc v3.19.6 5 | // source: metadata.proto 6 | 7 | package v1 8 | 9 | import ( 10 | context "context" 11 | grpc "google.golang.org/grpc" 12 | codes "google.golang.org/grpc/codes" 13 | status "google.golang.org/grpc/status" 14 | ) 15 | 16 | // This is a compile-time assertion to ensure that this generated file 17 | // is compatible with the grpc package it is being compiled against. 18 | // Requires gRPC-Go v1.64.0 or later. 19 | const _ = grpc.SupportPackageIsVersion9 20 | 21 | const ( 22 | MetadataService_GetMetadata_FullMethodName = "/com.pojtinger.felicitas.bofied.MetadataService/GetMetadata" 23 | ) 24 | 25 | // MetadataServiceClient is the client API for MetadataService service. 26 | // 27 | // For semantics around ctx use and closing/ending streaming RPCs, please refer to https://pkg.go.dev/google.golang.org/grpc/?tab=doc#ClientConn.NewStream. 28 | type MetadataServiceClient interface { 29 | GetMetadata(ctx context.Context, in *Empty, opts ...grpc.CallOption) (*MetadataMessage, error) 30 | } 31 | 32 | type metadataServiceClient struct { 33 | cc grpc.ClientConnInterface 34 | } 35 | 36 | func NewMetadataServiceClient(cc grpc.ClientConnInterface) MetadataServiceClient { 37 | return &metadataServiceClient{cc} 38 | } 39 | 40 | func (c *metadataServiceClient) GetMetadata(ctx context.Context, in *Empty, opts ...grpc.CallOption) (*MetadataMessage, error) { 41 | cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) 42 | out := new(MetadataMessage) 43 | err := c.cc.Invoke(ctx, MetadataService_GetMetadata_FullMethodName, in, out, cOpts...) 44 | if err != nil { 45 | return nil, err 46 | } 47 | return out, nil 48 | } 49 | 50 | // MetadataServiceServer is the server API for MetadataService service. 51 | // All implementations must embed UnimplementedMetadataServiceServer 52 | // for forward compatibility. 53 | type MetadataServiceServer interface { 54 | GetMetadata(context.Context, *Empty) (*MetadataMessage, error) 55 | mustEmbedUnimplementedMetadataServiceServer() 56 | } 57 | 58 | // UnimplementedMetadataServiceServer must be embedded to have 59 | // forward compatible implementations. 60 | // 61 | // NOTE: this should be embedded by value instead of pointer to avoid a nil 62 | // pointer dereference when methods are called. 63 | type UnimplementedMetadataServiceServer struct{} 64 | 65 | func (UnimplementedMetadataServiceServer) GetMetadata(context.Context, *Empty) (*MetadataMessage, error) { 66 | return nil, status.Errorf(codes.Unimplemented, "method GetMetadata not implemented") 67 | } 68 | func (UnimplementedMetadataServiceServer) mustEmbedUnimplementedMetadataServiceServer() {} 69 | func (UnimplementedMetadataServiceServer) testEmbeddedByValue() {} 70 | 71 | // UnsafeMetadataServiceServer may be embedded to opt out of forward compatibility for this service. 72 | // Use of this interface is not recommended, as added methods to MetadataServiceServer will 73 | // result in compilation errors. 74 | type UnsafeMetadataServiceServer interface { 75 | mustEmbedUnimplementedMetadataServiceServer() 76 | } 77 | 78 | func RegisterMetadataServiceServer(s grpc.ServiceRegistrar, srv MetadataServiceServer) { 79 | // If the following call pancis, it indicates UnimplementedMetadataServiceServer was 80 | // embedded by pointer and is nil. This will cause panics if an 81 | // unimplemented method is ever invoked, so we test this at initialization 82 | // time to prevent it from happening at runtime later due to I/O. 83 | if t, ok := srv.(interface{ testEmbeddedByValue() }); ok { 84 | t.testEmbeddedByValue() 85 | } 86 | s.RegisterService(&MetadataService_ServiceDesc, srv) 87 | } 88 | 89 | func _MetadataService_GetMetadata_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { 90 | in := new(Empty) 91 | if err := dec(in); err != nil { 92 | return nil, err 93 | } 94 | if interceptor == nil { 95 | return srv.(MetadataServiceServer).GetMetadata(ctx, in) 96 | } 97 | info := &grpc.UnaryServerInfo{ 98 | Server: srv, 99 | FullMethod: MetadataService_GetMetadata_FullMethodName, 100 | } 101 | handler := func(ctx context.Context, req interface{}) (interface{}, error) { 102 | return srv.(MetadataServiceServer).GetMetadata(ctx, req.(*Empty)) 103 | } 104 | return interceptor(ctx, in, info, handler) 105 | } 106 | 107 | // MetadataService_ServiceDesc is the grpc.ServiceDesc for MetadataService service. 108 | // It's only intended for direct use with grpc.RegisterService, 109 | // and not to be introspected or modified (even as a copy) 110 | var MetadataService_ServiceDesc = grpc.ServiceDesc{ 111 | ServiceName: "com.pojtinger.felicitas.bofied.MetadataService", 112 | HandlerType: (*MetadataServiceServer)(nil), 113 | Methods: []grpc.MethodDesc{ 114 | { 115 | MethodName: "GetMetadata", 116 | Handler: _MetadataService_GetMetadata_Handler, 117 | }, 118 | }, 119 | Streams: []grpc.StreamDesc{}, 120 | Metadata: "metadata.proto", 121 | } 122 | -------------------------------------------------------------------------------- /pkg/authorization/oidc_over_basic_auth.go: -------------------------------------------------------------------------------- 1 | package authorization 2 | 3 | import ( 4 | "encoding/base64" 5 | "fmt" 6 | "log" 7 | "net/http" 8 | 9 | "github.com/pojntfx/bofied/pkg/validators" 10 | ) 11 | 12 | func OIDCOverBasicAuth(next http.Handler, username string, oidcValidator *validators.OIDCValidator, description string) http.Handler { 13 | return http.HandlerFunc(func(rw http.ResponseWriter, r *http.Request) { 14 | // Validate the OIDC token which is passed as a HTTP basic auth password due to client limitations 15 | user, pass, ok := r.BasicAuth() 16 | if _, err := oidcValidator.Validate(pass); err != nil || !ok || user != username { 17 | // Unauthorized, log and redirect 18 | log.Println("could not authorize user, redirecting") 19 | 20 | rw.Header().Set("WWW-Authenticate", fmt.Sprintf(`Basic realm="%v"`, description)) 21 | rw.WriteHeader(401) 22 | rw.Write([]byte("could not authorize: " + err.Error())) 23 | 24 | return 25 | } 26 | 27 | // Authorized, continue 28 | next.ServeHTTP(rw, r) 29 | }) 30 | } 31 | 32 | func GetOIDCOverBasicAuthHeader(username string, idToken string) (key string, value string) { 33 | key = "Authorization" 34 | 35 | value = "Basic " + base64.StdEncoding.EncodeToString([]byte(username+":"+idToken)) 36 | 37 | return 38 | } 39 | -------------------------------------------------------------------------------- /pkg/components/about_modal.go: -------------------------------------------------------------------------------- 1 | package components 2 | 3 | import ( 4 | "github.com/maxence-charriere/go-app/v10/pkg/app" 5 | ) 6 | 7 | type AboutModal struct { 8 | app.Compo 9 | 10 | Open bool 11 | Close func() 12 | 13 | ID string 14 | 15 | LogoDarkSrc string 16 | LogoDarkAlt string 17 | 18 | LogoLightSrc string 19 | LogoLightAlt string 20 | 21 | Title string 22 | 23 | Body app.UI 24 | Footer string 25 | } 26 | 27 | func (c *AboutModal) Render() app.UI { 28 | return app.Div(). 29 | Class(func() string { 30 | classes := "pf-v6-c-backdrop" 31 | 32 | if !c.Open { 33 | classes += " pf-v6-u-display-none" 34 | } 35 | 36 | return classes 37 | }()). 38 | Body( 39 | app.Div(). 40 | Class("pf-v6-l-bullseye"). 41 | Body( 42 | app.Div(). 43 | Class("pf-v6-c-modal-box pf-m-lg"). 44 | Aria("role", "dialog"). 45 | Aria("modal", true). 46 | Aria("labelledby", c.ID). 47 | Body( 48 | app.Div(). 49 | Class("pf-v6-c-about-modal-box"). 50 | Body( 51 | app.Div(). 52 | Class("pf-v6-c-about-modal-box__brand"). 53 | Body( 54 | app.Img(). 55 | Class("pf-v6-c-about-modal-box__brand-image pf-v6-c-brand--dark"). 56 | Src(c.LogoDarkSrc). 57 | Alt(c.LogoDarkAlt), 58 | 59 | app.Img(). 60 | Class("pf-v6-c-about-modal-box__brand-image pf-v6-c-brand--light"). 61 | Src(c.LogoLightSrc). 62 | Alt(c.LogoLightAlt), 63 | ), 64 | app.Div(). 65 | Class("pf-v6-c-about-modal-box__close"). 66 | Body( 67 | app.Button(). 68 | Class("pf-v6-c-button pf-m-plain"). 69 | Type("button"). 70 | Aria("label", "Close dialog"). 71 | OnClick(func(ctx app.Context, e app.Event) { 72 | c.Close() 73 | }). 74 | Body( 75 | app.Span(). 76 | Class("pf-v6-c-button__icon"). 77 | Body( 78 | app.I(). 79 | Class("fas fa-times"). 80 | Aria("hidden", true), 81 | ), 82 | ), 83 | ), 84 | app.Div(). 85 | Class("pf-v6-c-about-modal-box__header"). 86 | Body( 87 | app.H1(). 88 | Class("pf-v6-c-title pf-m-4xl"). 89 | ID(c.ID). 90 | Text(c.Title), 91 | ), 92 | app.Div().Class("pf-v6-c-about-modal-box__hero"), 93 | app.Div(). 94 | Class("pf-v6-c-about-modal-box__content"). 95 | Body( 96 | app.Div(). 97 | Class("pf-v6-c-content"). 98 | Body( 99 | c.Body, 100 | ), 101 | app.P(). 102 | Class("pf-v6-c-about-modal-box__strapline"). 103 | Text(c.Footer), 104 | ), 105 | ), 106 | ), 107 | ), 108 | ) 109 | } 110 | 111 | func (c *AboutModal) OnMount(ctx app.Context) { 112 | app.Window().Call("addEventListener", "keyup", app.FuncOf(func(this app.Value, args []app.Value) any { 113 | ctx.Async(func() { 114 | if len(args) > 0 && args[0].Get("key").String() == "Escape" { 115 | c.Close() 116 | 117 | ctx.Update() 118 | } 119 | }) 120 | 121 | return nil 122 | })) 123 | } 124 | -------------------------------------------------------------------------------- /pkg/components/autofocused.go: -------------------------------------------------------------------------------- 1 | package components 2 | 3 | import ( 4 | "github.com/maxence-charriere/go-app/v10/pkg/app" 5 | ) 6 | 7 | type Autofocused struct { 8 | app.Compo 9 | 10 | Component app.UI 11 | } 12 | 13 | func (c *Autofocused) Render() app.UI { 14 | return c.Component 15 | } 16 | 17 | func (c *Autofocused) OnUpdate(ctx app.Context) { 18 | ctx.Defer(func(_ app.Context) { 19 | c.JSValue().Call("focus") 20 | }) 21 | } 22 | -------------------------------------------------------------------------------- /pkg/components/breadcrumbs.go: -------------------------------------------------------------------------------- 1 | package components 2 | 3 | import ( 4 | "path" 5 | 6 | "github.com/maxence-charriere/go-app/v10/pkg/app" 7 | ) 8 | 9 | type Breadcrumbs struct { 10 | app.Compo 11 | 12 | PathComponents []string 13 | 14 | CurrentPath string 15 | SetCurrentPath func(string) 16 | 17 | SelectedPath string 18 | SetSelectedPath func(string) 19 | 20 | ItemClass string 21 | } 22 | 23 | func (c *Breadcrumbs) Render() app.UI { 24 | return app.Nav(). 25 | Class("pf-v6-c-breadcrumb"). 26 | Aria("label", "Current path"). 27 | Body( 28 | app.Ol(). 29 | Class("pf-v6-c-breadcrumb__list pf-v6-u-font-weight-bold"). 30 | Body( 31 | app.Li(). 32 | Class("pf-v6-c-breadcrumb__item", c.ItemClass). 33 | Body( 34 | app.Span(). 35 | Class("pf-v6-c-breadcrumb__item-divider"). 36 | Body( 37 | app.I(). 38 | Class("fas fa-angle-right"). 39 | Aria("hidden", true), 40 | ), 41 | app.Button(). 42 | Type("button"). 43 | Class("pf-v6-c-breadcrumb__link"). 44 | TabIndex(0). 45 | OnClick(func(ctx app.Context, e app.Event) { 46 | c.SetCurrentPath("/") 47 | 48 | c.SetSelectedPath("") 49 | }). 50 | Text("Files"), 51 | ), 52 | app.Range(c.PathComponents).Slice(func(i int) app.UI { 53 | link := path.Join(append([]string{"/"}, c.PathComponents[:i+1]...)...) 54 | 55 | // The last path part shouldn't be marked as a link 56 | classes := "pf-v6-c-breadcrumb__link" 57 | if i == len(c.PathComponents)-1 { 58 | classes += " pf-m-current" 59 | } 60 | 61 | return app.Li(). 62 | Class("pf-v6-c-breadcrumb__item", c.ItemClass). 63 | Body( 64 | app.Span(). 65 | Class("pf-v6-c-breadcrumb__item-divider"). 66 | Body( 67 | app.I(). 68 | Class("fas fa-angle-right"). 69 | Aria("hidden", true), 70 | ), 71 | app.If( 72 | // The last path part shouldn't be an action 73 | i == len(c.PathComponents)-1, 74 | func() app.UI { 75 | return app.A(). 76 | Class(classes). 77 | Text(c.PathComponents[i]) 78 | }, 79 | ).Else( 80 | func() app.UI { 81 | return app.Button(). 82 | Type("button"). 83 | Class(classes). 84 | OnClick(func(ctx app.Context, e app.Event) { 85 | c.SetCurrentPath(link) 86 | 87 | c.SetSelectedPath("") 88 | }). 89 | Text(c.PathComponents[i]) 90 | }, 91 | ), 92 | ) 93 | }), 94 | ), 95 | ) 96 | } 97 | -------------------------------------------------------------------------------- /pkg/components/copyable_input.go: -------------------------------------------------------------------------------- 1 | package components 2 | 3 | import "github.com/maxence-charriere/go-app/v10/pkg/app" 4 | 5 | type CopyableInput struct { 6 | app.Compo 7 | 8 | Component app.UI 9 | ID string 10 | } 11 | 12 | func (c *CopyableInput) Render() app.UI { 13 | return app.Div(). 14 | Class("pf-v6-c-clipboard-copy"). 15 | Body( 16 | app.Div(). 17 | Class("pf-v6-c-clipboard-copy__group"). 18 | Body( 19 | app.Span(). 20 | Class("pf-v6-c-form-control"). 21 | Body( 22 | c.Component, 23 | ), 24 | app.Button(). 25 | Class("pf-v6-c-button pf-m-control"). 26 | Type("button"). 27 | Aria("label", "Copy to clipboard"). 28 | Aria("labelledby", c.ID). 29 | OnClick(func(ctx app.Context, e app.Event) { 30 | app.Window().JSValue().Get("document").Call("getElementById", c.ID).Call("select") 31 | 32 | app.Window().JSValue().Get("document").Call("execCommand", "copy") 33 | }). 34 | Body( 35 | app.Span(). 36 | Class("pf-v6-c-button__icon"). 37 | Body( 38 | app.I(). 39 | Class("fas fa-copy"). 40 | Aria("hidden", true), 41 | ), 42 | ), 43 | ), 44 | ) 45 | } 46 | -------------------------------------------------------------------------------- /pkg/components/data_shell.go: -------------------------------------------------------------------------------- 1 | package components 2 | 3 | import ( 4 | "net/url" 5 | "os" 6 | 7 | "github.com/coreos/go-oidc/v3/oidc" 8 | "github.com/maxence-charriere/go-app/v10/pkg/app" 9 | "github.com/pojntfx/bofied/pkg/providers" 10 | "github.com/studio-b12/gowebdav" 11 | ) 12 | 13 | type DataShell struct { 14 | app.Compo 15 | 16 | // Config file editor 17 | ConfigFile string 18 | SetConfigFile func(string) 19 | 20 | FormatConfigFile func() 21 | RefreshConfigFile func() 22 | SaveConfigFile func() 23 | 24 | ConfigFileError error 25 | IgnoreConfigFileError func() 26 | 27 | // File explorer 28 | CurrentPath string 29 | SetCurrentPath func(string) 30 | 31 | Index []os.FileInfo 32 | RefreshIndex func() 33 | WriteToPath func(string, []byte) 34 | 35 | HTTPShareLink url.URL 36 | TFTPShareLink url.URL 37 | SharePath func(string) 38 | 39 | CreatePath func(string) 40 | CreateEmptyFile func(string) 41 | DeletePath func(string) 42 | MovePath func(string, string) 43 | CopyPath func(string, string) 44 | 45 | EditPathContents string 46 | SetEditPathContents func(string) 47 | EditPath func(string) 48 | 49 | WebDAVAddress url.URL 50 | WebDAVUsername string 51 | WebDAVPassword string 52 | 53 | OperationIndex []os.FileInfo 54 | 55 | OperationCurrentPath string 56 | OperationSetCurrentPath func(string) 57 | 58 | FileExplorerError error 59 | RecoverFileExplorerError func(app.Context) 60 | IgnoreFileExplorerError func() 61 | 62 | Events []providers.Event 63 | 64 | EventsError error 65 | RecoverEventsError func(app.Context) 66 | IgnoreEventsError func() 67 | 68 | // Identity 69 | UserInfo oidc.UserInfo 70 | Logout func(app.Context) 71 | 72 | // Metadata 73 | UseAdvertisedIP bool 74 | SetUseAdvertisedIP func(bool) 75 | 76 | UseAdvertisedIPForWebDAV bool 77 | SetUseAdvertisedIPForWebDAV func(bool) 78 | 79 | SetUseHTTPS func(bool) 80 | SetUseDavs func(bool) 81 | 82 | // Internal state 83 | aboutDialogOpen bool 84 | notificationsDrawerOpen bool 85 | overflowMenuExpanded bool 86 | userMenuExpanded bool 87 | } 88 | 89 | func (c *DataShell) Render() app.UI { 90 | // Gather notifications 91 | notifications := []Notification{} 92 | for _, event := range c.Events { 93 | notifications = append(notifications, Notification{ 94 | CreatedAt: event.CreatedAt.String(), 95 | Message: event.Message, 96 | }) 97 | } 98 | 99 | // Reduce errors to global error 100 | globalError := c.FileExplorerError 101 | if c.EventsError != nil { 102 | globalError = c.EventsError 103 | } 104 | 105 | recoverGlobalError := c.RecoverFileExplorerError 106 | if c.EventsError != nil { 107 | recoverGlobalError = c.RecoverEventsError 108 | } 109 | 110 | ignoreGlobalError := c.IgnoreFileExplorerError 111 | if c.EventsError != nil { 112 | ignoreGlobalError = c.IgnoreEventsError 113 | } 114 | 115 | return app.Div(). 116 | Class("pf-v6-u-h-100"). 117 | Body( 118 | app.Div(). 119 | Class("pf-v6-c-page"). 120 | ID("page-layout-horizontal-nav"). 121 | Body( 122 | app.A(). 123 | Class("pf-v6-c-skip-to-content pf-v6-c-button pf-m-primary"). 124 | Href("#main-content-page-layout-horizontal-nav"). 125 | Text( 126 | "Skip to content", 127 | ), 128 | &Navbar{ 129 | NotificationsDrawerOpen: c.notificationsDrawerOpen, 130 | ToggleNotificationsDrawerOpen: func() { 131 | c.notificationsDrawerOpen = !c.notificationsDrawerOpen 132 | c.overflowMenuExpanded = false 133 | }, 134 | 135 | ToggleAbout: func() { 136 | c.aboutDialogOpen = true 137 | c.overflowMenuExpanded = false 138 | }, 139 | 140 | OverflowMenuExpanded: c.overflowMenuExpanded, 141 | ToggleOverflowMenuExpanded: func() { 142 | c.overflowMenuExpanded = !c.overflowMenuExpanded 143 | c.userMenuExpanded = false 144 | }, 145 | 146 | UserMenuExpanded: c.userMenuExpanded, 147 | ToggleUserMenuExpanded: func() { 148 | c.userMenuExpanded = !c.userMenuExpanded 149 | c.overflowMenuExpanded = false 150 | }, 151 | 152 | UserEmail: c.UserInfo.Email, 153 | Logout: func(ctx app.Context) { 154 | c.Logout(ctx) 155 | }, 156 | }, 157 | app.Div(). 158 | Class("pf-v6-c-page__drawer"). 159 | Body( 160 | app.Div(). 161 | Class(func() string { 162 | classes := "pf-v6-c-drawer" 163 | 164 | if c.notificationsDrawerOpen { 165 | classes += " pf-m-expanded" 166 | } 167 | 168 | return classes 169 | }()). 170 | Body( 171 | app.Div(). 172 | Class("pf-v6-c-drawer__main"). 173 | Body( 174 | app.Div(). 175 | Class("pf-v6-c-drawer__content"). 176 | Body( 177 | app.Div(). 178 | Class("pf-v6-c-page__main-container"). 179 | TabIndex(-1). 180 | Body( 181 | app.Div().Class("pf-v6-c-drawer__body").Body( 182 | app.Main(). 183 | Class("pf-v6-c-page__main pf-v6-u-h-100"). 184 | ID("main-content-page-layout-horizontal-nav"). 185 | TabIndex(-1). 186 | Body( 187 | app.Section(). 188 | Class("pf-v6-c-page__main-section"). 189 | Body( 190 | app.Div(). 191 | Class("pf-v6-l-grid pf-m-gutter pf-v6-u-h-100"). 192 | Body( 193 | app.Div(). 194 | Class("pf-v6-l-grid__item pf-m-12-col pf-m-12-col-on-md pf-m-5-col-on-xl"). 195 | Body( 196 | &TextEditorWrapper{ 197 | Title: "Config", 198 | 199 | HelpLink: "https://github.com/pojntfx/bofied#config-script", 200 | 201 | Error: c.ConfigFileError, 202 | ErrorDescription: "Syntax Error", 203 | Ignore: c.IgnoreConfigFileError, 204 | 205 | Children: &TextEditor{ 206 | Content: c.ConfigFile, 207 | SetContent: c.SetConfigFile, 208 | 209 | Format: c.FormatConfigFile, 210 | Refresh: c.RefreshConfigFile, 211 | Save: c.SaveConfigFile, 212 | 213 | Language: "Go", 214 | }, 215 | }, 216 | ), 217 | 218 | app.Div(). 219 | Class("pf-v6-l-grid__item pf-m-12-col pf-m-12-col-on-md pf-m-7-col-on-xl"). 220 | Body( 221 | &FileExplorer{ 222 | CurrentPath: c.CurrentPath, 223 | SetCurrentPath: c.SetCurrentPath, 224 | 225 | Index: c.Index, 226 | RefreshIndex: c.RefreshIndex, 227 | WriteToPath: c.WriteToPath, 228 | 229 | HTTPShareLink: c.HTTPShareLink, 230 | TFTPShareLink: c.TFTPShareLink, 231 | SharePath: c.SharePath, 232 | 233 | CreatePath: c.CreatePath, 234 | CreateEmptyFile: c.CreateEmptyFile, 235 | DeletePath: c.DeletePath, 236 | MovePath: c.MovePath, 237 | CopyPath: c.CopyPath, 238 | 239 | EditPathContents: c.EditPathContents, 240 | SetEditPathContents: c.SetEditPathContents, 241 | EditPath: c.EditPath, 242 | 243 | WebDAVAddress: c.WebDAVAddress, 244 | WebDAVUsername: c.WebDAVUsername, 245 | WebDAVPassword: c.WebDAVPassword, 246 | 247 | OperationIndex: c.OperationIndex, 248 | 249 | OperationCurrentPath: c.OperationCurrentPath, 250 | OperationSetCurrentPath: c.OperationSetCurrentPath, 251 | 252 | UseAdvertisedIP: c.UseAdvertisedIP, 253 | SetUseAdvertisedIP: c.SetUseAdvertisedIP, 254 | 255 | UseAdvertisedIPForWebDAV: c.UseAdvertisedIPForWebDAV, 256 | SetUseAdvertisedIPForWebDAV: c.SetUseAdvertisedIPForWebDAV, 257 | 258 | SetUseHTTPS: c.SetUseHTTPS, 259 | SetUseDavs: c.SetUseDavs, 260 | 261 | Nested: true, 262 | 263 | GetContentType: func(fi os.FileInfo) string { 264 | return fi.(gowebdav.File).ContentType() 265 | }, 266 | }, 267 | ), 268 | ), 269 | ), 270 | ), 271 | ), 272 | ), 273 | ), 274 | app.Div(). 275 | Class("pf-v6-c-drawer__panel"). 276 | Body( 277 | app.Div(). 278 | Class("pf-v6-c-drawer__body pf-m-no-padding"). 279 | Body( 280 | &NotificationDrawer{ 281 | Notifications: notifications, 282 | EmptyState: app.Div(). 283 | Class("pf-v6-c-empty-state"). 284 | Body( 285 | app.Div(). 286 | Class("pf-v6-c-empty-state__content"). 287 | Body( 288 | app.Div(). 289 | Class("pf-v6-c-empty-state__header"). 290 | Body( 291 | app.I(). 292 | Class("fas fa-inbox pf-v6-c-empty-state__icon"). 293 | Aria("hidden", true), 294 | app.Div(). 295 | Class("pf-v6-c-empty-state__title").Body( 296 | app.H2(). 297 | Class("pf-v6-c-empty-state__title-text"). 298 | Text("No events yet"), 299 | ), 300 | ), 301 | app.Div(). 302 | Class("pf-v6-c-empty-state__body"). 303 | Text("Network boot a node to see events here."), 304 | ), 305 | ), 306 | }, 307 | ), 308 | ), 309 | ), 310 | ), 311 | ), 312 | 313 | app.Ul(). 314 | Class("pf-v6-c-alert-group pf-m-toast"). 315 | Body( 316 | &UpdateNotification{ 317 | UpdateTitle: "An update for bofied is available", 318 | 319 | StartUpdateText: "Upgrade now", 320 | IgnoreUpdateText: "Maybe later", 321 | }, 322 | app.If( 323 | globalError != nil, 324 | func() app.UI { 325 | return app.Li(). 326 | Class("pf-v6-c-alert-group__item"). 327 | Body( 328 | &Status{ 329 | Error: globalError, 330 | ErrorText: "Fatal Error", 331 | Recover: recoverGlobalError, 332 | RecoverText: "Reconnect", 333 | Ignore: ignoreGlobalError, 334 | }, 335 | ) 336 | }, 337 | ), 338 | ), 339 | 340 | &AboutModal{ 341 | Open: c.aboutDialogOpen, 342 | Close: func() { 343 | c.aboutDialogOpen = false 344 | }, 345 | 346 | ID: "about-modal-title", 347 | 348 | LogoDarkSrc: "/web/logo-dark.png", 349 | LogoDarkAlt: "bofied Logo (dark variant)", 350 | 351 | LogoLightSrc: "/web/logo-light.png", 352 | LogoLightAlt: "bofied Logo (light variant)", 353 | 354 | Title: "bofied", 355 | 356 | Body: app.Dl(). 357 | Body( 358 | app.Dt().Text("Frontend version"), 359 | app.Dd().Text("main"), 360 | app.Dt().Text("Backend version"), 361 | app.Dd().Text("main"), 362 | ), 363 | Footer: "Copyright © 2024 Felicitas Pojtinger and contributors (SPDX-License-Identifier: AGPL-3.0)", 364 | }, 365 | ), 366 | ) 367 | } 368 | -------------------------------------------------------------------------------- /pkg/components/empty_state.go: -------------------------------------------------------------------------------- 1 | package components 2 | 3 | import "github.com/maxence-charriere/go-app/v10/pkg/app" 4 | 5 | type EmptyState struct { 6 | app.Compo 7 | 8 | Action app.UI 9 | } 10 | 11 | func (c *EmptyState) Render() app.UI { 12 | return app.Div(). 13 | Class("pf-v6-c-empty-state"). 14 | Body( 15 | app.Div(). 16 | Class("pf-v6-c-empty-state__content"). 17 | Body( 18 | app.Div(). 19 | Class("pf-v6-c-empty-state__header"). 20 | Body( 21 | app.I(). 22 | Class("fas fa-folder-open pf-v6-c-empty-state__icon"). 23 | Aria("hidden", true), 24 | app.Div(). 25 | Class("pf-v6-c-empty-state__title").Body( 26 | app.H2(). 27 | Class("pf-v6-c-empty-state__title-text"). 28 | Text("No files or directories here yet"), 29 | ), 30 | ), 31 | app.Div(). 32 | Class("pf-v6-c-empty-state__body"). 33 | Text("You can add a file or directory to make it available for nodes."), 34 | app.If( 35 | c.Action != nil, 36 | func() app.UI { 37 | return app.Div(). 38 | Class("pf-v6-c-empty-state__footer"). 39 | Body( 40 | app.Div(). 41 | Class("pf-v6-c-empty-state__actions"). 42 | Body( 43 | c.Action, 44 | ), 45 | ) 46 | }, 47 | ), 48 | ), 49 | ) 50 | } 51 | -------------------------------------------------------------------------------- /pkg/components/expandable_section.go: -------------------------------------------------------------------------------- 1 | package components 2 | 3 | import "github.com/maxence-charriere/go-app/v10/pkg/app" 4 | 5 | type ExpandableSection struct { 6 | app.Compo 7 | 8 | Open bool 9 | OnToggle func() 10 | Title string 11 | ClosedTitle string 12 | OpenTitle string 13 | Body []app.UI 14 | } 15 | 16 | func (c *ExpandableSection) Render() app.UI { 17 | return app.Div(). 18 | Class(func() string { 19 | classes := "pf-v6-c-expandable-section" 20 | 21 | if c.Open { 22 | classes += " pf-m-expanded" 23 | } 24 | 25 | return classes 26 | }()). 27 | Body( 28 | app.Div(). 29 | Class("pf-v6-c-expandable-section__toggle"). 30 | Body( 31 | app.Button(). 32 | Type("button"). 33 | Class("pf-v6-c-button pf-m-link"). 34 | Aria("label", func() string { 35 | message := c.ClosedTitle 36 | 37 | if c.Open { 38 | message = c.OpenTitle 39 | } 40 | 41 | return message 42 | }()). 43 | Aria("expanded", c.Open). 44 | OnClick(func(ctx app.Context, e app.Event) { 45 | c.OnToggle() 46 | }). 47 | Body( 48 | app.Span(). 49 | Class("pf-v6-c-button__icon pf-m-start"). 50 | Body( 51 | app.I(). 52 | Class("fas fa-angle-right"). 53 | Aria("hidden", true), 54 | ), 55 | app.Span(). 56 | Class("pf-v6-c-button__text"). 57 | Text(c.Title), 58 | ), 59 | ), 60 | app.Div(). 61 | Class("pf-v6-c-expandable-section__content"). 62 | Hidden(!c.Open). 63 | Body(c.Body...), 64 | ) 65 | } 66 | -------------------------------------------------------------------------------- /pkg/components/file_grid.go: -------------------------------------------------------------------------------- 1 | package components 2 | 3 | import ( 4 | "os" 5 | "path/filepath" 6 | "time" 7 | 8 | "github.com/maxence-charriere/go-app/v10/pkg/app" 9 | ) 10 | 11 | type FileGrid struct { 12 | app.Compo 13 | 14 | Index []os.FileInfo 15 | 16 | SelectedPath string 17 | SetSelectedPath func(string) 18 | 19 | CurrentPath string 20 | SetCurrentPath func(string) 21 | 22 | Standalone bool 23 | 24 | hasInitiatedClick bool 25 | } 26 | 27 | func (c *FileGrid) Render() app.UI { 28 | return app.Div(). 29 | Class(func() string { 30 | classes := "pf-v6-l-grid pf-m-gutter" 31 | if c.Standalone { 32 | classes += " pf-m-all-4-col-on-md pf-m-all-3-col-on-xl pf-v6-u-py-md" 33 | } else { 34 | classes += " pf-m-all-4-col-on-sm pf-m-all-4-col-on-md pf-m-all-3-col-on-lg pf-m-all-3-col-on-xl" 35 | } 36 | 37 | return classes 38 | }()). 39 | Body( 40 | app.Range(c.Index).Slice(func(i int) app.UI { 41 | selectCard := func() { 42 | newSelectedPath := filepath.Join(c.CurrentPath, c.Index[i].Name()) 43 | if c.SelectedPath == newSelectedPath { 44 | // Handle double click 45 | if c.hasInitiatedClick && c.Index[i].IsDir() { 46 | c.SetCurrentPath(filepath.Join(c.CurrentPath, c.Index[i].Name())) 47 | 48 | c.SetSelectedPath("") 49 | 50 | return 51 | } 52 | 53 | newSelectedPath = "" 54 | } 55 | 56 | c.SetSelectedPath(newSelectedPath) 57 | 58 | // Prepare for double click 59 | c.hasInitiatedClick = true 60 | time.AfterFunc(time.Second, func() { 61 | c.hasInitiatedClick = false 62 | }) 63 | } 64 | 65 | return app.Div(). 66 | Class("pf-v6-l-grid__item pf-v6-u-text-align-center"). 67 | Body( 68 | app.Div(). 69 | Class( 70 | func() string { 71 | classes := "pf-v6-c-card pf-m-plain pf-m-selectable" 72 | if c.SelectedPath == filepath.Join(c.CurrentPath, c.Index[i].Name()) { 73 | classes += " pf-m-selected" 74 | } 75 | 76 | return classes 77 | }()). 78 | On("keyup", func(ctx app.Context, e app.Event) { 79 | if e.Get("key").String() == "Enter" || e.Get("key").String() == " " { 80 | selectCard() 81 | } 82 | }). 83 | OnClick(func(ctx app.Context, e app.Event) { 84 | selectCard() 85 | }). 86 | Aria("role", "button"). 87 | TabIndex(0). 88 | Body( 89 | app.Div(). 90 | Class("pf-v6-c-card__body"). 91 | Body( 92 | app.I(). 93 | Class(func() string { 94 | classes := "fas pf-v6-u-font-size-3xl" 95 | if c.Index[i].IsDir() { 96 | classes += " fa-folder" 97 | } else { 98 | classes += " fa-file-alt" 99 | } 100 | 101 | return classes 102 | }()). 103 | Aria("hidden", true), 104 | ), 105 | app.Div(). 106 | Class("pf-v6-c-card__footer"). 107 | Body( 108 | app.Text(c.Index[i].Name()), 109 | ), 110 | ), 111 | ) 112 | }), 113 | ) 114 | } 115 | -------------------------------------------------------------------------------- /pkg/components/form_group.go: -------------------------------------------------------------------------------- 1 | package components 2 | 3 | import "github.com/maxence-charriere/go-app/v10/pkg/app" 4 | 5 | type FormGroup struct { 6 | app.Compo 7 | 8 | Required bool 9 | Label app.UI 10 | Input app.UI 11 | NoTopPadding bool 12 | NoControlWrapper bool 13 | } 14 | 15 | func (c *FormGroup) Render() app.UI { 16 | return app.Div(). 17 | Class("pf-v6-c-form__group"). 18 | Body( 19 | app.Div(). 20 | Class(func() string { 21 | classes := "pf-v6-c-form__group-label" 22 | if c.NoTopPadding { 23 | classes += " pf-m-no-padding-top" 24 | } 25 | 26 | return classes 27 | }()). 28 | Body( 29 | c.Label, 30 | app.If(c.Required, 31 | func() app.UI { 32 | return app.Span(). 33 | Class("pf-v6-c-form__label-required"). 34 | Aria("hidden", true). 35 | Text("*") 36 | }, 37 | ), 38 | ), 39 | app.Div(). 40 | Class("pf-v6-c-form__group-control"). 41 | Body( 42 | app.If( 43 | c.NoControlWrapper, 44 | func() app.UI { 45 | return c.Input 46 | }, 47 | ).Else( 48 | func() app.UI { 49 | return app. 50 | Span(). 51 | Class("pf-v6-c-form-control"). 52 | Body( 53 | c.Input, 54 | ) 55 | }), 56 | ), 57 | ) 58 | } 59 | -------------------------------------------------------------------------------- /pkg/components/home.go: -------------------------------------------------------------------------------- 1 | package components 2 | 3 | import ( 4 | "github.com/maxence-charriere/go-app/v10/pkg/app" 5 | "github.com/pojntfx/bofied/pkg/providers" 6 | ) 7 | 8 | type Home struct { 9 | app.Compo 10 | 11 | removeEventListener func() 12 | } 13 | 14 | func (c *Home) Render() app.UI { 15 | return &providers.SetupProvider{ 16 | StoragePrefix: "bofied.configuration", 17 | StateQueryParameter: "state", 18 | CodeQueryParameter: "code", 19 | Children: func(cpcp providers.SetupProviderChildrenProps) app.UI { 20 | // This div is required so that there are no authorization loops 21 | return app.Div(). 22 | Class("pf-v6-x-ws-router"). 23 | Body( 24 | app.If(cpcp.Ready, 25 | func() app.UI { 26 | // Identity provider 27 | return &providers.IdentityProvider{ 28 | Issuer: cpcp.OIDCIssuer, 29 | ClientID: cpcp.OIDCClientID, 30 | RedirectURL: cpcp.OIDCRedirectURL, 31 | HomeURL: "/", 32 | Scopes: []string{"profile", "email"}, 33 | StoragePrefix: "bofied.identity", 34 | Children: func(ipcp providers.IdentityProviderChildrenProps) app.UI { 35 | // Configuration shell 36 | if ipcp.Error != nil { 37 | return &SetupShell{ 38 | LogoDarkSrc: "/web/logo-dark.png", 39 | LogoDarkAlt: "bofied Logo (dark variant)", 40 | 41 | LogoLightSrc: "/web/logo-light.png", 42 | LogoLightAlt: "bofied Logo (light variant)", 43 | 44 | Title: "Log in to bofied", 45 | ShortDescription: "Modern network boot server.", 46 | LongDescription: `bofied is a network boot server. It provides everything you need to PXE boot a node, from a (proxy)DHCP server for PXE service to a TFTP and HTTP server to serve boot files.`, 47 | HelpLink: "https://github.com/pojntfx/bofied#tutorial", 48 | Links: map[string]string{ 49 | "License": "https://github.com/pojntfx/bofied/blob/main/LICENSE", 50 | "Source Code": "https://github.com/pojntfx/bofied", 51 | "Documentation": "https://github.com/pojntfx/bofied#tutorial", 52 | }, 53 | 54 | BackendURL: cpcp.BackendURL, 55 | OIDCIssuer: cpcp.OIDCIssuer, 56 | OIDCClientID: cpcp.OIDCClientID, 57 | OIDCRedirectURL: cpcp.OIDCRedirectURL, 58 | 59 | SetBackendURL: cpcp.SetBackendURL, 60 | SetOIDCIssuer: cpcp.SetOIDCIssuer, 61 | SetOIDCClientID: cpcp.SetOIDCClientID, 62 | SetOIDCRedirectURL: cpcp.SetOIDCRedirectURL, 63 | ApplyConfig: cpcp.ApplyConfig, 64 | 65 | Error: ipcp.Error, 66 | } 67 | } 68 | 69 | // Configuration placeholder 70 | if ipcp.IDToken == "" || ipcp.UserInfo.Email == "" { 71 | return app.P().Text("Authorizing ...") 72 | } 73 | 74 | // Data provider 75 | return &providers.DataProvider{ 76 | BackendURL: cpcp.BackendURL, 77 | IDToken: ipcp.IDToken, 78 | Children: func(dpcp providers.DataProviderChildrenProps) app.UI { 79 | // Data shell 80 | return &DataShell{ 81 | // Config file editor 82 | ConfigFile: dpcp.ConfigFile, 83 | SetConfigFile: dpcp.SetConfigFile, 84 | 85 | FormatConfigFile: dpcp.FormatConfigFile, 86 | RefreshConfigFile: dpcp.RefreshConfigFile, 87 | SaveConfigFile: dpcp.SaveConfigFile, 88 | 89 | ConfigFileError: dpcp.ConfigFileError, 90 | IgnoreConfigFileError: dpcp.IgnoreConfigFileError, 91 | 92 | // File explorer 93 | CurrentPath: dpcp.CurrentPath, 94 | SetCurrentPath: dpcp.SetCurrentPath, 95 | 96 | Index: dpcp.Index, 97 | RefreshIndex: dpcp.RefreshIndex, 98 | WriteToPath: dpcp.WriteToPath, 99 | 100 | HTTPShareLink: dpcp.HTTPShareLink, 101 | TFTPShareLink: dpcp.TFTPShareLink, 102 | SharePath: dpcp.SharePath, 103 | 104 | CreatePath: dpcp.CreatePath, 105 | CreateEmptyFile: dpcp.CreateEmptyFile, 106 | DeletePath: dpcp.DeletePath, 107 | MovePath: dpcp.MovePath, 108 | CopyPath: dpcp.CopyPath, 109 | 110 | EditPathContents: dpcp.EditPathContents, 111 | SetEditPathContents: dpcp.SetEditPathContents, 112 | EditPath: dpcp.EditPath, 113 | 114 | WebDAVAddress: dpcp.WebDAVAddress, 115 | WebDAVUsername: dpcp.WebDAVUsername, 116 | WebDAVPassword: dpcp.WebDAVPassword, 117 | 118 | OperationIndex: dpcp.OperationIndex, 119 | 120 | OperationCurrentPath: dpcp.OperationCurrentPath, 121 | OperationSetCurrentPath: dpcp.OperationSetCurrentPath, 122 | 123 | FileExplorerError: dpcp.FileExplorerError, 124 | RecoverFileExplorerError: dpcp.RecoverFileExplorerError, 125 | IgnoreFileExplorerError: dpcp.IgnoreFileExplorerError, 126 | 127 | Events: dpcp.Events, 128 | 129 | EventsError: dpcp.EventsError, 130 | RecoverEventsError: dpcp.RecoverEventsError, 131 | IgnoreEventsError: dpcp.IgnoreEventsError, 132 | 133 | UserInfo: ipcp.UserInfo, 134 | Logout: ipcp.Logout, 135 | 136 | // Metadata 137 | UseAdvertisedIP: dpcp.UseAdvertisedIP, 138 | SetUseAdvertisedIP: dpcp.SetUseAdvertisedIP, 139 | 140 | UseAdvertisedIPForWebDAV: dpcp.UseAdvertisedIPForWebDAV, 141 | SetUseAdvertisedIPForWebDAV: dpcp.SetUseAdvertisedIPForWebDAV, 142 | 143 | SetUseHTTPS: dpcp.SetUseHTTPS, 144 | SetUseDavs: dpcp.SetUseDavs, 145 | } 146 | }, 147 | } 148 | }, 149 | } 150 | }, 151 | ).Else( 152 | func() app.UI { 153 | // Configuration shell 154 | return &SetupShell{ 155 | LogoDarkSrc: "/web/logo-dark.png", 156 | LogoDarkAlt: "bofied Logo (dark variant)", 157 | 158 | LogoLightSrc: "/web/logo-light.png", 159 | LogoLightAlt: "bofied Logo (light variant)", 160 | 161 | Title: "Log in to bofied", 162 | ShortDescription: "Modern network boot server.", 163 | LongDescription: `bofied is a network boot server. It provides everything you need to PXE boot a node, from a (proxy)DHCP server for PXE service to a TFTP and HTTP server to serve boot files.`, 164 | HelpLink: "https://github.com/pojntfx/bofied#tutorial", 165 | Links: map[string]string{ 166 | "License": "https://github.com/pojntfx/bofied/blob/main/LICENSE", 167 | "Source Code": "https://github.com/pojntfx/bofied", 168 | "Documentation": "https://github.com/pojntfx/bofied#tutorial", 169 | }, 170 | 171 | BackendURL: cpcp.BackendURL, 172 | OIDCIssuer: cpcp.OIDCIssuer, 173 | OIDCClientID: cpcp.OIDCClientID, 174 | OIDCRedirectURL: cpcp.OIDCRedirectURL, 175 | 176 | SetBackendURL: cpcp.SetBackendURL, 177 | SetOIDCIssuer: cpcp.SetOIDCIssuer, 178 | SetOIDCClientID: cpcp.SetOIDCClientID, 179 | SetOIDCRedirectURL: cpcp.SetOIDCRedirectURL, 180 | ApplyConfig: cpcp.ApplyConfig, 181 | 182 | Error: cpcp.Error, 183 | } 184 | }, 185 | ), 186 | ) 187 | }, 188 | } 189 | } 190 | 191 | func (c *Home) OnMount(ctx app.Context) { 192 | darkModeMediaQuery := app.Window().Call("matchMedia", "(prefers-color-scheme: dark)") 193 | 194 | updateTheme := func() { 195 | app. 196 | Window(). 197 | Get("document"). 198 | Get("documentElement"). 199 | Get("classList"). 200 | Call("toggle", "pf-v6-theme-dark", darkModeMediaQuery.Get("matches")) 201 | } 202 | 203 | updateThemeEventListener := app.FuncOf(func(this app.Value, args []app.Value) any { 204 | updateTheme() 205 | 206 | return nil 207 | }) 208 | 209 | darkModeMediaQuery.Call( 210 | "addEventListener", 211 | "change", 212 | updateThemeEventListener, 213 | ) 214 | 215 | c.removeEventListener = func() { 216 | darkModeMediaQuery.Call( 217 | "removeEventListener", 218 | "change", 219 | updateThemeEventListener, 220 | ) 221 | } 222 | 223 | updateTheme() 224 | } 225 | 226 | func (c *Home) OnDismount() { 227 | if c.removeEventListener != nil { 228 | c.removeEventListener() 229 | } 230 | } 231 | -------------------------------------------------------------------------------- /pkg/components/modal.go: -------------------------------------------------------------------------------- 1 | package components 2 | 3 | import ( 4 | "github.com/maxence-charriere/go-app/v10/pkg/app" 5 | ) 6 | 7 | type Modal struct { 8 | app.Compo 9 | 10 | Open bool 11 | Close func() 12 | 13 | ID string 14 | Classes string 15 | 16 | Title string 17 | Body []app.UI 18 | Footer []app.UI 19 | 20 | Large bool 21 | PaddedBottom bool 22 | Overlay bool 23 | 24 | Nested bool 25 | } 26 | 27 | func (c *Modal) Render() app.UI { 28 | return app.Div(). 29 | Class(func() string { 30 | classes := "pf-v6-c-backdrop" 31 | 32 | if c.Classes != "" { 33 | classes += " " + c.Classes 34 | } 35 | 36 | if !c.Open { 37 | classes += " pf-v6-u-display-none" 38 | } 39 | 40 | if c.Overlay { 41 | classes += " pf-v6-x-m-modal-overlay" 42 | } 43 | 44 | if c.Nested { 45 | classes += " pf-v6-x-c-backdrop--nested" 46 | } 47 | 48 | return classes 49 | }()). 50 | Body( 51 | app.Div(). 52 | Class("pf-v6-l-bullseye"). 53 | Body( 54 | app.Div(). 55 | Class(func() string { 56 | classes := "pf-v6-c-modal-box" 57 | if c.Large { 58 | classes += " pf-m-lg" 59 | } else { 60 | classes += " pf-m-sm" 61 | } 62 | 63 | return classes 64 | }()). 65 | Aria("modal", true). 66 | Aria("labelledby", c.ID). 67 | Body( 68 | app.Div(). 69 | Class("pf-v6-c-modal-box__close"). 70 | Body( 71 | app.Button(). 72 | Class("pf-v6-c-button pf-m-plain"). 73 | Type("button"). 74 | Aria("label", "Close dialog"). 75 | OnClick(func(ctx app.Context, e app.Event) { 76 | c.Close() 77 | }). 78 | Body( 79 | app.Span(). 80 | Class("pf-v6-c-button__icon"). 81 | Body( 82 | app.I(). 83 | Class("fas fa-times"). 84 | Aria("hidden", true), 85 | ), 86 | ), 87 | ), 88 | app.Header(). 89 | Class("pf-v6-c-modal-box__header"). 90 | Body( 91 | app.H1(). 92 | Class("pf-v6-c-modal-box__title"). 93 | ID(c.ID). 94 | Text(c.Title), 95 | ), 96 | app.Div(). 97 | Class(func() string { 98 | classes := "pf-v6-c-modal-box__body" 99 | if c.PaddedBottom { 100 | classes += " pf-v6-u-pb-md" 101 | } 102 | 103 | return classes 104 | }()). 105 | Body(c.Body...), 106 | app.If( 107 | c.Footer != nil, 108 | func() app.UI { 109 | return app.Footer(). 110 | Class("pf-v6-c-modal-box__footer"). 111 | Body(c.Footer...) 112 | }, 113 | ), 114 | ), 115 | ), 116 | ) 117 | } 118 | 119 | func (c *Modal) OnMount(ctx app.Context) { 120 | app.Window().Call("addEventListener", "keyup", app.FuncOf(func(this app.Value, args []app.Value) any { 121 | ctx.Async(func() { 122 | if len(args) > 0 && args[0].Get("key").String() == "Escape" { 123 | c.Close() 124 | 125 | ctx.Update() 126 | } 127 | }) 128 | 129 | return nil 130 | })) 131 | } 132 | -------------------------------------------------------------------------------- /pkg/components/navbar.go: -------------------------------------------------------------------------------- 1 | package components 2 | 3 | import ( 4 | "crypto/md5" 5 | "encoding/hex" 6 | "fmt" 7 | 8 | "github.com/maxence-charriere/go-app/v10/pkg/app" 9 | ) 10 | 11 | type Navbar struct { 12 | app.Compo 13 | 14 | NotificationsDrawerOpen bool 15 | ToggleNotificationsDrawerOpen func() 16 | 17 | ToggleAbout func() 18 | 19 | OverflowMenuExpanded bool 20 | ToggleOverflowMenuExpanded func() 21 | 22 | UserMenuExpanded bool 23 | ToggleUserMenuExpanded func() 24 | 25 | UserEmail string 26 | Logout func(app.Context) 27 | } 28 | 29 | func (c *Navbar) Render() app.UI { 30 | // Get the MD5 hash for the user's gravatar 31 | avatarHash := md5.Sum([]byte(c.UserEmail)) 32 | 33 | return app.Header(). 34 | Class("pf-v6-c-masthead pf-m-display-inline"). 35 | Body( 36 | app.Div(). 37 | Class("pf-v6-c-masthead__main"). 38 | Body( 39 | app.Div(). 40 | Class("pf-v6-c-masthead__brand"). 41 | Body( 42 | app.Img(). 43 | Class("pf-v6-c-brand pf-v6-x-c-brand--nav pf-v6-c-brand--dark"). 44 | Src("/web/logo-dark.png"). 45 | Alt("bofied Logo (dark variant)"), 46 | 47 | app.Img(). 48 | Class("pf-v6-c-brand pf-v6-x-c-brand--nav pf-v6-c-brand--light"). 49 | Src("/web/logo-light.png"). 50 | Alt("bofied Logo (light variant)"), 51 | ), 52 | ), 53 | app.Div(). 54 | Class("pf-v6-c-masthead__content"). 55 | Body( 56 | app.Div(). 57 | Class("pf-v6-c-toolbar"). 58 | Body( 59 | app.Div(). 60 | Class("pf-v6-c-toolbar__content"). 61 | Body( 62 | app.Div(). 63 | Class("pf-v6-c-toolbar__content-section pf-v6-u-align-items-center"). 64 | Body( 65 | app.Div(). 66 | Class("pf-v6-c-toolbar__group pf-m-align-end pf-v6-u-align-items-center"). 67 | Body( 68 | app.Div(). 69 | Class(func() string { 70 | classes := "pf-v6-c-toolbar__item" 71 | 72 | if c.NotificationsDrawerOpen { 73 | classes += " pf-m-selected" 74 | } 75 | 76 | return classes 77 | }()). 78 | Body( 79 | app.Button(). 80 | Class( 81 | func() string { 82 | classes := "pf-v6-c-button pf-m-plain" 83 | 84 | if c.NotificationsDrawerOpen { 85 | classes += " pf-m-read pf-m-stateful pf-m-clicked pf-m-expanded" 86 | } 87 | 88 | return classes 89 | }()). 90 | Type("button"). 91 | Aria("label", "Unread notifications"). 92 | Aria("expanded", c.NotificationsDrawerOpen). 93 | OnClick(func(ctx app.Context, e app.Event) { 94 | c.ToggleNotificationsDrawerOpen() 95 | }). 96 | Body( 97 | app.Span(). 98 | Class("pf-v6-c-button__icon"). 99 | Body( 100 | app.Span(). 101 | Class("pf-v6-c-button__icon"). 102 | Body( 103 | app.I(). 104 | Class("fas fa-bell"). 105 | Aria("hidden", true), 106 | ), 107 | ), 108 | ), 109 | ), 110 | app.Div().Class("pf-v6-c-toolbar__item"). 111 | Body( 112 | app.Div(). 113 | Class(func() string { 114 | classes := "pf-v6-c-dropdown" 115 | 116 | if c.OverflowMenuExpanded { 117 | classes += " pf-m-expanded" 118 | } 119 | 120 | return classes 121 | }()). 122 | Body( 123 | app.Button(). 124 | Class("pf-v6-c-menu-toggle pf-m-plain"). 125 | Type("button"). 126 | Aria("expanded", c.OverflowMenuExpanded). 127 | Aria("label", "Actions"). 128 | Body( 129 | app.Span(). 130 | Class("pf-v6-c-menu-toggle__text pf-v6-u-display-flex pf-v6-u-display-block-on-md"). 131 | Body( 132 | app.Img(). 133 | Src(fmt.Sprintf("https://www.gravatar.com/avatar/%v?s=150", hex.EncodeToString(avatarHash[:]))). 134 | Alt("Avatar image of user with email "+c.UserEmail). 135 | Class("pf-v6-c-avatar pf-m-sm pf-v6-u-display-none-on-md"), 136 | app.I(). 137 | Class("fas fa-ellipsis-v pf-v6-u-display-none pf-v6-u-display-inline-block-on-md pf-v6-u-display-none-on-lg"). 138 | Aria("hidden", true), 139 | app.I(). 140 | Class("fas fa-question-circle pf-v6-u-display-none pf-v6-u-display-inline-block-on-lg"). 141 | Aria("hidden", true), 142 | ), 143 | ). 144 | OnClick(func(ctx app.Context, e app.Event) { 145 | c.ToggleOverflowMenuExpanded() 146 | }), 147 | 148 | app.Div(). 149 | Class("pf-v6-c-menu pf-v6-x-u-position-absolute"). 150 | Hidden(!c.OverflowMenuExpanded). 151 | Body( 152 | app.Div(). 153 | Class("pf-v6-c-menu__content"). 154 | Body( 155 | app.Ul(). 156 | Role("menu"). 157 | Class("pf-v6-c-menu__list"). 158 | Body( 159 | app.Li(). 160 | Class("pf-v6-c-menu__list-item"). 161 | Role("none"). 162 | Body( 163 | app.Button(). 164 | Class("pf-v6-c-menu__item"). 165 | Type("button"). 166 | Aria("role", "menuitem"). 167 | Body( 168 | app.Span(). 169 | Class("pf-v6-c-menu__item-main"). 170 | Body( 171 | app.Span(). 172 | Class("pf-v6-c-menu__item-text"). 173 | Text("About"), 174 | ), 175 | ). 176 | OnClick(func(ctx app.Context, e app.Event) { 177 | c.ToggleAbout() 178 | }), 179 | ), 180 | app.Li(). 181 | Class("pf-v6-c-menu__list-item"). 182 | Role("none"). 183 | Body( 184 | app.A(). 185 | Class("pf-v6-c-menu__item"). 186 | Target("_blank"). 187 | Href("https://github.com/pojntfx/bofied#tutorial"). 188 | Aria("role", "menuitem"). 189 | Body( 190 | app.Span(). 191 | Class("pf-v6-c-menu__item-main"). 192 | Body( 193 | app.Span(). 194 | Class("pf-v6-c-menu__item-text"). 195 | Text("Documentation"), 196 | ), 197 | ), 198 | ), 199 | app.Li(). 200 | Class("pf-v6-c-divider pf-v6-u-display-inherit pf-v6-u-display-none-on-md"). 201 | Role("separator"), 202 | app.Li(). 203 | Class("pf-v6-c-menu__list-item pf-v6-u-display-inherit pf-v6-u-display-none-on-md"). 204 | Role("none"). 205 | Body( 206 | app.Button(). 207 | Class("pf-v6-c-menu__item"). 208 | Type("button"). 209 | Aria("role", "menuitem"). 210 | Body( 211 | app.Span(). 212 | Class("pf-v6-c-menu__item-main"). 213 | Body( 214 | app.Span(). 215 | Class("pf-v6-c-menu__item-icon"). 216 | Body( 217 | app.I(). 218 | Class("fas fa-sign-out-alt"). 219 | Aria("hidden", true), 220 | ), 221 | app.Span(). 222 | Class("pf-v6-c-menu__item-text"). 223 | Text("Logout"), 224 | ), 225 | ). 226 | OnClick(func(ctx app.Context, e app.Event) { 227 | c.Logout(ctx) 228 | }), 229 | ), 230 | ), 231 | ), 232 | ), 233 | ), 234 | ), 235 | app.Div().Class("pf-v6-c-toolbar__item pf-m-hidden pf-m-visible-on-md"). 236 | Body( 237 | app.Div(). 238 | Class(func() string { 239 | classes := "pf-v6-c-dropdown" 240 | 241 | if c.UserMenuExpanded { 242 | classes += " pf-m-expanded" 243 | } 244 | 245 | return classes 246 | }()). 247 | Body( 248 | app.Button(). 249 | Class("pf-v6-c-menu-toggle pf-m-plain"). 250 | Type("button"). 251 | Aria("expanded", c.UserMenuExpanded). 252 | Aria("label", "User actions"). 253 | Body( 254 | app.Span(). 255 | Class("pf-v6-c-menu-toggle__icon pf-v6-u-display-flex"). 256 | Body( 257 | app.Img(). 258 | Src(fmt.Sprintf("https://www.gravatar.com/avatar/%v?s=150", hex.EncodeToString(avatarHash[:]))). 259 | Alt("Avatar image of user with email "+c.UserEmail). 260 | Class("pf-v6-c-avatar pf-m-sm"), 261 | ), 262 | app.Span(). 263 | Class("pf-v6-c-menu-toggle__text"). 264 | Text(c.UserEmail), 265 | app.Span(). 266 | Class("pf-v6-c-menu-toggle__controls"). 267 | Body( 268 | app.Span(). 269 | Class("pf-v6-c-menu-toggle__toggle-icon"). 270 | Body( 271 | app.I(). 272 | Class("fas fa-caret-down"). 273 | Aria("hidden", true), 274 | ), 275 | ), 276 | ). 277 | OnClick(func(ctx app.Context, e app.Event) { 278 | c.ToggleUserMenuExpanded() 279 | }), 280 | 281 | app.Div(). 282 | Class("pf-v6-c-menu pf-v6-x-u-position-absolute"). 283 | Hidden(!c.UserMenuExpanded). 284 | Body( 285 | app.Div(). 286 | Class("pf-v6-c-menu__content"). 287 | Body( 288 | app.Ul(). 289 | Role("menu"). 290 | Class("pf-v6-c-menu__list"). 291 | Body( 292 | app.Li(). 293 | Class("pf-v6-c-menu__list-item"). 294 | Role("none"). 295 | Body( 296 | app.Button(). 297 | Class("pf-v6-c-menu__item"). 298 | Type("button"). 299 | Aria("role", "menuitem"). 300 | Body( 301 | app.Span(). 302 | Class("pf-v6-c-menu__item-main"). 303 | Body( 304 | app.Span(). 305 | Class("pf-v6-c-menu__item-icon"). 306 | Body( 307 | app.I(). 308 | Class("fas fa-sign-out-alt"). 309 | Aria("hidden", true), 310 | ), 311 | app.Span(). 312 | Class("pf-v6-c-menu__item-text"). 313 | Text("Logout"), 314 | ), 315 | ). 316 | OnClick(func(ctx app.Context, e app.Event) { 317 | c.Logout(ctx) 318 | }), 319 | ), 320 | ), 321 | ), 322 | ), 323 | ), 324 | ), 325 | ), 326 | ), 327 | ), 328 | ), 329 | ), 330 | ) 331 | } 332 | -------------------------------------------------------------------------------- /pkg/components/notification_drawer.go: -------------------------------------------------------------------------------- 1 | package components 2 | 3 | import "github.com/maxence-charriere/go-app/v10/pkg/app" 4 | 5 | type Notification struct { 6 | Message string 7 | CreatedAt string 8 | } 9 | 10 | type NotificationDrawer struct { 11 | app.Compo 12 | 13 | Notifications []Notification 14 | EmptyState app.UI 15 | } 16 | 17 | func (c *NotificationDrawer) Render() app.UI { 18 | return app.Div(). 19 | Class("pf-v6-c-notification-drawer"). 20 | Body( 21 | app.Div(). 22 | Class("pf-v6-c-notification-drawer__header"). 23 | Body( 24 | app.H1(). 25 | Class("pf-v6-c-notification-drawer__header-title"). 26 | Text("Events"), 27 | ), 28 | app.Div().Class("pf-v6-c-notification-drawer__body").Body( 29 | app.If( 30 | len(c.Notifications) > 0, 31 | func() app.UI { 32 | return app.Ul().Class("pf-v6-c-notification-drawer__list").Body( 33 | app.Range(c.Notifications).Slice(func(i int) app.UI { 34 | return app.Li().Class("pf-v6-c-notification-drawer__list-item pf-m-read pf-m-info").Body( 35 | app.Div().Class("pf-v6-c-notification-drawer__list-item-description").Text( 36 | c.Notifications[len(c.Notifications)-1-i].Message, 37 | ), 38 | app.Div().Class("pf-v6-c-notification-drawer__list-item-timestamp").Text( 39 | c.Notifications[len(c.Notifications)-1-i].CreatedAt, 40 | ), 41 | ) 42 | }), 43 | ) 44 | }, 45 | ).Else(func() app.UI { 46 | return c.EmptyState 47 | }), 48 | ), 49 | ) 50 | } 51 | -------------------------------------------------------------------------------- /pkg/components/path_picker_toolbar.go: -------------------------------------------------------------------------------- 1 | package components 2 | 3 | import ( 4 | "os" 5 | 6 | "github.com/maxence-charriere/go-app/v10/pkg/app" 7 | ) 8 | 9 | type PathPickerToolbar struct { 10 | app.Compo 11 | 12 | Index []os.FileInfo 13 | RefreshIndex func() 14 | 15 | PathComponents []string 16 | 17 | CurrentPath string 18 | SetCurrentPath func(string) 19 | 20 | SelectedPath string 21 | SetSelectedPath func(string) 22 | 23 | OpenCreateDirectoryModal func() 24 | } 25 | 26 | func (c *PathPickerToolbar) Render() app.UI { 27 | return app.Div(). 28 | Class("pf-v6-c-toolbar pf-v6-u-py-0"). 29 | Body( 30 | app.Div(). 31 | Class("pf-v6-c-toolbar__content pf-v6-x-m-gap-md"). 32 | Body( 33 | app.Div(). 34 | Class("pf-v6-c-toolbar__content-section pf-v6-u-align-items-center"). 35 | Body( 36 | app.Div(). 37 | Class("pf-v6-c-toolbar__item pf-m-overflow-menu"). 38 | Body( 39 | app.Div(). 40 | Class("pf-v6-c-overflow-menu"). 41 | Body( 42 | app.Div(). 43 | Class("pf-v6-c-overflow-menu__content"). 44 | Body( 45 | app.Div(). 46 | Class("pf-v6-c-overflow-menu__group pf-m-button-group"). 47 | Body( 48 | app.Div(). 49 | Class("pf-v6-c-overflow-menu__item"). 50 | Body( 51 | &Breadcrumbs{ 52 | PathComponents: c.PathComponents, 53 | 54 | CurrentPath: c.CurrentPath, 55 | SetCurrentPath: c.SetCurrentPath, 56 | 57 | SelectedPath: c.SelectedPath, 58 | SetSelectedPath: func(s string) { 59 | c.SetSelectedPath(s) 60 | }, 61 | }, 62 | ), 63 | ), 64 | ), 65 | ), 66 | ), 67 | app.Div(). 68 | Class("pf-v6-c-toolbar__item pf-m-pagination"). 69 | Body( 70 | app.Div(). 71 | Class("pf-v6-c-pagination pf-m-compact"). 72 | Body( 73 | app.Div(). 74 | Class("pf-v6-c-pagination pf-m-compact pf-m-compact"). 75 | Body( 76 | app.Div(). 77 | Class("pf-v6-c-overflow-menu"). 78 | Body( 79 | app.Div(). 80 | Class("pf-v6-c-overflow-menu__content"). 81 | Body( 82 | app.Div(). 83 | Class("pf-v6-c-overflow-menu__group pf-m-button-group"). 84 | Body( 85 | app.Div(). 86 | Class("pf-v6-c-overflow-menu__item"). 87 | Body( 88 | app.Button(). 89 | Type("button"). 90 | Aria("label", "Create directory"). 91 | Title("Create directory"). 92 | Class("pf-v6-c-button pf-m-plain"). 93 | OnClick(func(ctx app.Context, e app.Event) { 94 | c.OpenCreateDirectoryModal() 95 | }). 96 | Body( 97 | app.Span(). 98 | Class("pf-v6-c-button__icon"). 99 | Body( 100 | app.I(). 101 | Class("fas fa-folder-plus"). 102 | Aria("hidden", true), 103 | ), 104 | ), 105 | ), 106 | ), 107 | ), 108 | app.Div(). 109 | Class("pf-v6-c-divider pf-m-vertical pf-m-inset-md"). 110 | Aria("role", "separator"), 111 | app.Div(). 112 | Class("pf-v6-c-overflow-menu__group pf-m-button-group"). 113 | Body( 114 | app.Div(). 115 | Class("pf-v6-c-overflow-menu__item"). 116 | Body( 117 | app.Button(). 118 | Type("button"). 119 | Aria("label", "Refresh"). 120 | Title("Refresh"). 121 | Class("pf-v6-c-button pf-m-plain"). 122 | OnClick(func(ctx app.Context, e app.Event) { 123 | c.RefreshIndex() 124 | }). 125 | Body( 126 | app.Span(). 127 | Class("pf-v6-c-button__icon"). 128 | Body( 129 | app.I(). 130 | Class("fas fas fa-sync"). 131 | Aria("hidden", true), 132 | ), 133 | ), 134 | ), 135 | ), 136 | ), 137 | ), 138 | ), 139 | ), 140 | ), 141 | ), 142 | ) 143 | } 144 | -------------------------------------------------------------------------------- /pkg/components/setup_form.go: -------------------------------------------------------------------------------- 1 | package components 2 | 3 | import "github.com/maxence-charriere/go-app/v10/pkg/app" 4 | 5 | type SetupForm struct { 6 | app.Compo 7 | 8 | Error error 9 | ErrorMessage string 10 | 11 | BackendURL string 12 | SetBackendURL func(string, app.Context) 13 | 14 | OIDCIssuer string 15 | SetOIDCIssuer func(string, app.Context) 16 | 17 | OIDCClientID string 18 | SetOIDCClientID func(string, app.Context) 19 | 20 | OIDCRedirectURL string 21 | SetOIDCRedirectURL func(string, app.Context) 22 | 23 | Submit func(app.Context) 24 | } 25 | 26 | const ( 27 | // Names and IDs 28 | backendURLName = "backendURLName" 29 | oidcIssuerName = "oidcIssuer" 30 | oidcClientIDName = "oidcClientID" 31 | oidcRedirectURLName = "oidcRedirectURL" 32 | 33 | // Placeholders 34 | backendURLPlaceholder = "http://localhost:15256" 35 | oidcIssuerPlaceholder = "https://pojntfx.eu.auth0.com/" 36 | oidcRedirectURLPlaceholder = "http://localhost:15255/" 37 | ) 38 | 39 | func (c *SetupForm) Render() app.UI { 40 | return app.Form(). 41 | Class("pf-v6-c-form"). 42 | Body( 43 | // Error display 44 | app.If(c.Error != nil, func() app.UI { 45 | return app.P(). 46 | Class("pf-v6-c-form__helper-text pf-m-error"). 47 | Aria("live", "polite"). 48 | Body( 49 | app.Span(). 50 | Class("pf-v6-c-form__helper-text-icon"). 51 | Body( 52 | app.I(). 53 | Class("fas fa-exclamation-circle"). 54 | Aria("hidden", true), 55 | ), 56 | app.Text(c.ErrorMessage), 57 | ) 58 | }, 59 | ), 60 | // Backend URL Input 61 | &FormGroup{ 62 | Label: app. 63 | Label(). 64 | For(backendURLName). 65 | Class("pf-v6-c-form__label"). 66 | Body( 67 | app. 68 | Span(). 69 | Class("pf-v6-c-form__label-text"). 70 | Text("Backend URL"), 71 | ), 72 | Input: app. 73 | Input(). 74 | Name(backendURLName). 75 | ID(backendURLName). 76 | Type("url"). 77 | Required(true). 78 | Placeholder(backendURLPlaceholder). 79 | Class("pf-v6-c-form-control"). 80 | Aria("invalid", c.Error != nil). 81 | Value(c.BackendURL). 82 | OnInput(func(ctx app.Context, e app.Event) { 83 | c.SetBackendURL(ctx.JSSrc().Get("value").String(), ctx) 84 | }), 85 | Required: true, 86 | }, 87 | // OIDC Issuer Input 88 | &FormGroup{ 89 | Label: app. 90 | Label(). 91 | For(oidcIssuerName). 92 | Class("pf-v6-c-form__label"). 93 | Body( 94 | app. 95 | Span(). 96 | Class("pf-v6-c-form__label-text"). 97 | Text("OIDC Issuer"), 98 | ), 99 | Input: app. 100 | Input(). 101 | Name(oidcIssuerName). 102 | ID(oidcIssuerName). 103 | Type("url"). 104 | Required(true). 105 | Placeholder(oidcIssuerPlaceholder). 106 | Class("pf-v6-c-form-control"). 107 | Aria("invalid", c.Error != nil). 108 | Value(c.OIDCIssuer). 109 | OnInput(func(ctx app.Context, e app.Event) { 110 | c.SetOIDCIssuer(ctx.JSSrc().Get("value").String(), ctx) 111 | }), 112 | Required: true, 113 | }, 114 | // OIDC Client ID 115 | &FormGroup{ 116 | Label: app. 117 | Label(). 118 | For(oidcClientIDName). 119 | Class("pf-v6-c-form__label"). 120 | Body( 121 | app. 122 | Span(). 123 | Class("pf-v6-c-form__label-text"). 124 | Text("OIDC Client ID"), 125 | ), 126 | Input: app. 127 | Input(). 128 | Name(oidcClientIDName). 129 | ID(oidcClientIDName). 130 | Type("text"). 131 | Required(true). 132 | Class("pf-v6-c-form-control"). 133 | Aria("invalid", c.Error != nil). 134 | Value(c.OIDCClientID). 135 | OnInput(func(ctx app.Context, e app.Event) { 136 | c.SetOIDCClientID(ctx.JSSrc().Get("value").String(), ctx) 137 | }), 138 | Required: true, 139 | }, 140 | // OIDC Redirect URL 141 | &FormGroup{ 142 | Label: app. 143 | Label(). 144 | For(oidcRedirectURLName). 145 | Class("pf-v6-c-form__label"). 146 | Body( 147 | app. 148 | Span(). 149 | Class("pf-v6-c-form__label-text"). 150 | Text("OIDC Redirect URL"), 151 | ), 152 | Input: app. 153 | Input(). 154 | Name(oidcRedirectURLName). 155 | ID(oidcRedirectURLName). 156 | Type("url"). 157 | Required(true). 158 | Placeholder(oidcRedirectURLPlaceholder). 159 | Class("pf-v6-c-form-control"). 160 | Aria("invalid", c.Error != nil). 161 | Value(c.OIDCRedirectURL). 162 | OnInput(func(ctx app.Context, e app.Event) { 163 | c.SetOIDCRedirectURL(ctx.JSSrc().Get("value").String(), ctx) 164 | }), 165 | Required: true, 166 | }, 167 | // Configuration Apply Trigger 168 | app.Div(). 169 | Class("pf-v6-c-form__group pf-m-action"). 170 | Body( 171 | app. 172 | Button(). 173 | Type("submit"). 174 | Class("pf-v6-c-button pf-m-primary pf-m-block"). 175 | Text("Log in"), 176 | ), 177 | ).OnSubmit(func(ctx app.Context, e app.Event) { 178 | e.PreventDefault() 179 | 180 | c.Submit(ctx) 181 | }) 182 | } 183 | -------------------------------------------------------------------------------- /pkg/components/setup_shell.go: -------------------------------------------------------------------------------- 1 | package components 2 | 3 | import ( 4 | "github.com/maxence-charriere/go-app/v10/pkg/app" 5 | ) 6 | 7 | type SetupShell struct { 8 | app.Compo 9 | 10 | LogoDarkSrc string 11 | LogoDarkAlt string 12 | 13 | LogoLightSrc string 14 | LogoLightAlt string 15 | 16 | Title string 17 | ShortDescription string 18 | LongDescription string 19 | HelpLink string 20 | Links map[string]string 21 | 22 | BackendURL string 23 | OIDCIssuer string 24 | OIDCClientID string 25 | OIDCRedirectURL string 26 | 27 | SetBackendURL, 28 | SetOIDCIssuer, 29 | SetOIDCClientID, 30 | SetOIDCRedirectURL func(string, app.Context) 31 | ApplyConfig func(app.Context) 32 | 33 | Error error 34 | } 35 | 36 | func (c *SetupShell) Render() app.UI { 37 | // Display the error message if error != nil 38 | errorMessage := "" 39 | if c.Error != nil { 40 | errorMessage = c.Error.Error() 41 | } 42 | 43 | return app.Div(). 44 | Class("pf-v6-u-h-100"). 45 | Body( 46 | app.Div(). 47 | Class("pf-v6-c-background-image"). 48 | Body( 49 | app.Raw(` 55 | 56 | 60 | 64 | 68 | 72 | 76 | 77 | 78 | 79 | `), 80 | ), 81 | app.Div(). 82 | Class("pf-v6-c-login"). 83 | Body( 84 | app.Div(). 85 | Class("pf-v6-c-login__container"). 86 | Body( 87 | app.Header(). 88 | Class("pf-v6-c-login__header"). 89 | Body( 90 | app.Img(). 91 | Class("pf-v6-c-brand pf-v6-x-c-brand--main pf-v6-c-brand--dark"). 92 | Src(c.LogoDarkSrc). 93 | Alt(c.LogoDarkAlt), 94 | 95 | app.Img(). 96 | Class("pf-v6-c-brand pf-v6-x-c-brand--main pf-v6-c-brand--light"). 97 | Src(c.LogoLightSrc). 98 | Alt(c.LogoLightAlt), 99 | ), 100 | app.Main(). 101 | Class("pf-v6-c-login__main"). 102 | Body( 103 | app.Header(). 104 | Class("pf-v6-c-login__main-header"). 105 | Body( 106 | app.H1(). 107 | Class("pf-v6-c-title pf-m-3xl"). 108 | Text( 109 | c.Title, 110 | ), 111 | app.P(). 112 | Class("pf-v6-c-login__main-header-desc"). 113 | Text( 114 | c.ShortDescription, 115 | ), 116 | ), 117 | app.Div(). 118 | Class("pf-v6-c-login__main-body"). 119 | Body( 120 | &SetupForm{ 121 | Error: c.Error, 122 | ErrorMessage: errorMessage, 123 | 124 | BackendURL: c.BackendURL, 125 | SetBackendURL: c.SetBackendURL, 126 | 127 | OIDCIssuer: c.OIDCIssuer, 128 | SetOIDCIssuer: c.SetOIDCIssuer, 129 | 130 | OIDCClientID: c.OIDCClientID, 131 | SetOIDCClientID: c.SetOIDCClientID, 132 | 133 | OIDCRedirectURL: c.OIDCRedirectURL, 134 | SetOIDCRedirectURL: c.SetOIDCRedirectURL, 135 | 136 | Submit: c.ApplyConfig, 137 | }, 138 | ), 139 | app.Footer(). 140 | Class("pf-v6-c-login__main-footer"). 141 | Body( 142 | app.Div(). 143 | Class("pf-v6-c-login__main-footer-band"). 144 | Body( 145 | app.P(). 146 | Class("pf-v6-c-login__main-footer-band-item"). 147 | Body( 148 | app.Text("Not sure what to do? "), 149 | app.A(). 150 | Href(c.HelpLink). 151 | Target("_blank"). 152 | Text("Get help."), 153 | ), 154 | ), 155 | ), 156 | ), 157 | app.Footer(). 158 | Class("pf-v6-c-login__footer"). 159 | Body( 160 | app.P(). 161 | Text(c.LongDescription), 162 | app.Ul(). 163 | Class("pf-v6-c-list pf-m-inline"). 164 | Body( 165 | app.Range(c.Links).Map(func(s string) app.UI { 166 | return app.Li().Body( 167 | app.A(). 168 | Target("_blank"). 169 | Href(c.Links[s]). 170 | Text(s), 171 | ) 172 | }), 173 | ), 174 | ), 175 | ), 176 | ), 177 | app.Ul(). 178 | Class("pf-v6-c-alert-group pf-m-toast"). 179 | Body( 180 | &UpdateNotification{ 181 | UpdateTitle: "An update for bofied is available", 182 | 183 | StartUpdateText: "Upgrade now", 184 | IgnoreUpdateText: "Maybe later", 185 | }, 186 | ), 187 | ) 188 | } 189 | -------------------------------------------------------------------------------- /pkg/components/status.go: -------------------------------------------------------------------------------- 1 | package components 2 | 3 | import "github.com/maxence-charriere/go-app/v10/pkg/app" 4 | 5 | type Status struct { 6 | app.Compo 7 | 8 | Error error 9 | ErrorText string 10 | Recover func(app.Context) 11 | RecoverText string 12 | Ignore func() 13 | } 14 | 15 | func (c *Status) Render() app.UI { 16 | // Display the error message if error != nil 17 | errorMessage := "" 18 | if c.Error != nil { 19 | errorMessage = c.Error.Error() 20 | } 21 | 22 | return app.If(c.Error != nil, func() app.UI { 23 | return app.Div(). 24 | Class("pf-v6-c-alert pf-m-danger"). 25 | Aria("label", c.ErrorText). 26 | Body( 27 | app.Div(). 28 | Class("pf-v6-c-alert__icon"). 29 | Body( 30 | app.I(). 31 | Class("fas fa-fw fa-exclamation-circle"). 32 | Aria("hidden", true), 33 | ), 34 | app.P(). 35 | Class("pf-v6-c-alert__title"). 36 | Body( 37 | app.Strong().Body( 38 | app.Span(). 39 | Class("pf-screen-reader"). 40 | Text(c.ErrorText), 41 | ), 42 | app.Text(c.ErrorText), 43 | ), 44 | app.Div(). 45 | Class("pf-v6-c-alert__action"). 46 | Body( 47 | app.Button(). 48 | Class("pf-v6-c-button pf-m-plain"). 49 | Aria("label", "Ignore error"). 50 | OnClick(func(ctx app.Context, e app.Event) { 51 | c.Ignore() 52 | }). 53 | Body( 54 | app.Span(). 55 | Class("pf-v6-c-button__icon"). 56 | Body( 57 | app.I(). 58 | Class("fas fa-times"). 59 | Aria("hidden", true), 60 | ), 61 | ), 62 | ), 63 | app.Div(). 64 | Class("pf-v6-c-alert__description"). 65 | Body( 66 | app.P().Body( 67 | app.Code(). 68 | Text(errorMessage), 69 | ), 70 | ), 71 | app.If(c.Recover != nil, 72 | func() app.UI { 73 | return app.Div(). 74 | Class("pf-v6-c-alert__action-group"). 75 | Body( 76 | app.Button(). 77 | Class("pf-v6-c-button pf-m-link pf-m-inline"). 78 | Type("button"). 79 | OnClick(func(ctx app.Context, e app.Event) { 80 | c.Recover(ctx) 81 | }). 82 | Text(c.RecoverText), 83 | ) 84 | }, 85 | ), 86 | ) 87 | }).Else(func() app.UI { 88 | return app.Span() 89 | }) 90 | } 91 | -------------------------------------------------------------------------------- /pkg/components/switch.go: -------------------------------------------------------------------------------- 1 | package components 2 | 3 | import ( 4 | "github.com/maxence-charriere/go-app/v10/pkg/app" 5 | ) 6 | 7 | type Switch struct { 8 | app.Compo 9 | 10 | ID string 11 | 12 | Open bool 13 | ToggleOpen func() 14 | 15 | OnMessage string 16 | OffMessage string 17 | } 18 | 19 | func (c *Switch) Render() app.UI { 20 | return app.Label(). 21 | Class("pf-v6-c-switch"). 22 | For(c.ID). 23 | Body( 24 | app.Input(). 25 | Class("pf-v6-c-switch__input"). 26 | Type("checkbox"). 27 | ID(c.ID). 28 | Aria("labelledby", c.ID+"-on"). 29 | Name(c.ID). 30 | Checked(c.Open). 31 | OnInput(func(ctx app.Context, e app.Event) { 32 | c.ToggleOpen() 33 | }), 34 | app.Span(). 35 | Class("pf-v6-c-switch__toggle"), 36 | app.If( 37 | c.OnMessage != "" && c.Open, 38 | func() app.UI { 39 | return app.Span(). 40 | Class("pf-v6-c-switch__label pf-m-on"). 41 | ID(c.ID+"-on"). 42 | Aria("hidden", true). 43 | Text(c.OnMessage) 44 | }, 45 | ), 46 | app.If( 47 | c.OffMessage != "" && !c.Open, 48 | func() app.UI { 49 | return app.Span(). 50 | Class("pf-v6-c-switch__label pf-m-off"). 51 | ID(c.ID+"-off"). 52 | Aria("hidden", true). 53 | Text(c.OffMessage) 54 | }, 55 | ), 56 | ) 57 | } 58 | -------------------------------------------------------------------------------- /pkg/components/text_editor.go: -------------------------------------------------------------------------------- 1 | package components 2 | 3 | import ( 4 | "strings" 5 | 6 | "github.com/maxence-charriere/go-app/v10/pkg/app" 7 | ) 8 | 9 | type TextEditor struct { 10 | app.Compo 11 | 12 | Content string 13 | SetContent func(string) 14 | 15 | Format func() 16 | Refresh func() 17 | Save func() 18 | 19 | Language string 20 | VariableHeight bool 21 | } 22 | 23 | func (c *TextEditor) Render() app.UI { 24 | return app.Div(). 25 | Class(func() string { 26 | classes := "pf-v6-c-code-editor pf-v6-u-h-100 pf-v6-u-display-flex pf-v6-u-flex-direction-column" 27 | if c.SetContent == nil { 28 | classes += " pf-m-read-only" 29 | } 30 | 31 | return classes 32 | }()). 33 | Body( 34 | app.Div(). 35 | Class("pf-v6-c-code-editor__header"). 36 | Body( 37 | app.Div(). 38 | Class("pf-v6-c-code-editor__header-content"). 39 | Body( 40 | app.Div(). 41 | Class("pf-v6-c-code-editor__controls"). 42 | Body( 43 | app.If( 44 | c.Format != nil, 45 | func() app.UI { 46 | return app.Button(). 47 | Class("pf-v6-c-button pf-m-plain"). 48 | Type("button"). 49 | Aria("label", "Format"). 50 | Title("Format"). 51 | OnClick(func(ctx app.Context, e app.Event) { 52 | c.Format() 53 | }). 54 | Body( 55 | app.Span(). 56 | Class("pf-v6-c-button__icon"). 57 | Body( 58 | app.I(). 59 | Class("fas fa-align-left"). 60 | Aria("hidden", true), 61 | ), 62 | ) 63 | }, 64 | ), 65 | app.If( 66 | c.Refresh != nil, 67 | func() app.UI { 68 | return app.Button(). 69 | Class("pf-v6-c-button pf-m-plain"). 70 | Type("button"). 71 | Aria("label", "Refresh"). 72 | Title("Refresh"). 73 | OnClick(func(ctx app.Context, e app.Event) { 74 | c.Refresh() 75 | }). 76 | Body( 77 | app.Span(). 78 | Class("pf-v6-c-button__icon"). 79 | Body( 80 | app.I(). 81 | Class("fas fas fa-sync"). 82 | Aria("hidden", true), 83 | ), 84 | ) 85 | }, 86 | ), 87 | app.If( 88 | c.Save != nil, 89 | func() app.UI { 90 | return app.Button(). 91 | Class("pf-v6-c-button pf-m-plain"). 92 | Type("button"). 93 | Aria("label", "Save"). 94 | Title("Save"). 95 | OnClick(func(ctx app.Context, e app.Event) { 96 | c.Save() 97 | }). 98 | Body( 99 | app.Span(). 100 | Class("pf-v6-c-button__icon"). 101 | Body( 102 | app.I(). 103 | Class("fas fas fa-save"). 104 | Aria("hidden", true), 105 | ), 106 | ) 107 | }, 108 | ), 109 | ), 110 | app.Div(). 111 | Class("pf-v6-c-code-editor__header-main"), 112 | ), 113 | app.If( 114 | c.Language != "", 115 | func() app.UI { 116 | return app.Div(). 117 | Class("pf-v6-c-code-editor__tab"). 118 | Body( 119 | app.Span(). 120 | Class("pf-v6-c-code-editor__tab-icon"). 121 | Body( 122 | app.I(). 123 | Class("fas fa-code"). 124 | Aria("hidden", true), 125 | ), 126 | app.Span(). 127 | Class("pf-v6-c-code-editor__tab-text"). 128 | Text(c.Language), 129 | ) 130 | }, 131 | ), 132 | ), 133 | app.Textarea(). 134 | Class(func() string { 135 | classes := "pf-v6-c-code-editor__main pf-v6-u-w-100 pf-v6-x-u-resize-none pf-v6-u-p-sm pf-v6-u-p-sm pf-v6-u-flex-fill" 136 | if c.VariableHeight { 137 | classes += " pf-v6-x-m-overflow-y-hidden" 138 | } 139 | 140 | return classes 141 | }()). 142 | Rows(func() int { 143 | if c.VariableHeight { 144 | return strings.Count(c.Content, "\n") + 1 // Trailing newline 145 | } 146 | 147 | return 25 148 | }()). 149 | ReadOnly(c.SetContent == nil). 150 | OnInput(func(ctx app.Context, e app.Event) { 151 | c.SetContent(ctx.JSSrc().Get("value").String()) 152 | }). 153 | Text(c.Content), 154 | ) 155 | } 156 | -------------------------------------------------------------------------------- /pkg/components/text_editor_wrapper.go: -------------------------------------------------------------------------------- 1 | package components 2 | 3 | import "github.com/maxence-charriere/go-app/v10/pkg/app" 4 | 5 | type TextEditorWrapper struct { 6 | app.Compo 7 | 8 | Title string 9 | HelpLink string 10 | 11 | Children app.UI 12 | 13 | Error error 14 | ErrorDescription string 15 | Ignore func() 16 | } 17 | 18 | func (c *TextEditorWrapper) Render() app.UI { 19 | return app.Div(). 20 | Class("pf-v6-c-card pf-m-plain pf-v6-u-h-100"). 21 | Body( 22 | app.Div(). 23 | Class("pf-v6-c-card__header"). 24 | Body( 25 | app.If( 26 | c.HelpLink != "", 27 | func() app.UI { 28 | return app.Div(). 29 | Class("pf-v6-c-card__actions"). 30 | Body( 31 | app.A(). 32 | Class("pf-v6-c-button pf-m-plain"). 33 | Aria("label", "Help"). 34 | Target("_blank"). 35 | Href(c.HelpLink). 36 | Body( 37 | app.Span(). 38 | Class("pf-v6-c-menu-toggle__text pf-v6-c-button__icon"). 39 | Body( 40 | app.I(). 41 | Class("fas fa-question-circle"). 42 | Aria("hidden", true), 43 | ), 44 | ), 45 | ) 46 | }, 47 | ), 48 | app.Div(). 49 | Class("pf-v6-c-card__header-main"). 50 | Body( 51 | app.Div(). 52 | Class("pf-v6-c-card__title"). 53 | Body( 54 | app.H2(). 55 | Class("pf-v6-c-card__title-text"). 56 | Text(c.Title), 57 | ), 58 | ), 59 | ), 60 | app.Div(). 61 | Class("pf-v6-c-card__body"). 62 | Body(c.Children), 63 | app.If( 64 | c.Error != nil, 65 | func() app.UI { 66 | return app.Div(). 67 | Class("pf-v6-c-card__footer"). 68 | Body( 69 | app.Div(). 70 | Class("pf-v6-c-alert pf-m-danger pf-m-inline"). 71 | Aria("label", "Error alert"). 72 | Body( 73 | app.Div(). 74 | Class("pf-v6-c-alert__icon"). 75 | Body( 76 | app.I(). 77 | Class("fas fa-fw fa-exclamation-circle"). 78 | Aria("hidden", true), 79 | ), 80 | app.P(). 81 | Class("pf-v6-c-alert__title"). 82 | Body( 83 | app. 84 | Strong(). 85 | Body( 86 | app.Span(). 87 | Class("pf-screen-reader"). 88 | Text(c.ErrorDescription+":"), 89 | app.Text(c.ErrorDescription), 90 | ), 91 | ), 92 | app.Div(). 93 | Class("pf-v6-c-alert__action"). 94 | Body( 95 | app.Button(). 96 | Class("pf-v6-c-button pf-m-plain"). 97 | Type("button"). 98 | Aria("label", "Button to ignore the error"). 99 | OnClick(func(ctx app.Context, e app.Event) { 100 | c.Ignore() 101 | }). 102 | Body( 103 | app.Span(). 104 | Class("pf-v6-c-button__icon"). 105 | Body( 106 | app.I(). 107 | Class("fas fa-times"). 108 | Aria("hidden", true), 109 | ), 110 | ), 111 | ), 112 | app.Div(). 113 | Class("pf-v6-c-alert__description"). 114 | Body( 115 | app.Code().Text(c.Error), 116 | ), 117 | ), 118 | ) 119 | }, 120 | ), 121 | ) 122 | } 123 | -------------------------------------------------------------------------------- /pkg/components/update_notification.go: -------------------------------------------------------------------------------- 1 | package components 2 | 3 | import "github.com/maxence-charriere/go-app/v10/pkg/app" 4 | 5 | type UpdateNotification struct { 6 | app.Compo 7 | 8 | UpdateTitle string 9 | UpdateDescription string 10 | 11 | StartUpdateText string 12 | IgnoreUpdateText string 13 | 14 | updateAvailable bool 15 | updateIgnored bool 16 | } 17 | 18 | func (c *UpdateNotification) Render() app.UI { 19 | return app.If( 20 | c.updateAvailable && !c.updateIgnored, 21 | func() app.UI { 22 | return app.Li(). 23 | Class("pf-v6-c-alert-group__item"). 24 | Body( 25 | app.Div(). 26 | Class("pf-v6-c-alert pf-m-info"). 27 | Aria("label", c.UpdateTitle). 28 | Body( 29 | app.Div(). 30 | Class("pf-v6-c-alert__icon"). 31 | Body( 32 | app.I(). 33 | Class("fas fa-fw fa-bell"). 34 | Aria("hidden", true), 35 | ), 36 | app.P(). 37 | Class("pf-v6-c-alert__title"). 38 | Body( 39 | app.Strong().Body( 40 | app.Span(). 41 | Class("pf-screen-reader"). 42 | Text(c.UpdateTitle), 43 | ), 44 | app.Text(c.UpdateTitle), 45 | ), 46 | app.Div(). 47 | Class("pf-v6-c-alert__action"). 48 | Body( 49 | app.Button(). 50 | Class("pf-v6-c-button pf-m-plain"). 51 | Aria("label", c.IgnoreUpdateText). 52 | OnClick(func(ctx app.Context, e app.Event) { 53 | c.updateIgnored = true 54 | }). 55 | Body( 56 | app.Span(). 57 | Class("pf-v6-c-button__icon"). 58 | Body( 59 | app.I(). 60 | Class("fas fa-times"). 61 | Aria("hidden", true), 62 | ), 63 | ), 64 | ), 65 | app.If( 66 | c.UpdateDescription != "", 67 | func() app.UI { 68 | return app.Div(). 69 | Class("pf-v6-c-alert__description"). 70 | Body( 71 | app.P().Text(c.UpdateDescription), 72 | ) 73 | }, 74 | ), 75 | app.Div(). 76 | Class("pf-v6-c-alert__action-group"). 77 | Body( 78 | app.Button(). 79 | Class("pf-v6-c-button pf-m-link pf-m-inline"). 80 | Type("button"). 81 | OnClick(func(ctx app.Context, e app.Event) { 82 | ctx.Reload() 83 | }). 84 | Body( 85 | app.Span().Class("pf-v6-c-button__icon pf-m-start").Body( 86 | app.I().Class("fas fas fa-arrow-up").Aria("hidden", true), 87 | ), 88 | app.Text(c.StartUpdateText), 89 | ), 90 | app.Button(). 91 | Class("pf-v6-c-button pf-m-link pf-m-inline"). 92 | Type("button"). 93 | OnClick(func(ctx app.Context, e app.Event) { 94 | c.updateIgnored = true 95 | }). 96 | Body( 97 | app.Span().Class("pf-v6-c-button__icon pf-m-start").Body( 98 | app.I().Class("fas fa-ban").Aria("hidden", true), 99 | ), 100 | app.Text(c.IgnoreUpdateText), 101 | ), 102 | ), 103 | ), 104 | ) 105 | }, 106 | ).Else(func() app.UI { 107 | return app.Span() 108 | }) 109 | } 110 | 111 | func (c *UpdateNotification) OnMount(ctx app.Context) { 112 | c.updateAvailable = ctx.AppUpdateAvailable() 113 | } 114 | -------------------------------------------------------------------------------- /pkg/config/architecture_types.go: -------------------------------------------------------------------------------- 1 | package config 2 | 3 | import "strconv" 4 | 5 | // See https://www.iana.org/assignments/dhcpv6-parameters/dhcpv6-parameters.xhtml#processor-architecture 6 | func GetNameForArchId(id int) string { 7 | switch id { 8 | case 0x00: 9 | return "x86 BIOS" 10 | case 0x01: 11 | return "NEC/PC98 (DEPRECATED)" 12 | case 0x02: 13 | return "Itanium" 14 | case 0x03: 15 | return "DEC Alpha (DEPRECATED)" 16 | case 0x04: 17 | return "Arc x86 (DEPRECATED)" 18 | case 0x05: 19 | return "Intel Lean Client (DEPRECATED)" 20 | case 0x06: 21 | return "x86 UEFI" 22 | case 0x07: 23 | return "x64 UEFI" 24 | case 0x08: 25 | return "EFI Xscale (DEPRECATED)" 26 | case 0x09: 27 | return "EBC" 28 | case 0x0a: 29 | return "ARM 32-bit UEFI" 30 | case 0x0b: 31 | return "ARM 64-bit UEFI" 32 | case 0x0c: 33 | return "PowerPC Open Firmware" 34 | case 0x0d: 35 | return "PowerPC ePAPR" 36 | case 0x0e: 37 | return "POWER OPAL v3" 38 | case 0x0f: 39 | return "x86 uefi boot from http" 40 | case 0x10: 41 | return "x64 uefi boot from http" 42 | case 0x11: 43 | return "ebc boot from http" 44 | case 0x12: 45 | return "arm uefi 32 boot from http" 46 | case 0x13: 47 | return "arm uefi 64 boot from http" 48 | case 0x14: 49 | return "pc/at bios boot from http" 50 | case 0x15: 51 | return "arm 32 uboot" 52 | case 0x16: 53 | return "arm 64 uboot" 54 | case 0x17: 55 | return "arm uboot 32 boot from http" 56 | case 0x18: 57 | return "arm uboot 64 boot from http" 58 | case 0x19: 59 | return "RISC-V 32-bit UEFI" 60 | case 0x1a: 61 | return "RISC-V 32-bit UEFI boot from http" 62 | case 0x1b: 63 | return "RISC-V 64-bit UEFI" 64 | case 0x1c: 65 | return "RISC-V 64-bit UEFI boot from http" 66 | case 0x1d: 67 | return "RISC-V 128-bit UEFI" 68 | case 0x1e: 69 | return "RISC-V 128-bit UEFI boot from http" 70 | case 0x1f: 71 | return "s390 Basic" 72 | case 0x20: 73 | return "s390 Extended" 74 | case 0x21: 75 | return "MIPS 32-bit UEFI" 76 | case 0x22: 77 | return "MIPS 64-bit UEFI" 78 | case 0x23: 79 | return "Sunway 32-bit UEFI" 80 | case 0x24: 81 | return "Sunway 64-bit UEFI" 82 | default: 83 | return strconv.Itoa(id) 84 | } 85 | } 86 | -------------------------------------------------------------------------------- /pkg/config/yaegi.go: -------------------------------------------------------------------------------- 1 | package config 2 | 3 | import ( 4 | "context" 5 | "errors" 6 | "io/ioutil" 7 | "net/http" 8 | "os" 9 | "path/filepath" 10 | 11 | "github.com/codeclysm/extract/v3" 12 | "github.com/traefik/yaegi/interp" 13 | "github.com/traefik/yaegi/stdlib" 14 | ) 15 | 16 | const ( 17 | FilenameFunctionIdentifier = "config.Filename" 18 | ConfigureFunctionIdentifier = "config.Configure" 19 | ) 20 | 21 | const initialConfigFileContent = `package config 22 | 23 | import "log" 24 | 25 | func Filename( 26 | ip string, 27 | macAddress string, 28 | arch string, 29 | archID int, 30 | ) string { 31 | log.Println("You did not set up boot files yet!") 32 | 33 | return "changeme" 34 | } 35 | 36 | func Configure() map[string]string { 37 | return map[string]string{ 38 | "useStdlib": "true", 39 | } 40 | } 41 | ` 42 | 43 | func GetFileName( 44 | configFileLocation string, 45 | ip string, 46 | macAddress string, 47 | arch string, 48 | archID int, 49 | pure bool, 50 | handleOutput func(string), 51 | ) (string, error) { 52 | // Read the config file (we are re-reading each time so that a server restart is unnecessary) 53 | src, err := ioutil.ReadFile(configFileLocation) 54 | if err != nil { 55 | return "", err 56 | } 57 | 58 | // Configure the interpreter 59 | useStdlib := false 60 | { 61 | // Setup stdout/stderr handling 62 | outputReader, outputWriter, err := os.Pipe() 63 | if err != nil { 64 | return "", err 65 | } 66 | 67 | // Start the interpreter (for configuration) 68 | i := interp.New(interp.Options{ 69 | Stdout: outputWriter, 70 | Stderr: outputWriter, 71 | }) 72 | i.Use(stdlib.Symbols) 73 | 74 | // "Run" the config file, exporting the config function identifier 75 | if _, err := i.Eval(string(src)); err != nil { 76 | return "", err 77 | } 78 | 79 | // Get the config function by it's identifier 80 | v, err := i.Eval(ConfigureFunctionIdentifier) 81 | if err != nil { 82 | return "", err 83 | } 84 | 85 | // Cast the function 86 | configure, ok := v.Interface().(func() map[string]string) 87 | if !ok { 88 | return "", errors.New("could not parse config function: invalid config function signature") 89 | } 90 | 91 | // Run the function 92 | configParameters := configure() 93 | for key, value := range configParameters { 94 | if key == "useStdlib" && value == "true" { 95 | useStdlib = true 96 | } 97 | } 98 | 99 | // Close the output pipe 100 | if err := outputWriter.Close(); err != nil { 101 | return "", err 102 | } 103 | 104 | // Read & handle output 105 | out, err := ioutil.ReadAll(outputReader) 106 | if err != nil { 107 | return "", err 108 | } 109 | 110 | handleOutput(string(out)) 111 | } 112 | 113 | // Setup stdout/stderr handling 114 | outputReader, outputWriter, err := os.Pipe() 115 | if err != nil { 116 | return "", err 117 | } 118 | 119 | // Manually prevent stdlib use if set to pure 120 | if pure { 121 | useStdlib = false 122 | } 123 | 124 | // Start the interpreter (for file name) 125 | e := interp.New(interp.Options{ 126 | Stdout: outputWriter, 127 | Stderr: outputWriter, 128 | }) 129 | if useStdlib { 130 | e.Use(stdlib.Symbols) 131 | } 132 | 133 | // "Run" the config file, exporting the file name function identifier 134 | if _, err := e.Eval(string(src)); err != nil { 135 | return "", err 136 | } 137 | 138 | // Get the file name function by it's identifier 139 | w, err := e.Eval(FilenameFunctionIdentifier) 140 | if err != nil { 141 | return "", err 142 | } 143 | 144 | // Cast the function 145 | getFileName, ok := w.Interface().(func( 146 | ip string, 147 | macAddress string, 148 | arch string, 149 | archID int, 150 | ) string) 151 | if !ok { 152 | return "", errors.New("could not parse file name function: invalid file name function signature") 153 | } 154 | 155 | // Run the function 156 | rv, err := getFileName( 157 | ip, 158 | macAddress, 159 | arch, 160 | archID, 161 | ), nil 162 | 163 | // Close the output pipe 164 | if err := outputWriter.Close(); err != nil { 165 | return "", err 166 | } 167 | 168 | // Read & handle output 169 | out, err := ioutil.ReadAll(outputReader) 170 | if err != nil { 171 | return "", err 172 | } 173 | 174 | handleOutput(string(out)) 175 | 176 | return rv, err 177 | } 178 | 179 | func CreateConfigIfNotExists(configFileLocation string) error { 180 | // If config file does not exist, create and write to it 181 | if _, err := os.Stat(configFileLocation); os.IsNotExist(err) { 182 | // Create leading directories 183 | leadingDir, _ := filepath.Split(configFileLocation) 184 | if err := os.MkdirAll(leadingDir, os.ModePerm); err != nil { 185 | return err 186 | } 187 | 188 | // Create file 189 | out, err := os.Create(configFileLocation) 190 | if err != nil { 191 | return err 192 | } 193 | defer out.Close() 194 | 195 | // Write to file 196 | if err := ioutil.WriteFile(configFileLocation, []byte(initialConfigFileContent), os.ModePerm); err != nil { 197 | return err 198 | } 199 | 200 | return nil 201 | } 202 | 203 | return nil 204 | } 205 | 206 | func GetStarterIfNotExists(configFileLocation string, starterURL string, outDir string) error { 207 | // If config file does not exist, get and extract starter 208 | if _, err := os.Stat(configFileLocation); os.IsNotExist(err) { 209 | // Create directory to extract to 210 | if err := os.MkdirAll(outDir, os.ModePerm); err != nil { 211 | return err 212 | } 213 | 214 | // Download .tar.gz 215 | resp, err := http.Get(starterURL) 216 | if err != nil { 217 | return err 218 | } 219 | defer resp.Body.Close() 220 | 221 | // Extract .tar.gz 222 | return extract.Gz(context.Background(), resp.Body, outDir, nil) 223 | } 224 | 225 | return nil 226 | } 227 | -------------------------------------------------------------------------------- /pkg/constants/authorization.go: -------------------------------------------------------------------------------- 1 | package constants 2 | 3 | const ( 4 | OIDCOverBasicAuthUsername = "user" 5 | ) 6 | -------------------------------------------------------------------------------- /pkg/constants/config.go: -------------------------------------------------------------------------------- 1 | package constants 2 | 3 | const ( 4 | BootConfigFileName = "config.go" 5 | ) 6 | -------------------------------------------------------------------------------- /pkg/eventing/event.go: -------------------------------------------------------------------------------- 1 | package eventing 2 | 3 | import ( 4 | "fmt" 5 | "log" 6 | "time" 7 | 8 | "github.com/ugjka/messenger" 9 | ) 10 | 11 | type Event struct { 12 | CreatedAt time.Time 13 | Message string 14 | } 15 | 16 | type EventHandler struct { 17 | messenger *messenger.Messenger 18 | } 19 | 20 | func NewEventHandler() *EventHandler { 21 | return &EventHandler{ 22 | messenger: messenger.New(0, true), 23 | } 24 | } 25 | 26 | func (h *EventHandler) Emit(s string, v ...interface{}) { 27 | // Construct the description 28 | msg := fmt.Sprintf(s, v...) 29 | 30 | // Log the emitted description 31 | log.Println(msg) 32 | 33 | // Broadcast the description 34 | h.messenger.Broadcast(Event{ 35 | CreatedAt: time.Now(), 36 | Message: msg, 37 | }) 38 | } 39 | 40 | // Proxy to internal messenger 41 | func (h *EventHandler) Sub() (client chan interface{}, err error) { 42 | return h.messenger.Sub() 43 | } 44 | 45 | // Proxy to internal messenger 46 | func (h *EventHandler) Unsub(client chan interface{}) { 47 | h.messenger.Unsub(client) 48 | } 49 | -------------------------------------------------------------------------------- /pkg/eventing/http_logging.go: -------------------------------------------------------------------------------- 1 | package eventing 2 | 3 | import ( 4 | "net/http" 5 | ) 6 | 7 | func LogRequestHandler(h http.Handler, eventHandler *EventHandler) http.Handler { 8 | return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 9 | if r.Method == http.MethodGet { 10 | eventHandler.Emit(`sending file "%v" to client "%v" with user agent "%v"`, r.URL.Path, r.RemoteAddr, r.UserAgent()) 11 | } 12 | 13 | h.ServeHTTP(w, r) 14 | }) 15 | } 16 | -------------------------------------------------------------------------------- /pkg/providers/identity_provider.go: -------------------------------------------------------------------------------- 1 | package providers 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "time" 7 | 8 | "github.com/coreos/go-oidc/v3/oidc" 9 | "github.com/maxence-charriere/go-app/v10/pkg/app" 10 | "golang.org/x/oauth2" 11 | ) 12 | 13 | const ( 14 | oauth2TokenKey = "oauth2Token" 15 | idTokenKey = "idToken" 16 | userInfoKey = "userInfo" 17 | 18 | StateQueryParameter = "state" 19 | CodeQueryParameter = "code" 20 | 21 | idTokenExtraKey = "id_token" 22 | ) 23 | 24 | type IdentityProviderChildrenProps struct { 25 | IDToken string 26 | UserInfo oidc.UserInfo 27 | 28 | Logout func(ctx app.Context) 29 | 30 | Error error 31 | Recover func(ctx app.Context) 32 | } 33 | 34 | type IdentityProvider struct { 35 | app.Compo 36 | 37 | Issuer string 38 | ClientID string 39 | RedirectURL string 40 | HomeURL string 41 | Scopes []string 42 | StoragePrefix string 43 | Children func(IdentityProviderChildrenProps) app.UI 44 | 45 | oauth2Token oauth2.Token 46 | idToken string 47 | userInfo oidc.UserInfo 48 | 49 | err error 50 | } 51 | 52 | func (c *IdentityProvider) Render() app.UI { 53 | return c.Children( 54 | IdentityProviderChildrenProps{ 55 | IDToken: c.idToken, 56 | UserInfo: c.userInfo, 57 | 58 | Logout: func(ctx app.Context) { 59 | c.logout(true, ctx) 60 | }, 61 | 62 | Error: c.err, 63 | Recover: c.recover, 64 | }, 65 | ) 66 | } 67 | 68 | func (c *IdentityProvider) OnMount(ctx app.Context) { 69 | // Only continue if there is no error state; this prevents endless loops 70 | if c.err == nil { 71 | c.authorize(ctx) 72 | } 73 | } 74 | 75 | func (c *IdentityProvider) OnNav(ctx app.Context) { 76 | // Only continue if there is no error state; this prevents endless loops 77 | if c.err == nil { 78 | c.authorize(ctx) 79 | } 80 | } 81 | 82 | func (c *IdentityProvider) panic(err error, ctx app.Context) { 83 | go func() { 84 | // Set the error 85 | c.err = err 86 | 87 | // Prevent infinite retries 88 | time.Sleep(time.Second) 89 | 90 | // Unset the error & enable re-trying 91 | c.err = err 92 | }() 93 | } 94 | 95 | func (c *IdentityProvider) recover(ctx app.Context) { 96 | // Clear the error 97 | c.err = nil 98 | 99 | // Logout 100 | c.logout(false, ctx) 101 | } 102 | 103 | func (c *IdentityProvider) watch(ctx app.Context) { 104 | for { 105 | // Wait till token expires 106 | if c.oauth2Token.Expiry.After(time.Now()) { 107 | time.Sleep(c.oauth2Token.Expiry.Sub(time.Now())) 108 | } 109 | 110 | // Fetch new OAuth2 token 111 | oauth2Token, err := oauth2.StaticTokenSource(&c.oauth2Token).Token() 112 | if err != nil { 113 | c.panic(err, ctx) 114 | 115 | return 116 | } 117 | 118 | // Parse ID token 119 | idToken, ok := oauth2Token.Extra("id_token").(string) 120 | if !ok { 121 | c.panic(err, ctx) 122 | 123 | return 124 | } 125 | 126 | // Persist state in storage 127 | if err := c.persist(*oauth2Token, idToken, c.userInfo, ctx); err != nil { 128 | c.panic(err, ctx) 129 | 130 | return 131 | } 132 | 133 | // Set the login state 134 | c.oauth2Token = *oauth2Token 135 | c.idToken = idToken 136 | } 137 | } 138 | 139 | func (c *IdentityProvider) logout(withRedirect bool, ctx app.Context) { 140 | // Remove from storage 141 | c.clear(ctx) 142 | 143 | // Reload the app 144 | if withRedirect { 145 | ctx.Reload() 146 | } 147 | } 148 | 149 | func (c *IdentityProvider) rehydrate(ctx app.Context) (oauth2.Token, string, oidc.UserInfo, error) { 150 | // Read state from storage 151 | oauth2Token := oauth2.Token{} 152 | idToken := "" 153 | userInfo := oidc.UserInfo{} 154 | 155 | if err := ctx.LocalStorage().Get(c.getKey(oauth2TokenKey), &oauth2Token); err != nil { 156 | return oauth2.Token{}, "", oidc.UserInfo{}, err 157 | } 158 | if err := ctx.LocalStorage().Get(c.getKey(idTokenKey), &idToken); err != nil { 159 | return oauth2.Token{}, "", oidc.UserInfo{}, err 160 | } 161 | if err := ctx.LocalStorage().Get(c.getKey(userInfoKey), &userInfo); err != nil { 162 | return oauth2.Token{}, "", oidc.UserInfo{}, err 163 | } 164 | 165 | return oauth2Token, idToken, userInfo, nil 166 | } 167 | 168 | func (c *IdentityProvider) persist(oauth2Token oauth2.Token, idToken string, userInfo oidc.UserInfo, ctx app.Context) error { 169 | // Write state to storage 170 | if err := ctx.LocalStorage().Set(c.getKey(oauth2TokenKey), oauth2Token); err != nil { 171 | return err 172 | } 173 | if err := ctx.LocalStorage().Set(c.getKey(idTokenKey), idToken); err != nil { 174 | return err 175 | } 176 | return ctx.LocalStorage().Set(c.getKey(userInfoKey), userInfo) 177 | } 178 | 179 | func (c *IdentityProvider) clear(ctx app.Context) { 180 | // Remove from storage 181 | ctx.LocalStorage().Del(c.getKey(oauth2TokenKey)) 182 | ctx.LocalStorage().Del(c.getKey(idTokenKey)) 183 | ctx.LocalStorage().Del(c.getKey(userInfoKey)) 184 | 185 | // Remove cookies 186 | app.Window().Get("document").Set("cookie", "") 187 | } 188 | 189 | func (c *IdentityProvider) getKey(key string) string { 190 | // Get a prefixed key 191 | return fmt.Sprintf("%v.%v", c.StoragePrefix, key) 192 | } 193 | 194 | func (c *IdentityProvider) authorize(ctx app.Context) { 195 | // Read state from storage 196 | oauth2Token, idToken, userInfo, err := c.rehydrate(ctx) 197 | if err != nil { 198 | c.panic(err, ctx) 199 | 200 | return 201 | } 202 | 203 | // Create the OIDC provider 204 | provider, err := oidc.NewProvider(context.Background(), c.Issuer) 205 | if err != nil { 206 | c.panic(err, ctx) 207 | 208 | return 209 | } 210 | 211 | // Create the OAuth2 config 212 | config := &oauth2.Config{ 213 | ClientID: c.ClientID, 214 | RedirectURL: c.RedirectURL, 215 | Endpoint: provider.Endpoint(), 216 | Scopes: append([]string{oidc.ScopeOpenID}, c.Scopes...), 217 | } 218 | 219 | // Log in 220 | if oauth2Token.AccessToken == "" || userInfo.Email == "" { 221 | // Logged out state, info neither in storage nor in URL: Redirect to login 222 | if app.Window().URL().Query().Get(StateQueryParameter) == "" { 223 | ctx.Navigate(config.AuthCodeURL(c.RedirectURL, oauth2.AccessTypeOffline)) 224 | 225 | return 226 | } 227 | 228 | // Intermediate state, info is in URL: Parse OAuth2 token 229 | oauth2Token, err := config.Exchange(context.Background(), app.Window().URL().Query().Get(CodeQueryParameter)) 230 | if err != nil { 231 | c.panic(err, ctx) 232 | 233 | return 234 | } 235 | 236 | // Parse ID token 237 | idToken, ok := oauth2Token.Extra(idTokenExtraKey).(string) 238 | if !ok { 239 | c.panic(err, ctx) 240 | 241 | return 242 | } 243 | 244 | // Parse user info 245 | userInfo, err := provider.UserInfo(context.Background(), oauth2.StaticTokenSource(oauth2Token)) 246 | if err != nil { 247 | c.panic(err, ctx) 248 | 249 | return 250 | } 251 | 252 | // Persist state in storage 253 | if err := c.persist(*oauth2Token, idToken, *userInfo, ctx); err != nil { 254 | c.panic(err, ctx) 255 | 256 | return 257 | } 258 | 259 | // Test validity of storage 260 | if _, _, _, err = c.rehydrate(ctx); err != nil { 261 | c.panic(err, ctx) 262 | 263 | return 264 | } 265 | 266 | // Update and navigate to home URL 267 | ctx.Navigate(c.HomeURL) 268 | 269 | return 270 | } 271 | 272 | // Validation state 273 | 274 | // Create the OIDC config 275 | oidcConfig := &oidc.Config{ 276 | ClientID: c.ClientID, 277 | } 278 | 279 | // Create the OIDC verifier and validate the token (i.e. check for it's expiry date) 280 | verifier := provider.Verifier(oidcConfig) 281 | if _, err := verifier.Verify(context.Background(), idToken); err != nil { 282 | // Invalid token; clear and re-authorize 283 | c.clear(ctx) 284 | c.authorize(ctx) 285 | 286 | return 287 | } 288 | 289 | // Logged in state 290 | 291 | // Set the login state 292 | c.oauth2Token = oauth2Token 293 | c.idToken = idToken 294 | c.userInfo = userInfo 295 | 296 | // Watch and renew token once expired 297 | go c.watch(ctx) 298 | } 299 | -------------------------------------------------------------------------------- /pkg/providers/setup_provider.go: -------------------------------------------------------------------------------- 1 | package providers 2 | 3 | import ( 4 | "errors" 5 | "fmt" 6 | "net/url" 7 | 8 | "github.com/maxence-charriere/go-app/v10/pkg/app" 9 | ) 10 | 11 | type SetupProviderChildrenProps struct { 12 | BackendURL string 13 | OIDCIssuer string 14 | OIDCClientID string 15 | OIDCRedirectURL string 16 | Ready bool 17 | 18 | SetBackendURL, 19 | SetOIDCIssuer, 20 | SetOIDCClientID, 21 | SetOIDCRedirectURL func(string, app.Context) 22 | ApplyConfig func(app.Context) 23 | 24 | Error error 25 | } 26 | 27 | type SetupProvider struct { 28 | app.Compo 29 | 30 | StoragePrefix string 31 | StateQueryParameter string 32 | CodeQueryParameter string 33 | Children func(SetupProviderChildrenProps) app.UI 34 | 35 | backendURL string 36 | oidcIssuer string 37 | oidcClientID string 38 | oidcRedirectURL string 39 | ready bool 40 | 41 | err error 42 | } 43 | 44 | const ( 45 | backendURLKey = "backendURL" 46 | oidcIssuerKey = "oidcIssuer" 47 | oidcClientIDKey = "oidcClientID" 48 | oidcRedirectURLKey = "oidcRedirectURL" 49 | ) 50 | 51 | func (c *SetupProvider) Render() app.UI { 52 | return c.Children(SetupProviderChildrenProps{ 53 | BackendURL: c.backendURL, 54 | OIDCIssuer: c.oidcIssuer, 55 | OIDCClientID: c.oidcClientID, 56 | OIDCRedirectURL: c.oidcRedirectURL, 57 | Ready: c.ready, 58 | 59 | SetBackendURL: func(s string, ctx app.Context) { 60 | c.ready = false 61 | c.backendURL = s 62 | }, 63 | SetOIDCIssuer: func(s string, ctx app.Context) { 64 | c.ready = false 65 | c.oidcIssuer = s 66 | }, 67 | SetOIDCClientID: func(s string, ctx app.Context) { 68 | c.ready = false 69 | c.oidcClientID = s 70 | }, 71 | SetOIDCRedirectURL: func(s string, ctx app.Context) { 72 | c.ready = false 73 | c.oidcRedirectURL = s 74 | }, 75 | ApplyConfig: func(ctx app.Context) { 76 | c.validate(ctx) 77 | }, 78 | 79 | Error: c.err, 80 | }) 81 | } 82 | 83 | func (c *SetupProvider) invalidate(err error) { 84 | // Set the error state 85 | c.err = err 86 | c.ready = false 87 | } 88 | 89 | func (c *SetupProvider) validate(ctx app.Context) { 90 | // Validate fields 91 | if c.oidcClientID == "" { 92 | c.invalidate(errors.New("invalid OIDC client ID")) 93 | 94 | return 95 | } 96 | 97 | if _, err := url.ParseRequestURI(c.oidcIssuer); err != nil { 98 | c.invalidate(fmt.Errorf("invalid OIDC issuer: %v", err)) 99 | 100 | return 101 | } 102 | 103 | if _, err := url.ParseRequestURI(c.backendURL); err != nil { 104 | c.invalidate(fmt.Errorf("invalid backend URL: %v", err)) 105 | 106 | return 107 | } 108 | 109 | if _, err := url.ParseRequestURI(c.oidcRedirectURL); err != nil { 110 | c.invalidate(fmt.Errorf("invalid OIDC redirect URL: %v", err)) 111 | 112 | return 113 | } 114 | 115 | // Persist state 116 | if err := c.persist(ctx); err != nil { 117 | c.invalidate(err) 118 | 119 | return 120 | } 121 | 122 | // If all are valid, set ready state 123 | c.err = nil 124 | c.ready = true 125 | } 126 | 127 | func (c *SetupProvider) persist(ctx app.Context) error { 128 | // Write state to storage 129 | if err := ctx.LocalStorage().Set(c.getKey(backendURLKey), c.backendURL); err != nil { 130 | return err 131 | } 132 | if err := ctx.LocalStorage().Set(c.getKey(oidcIssuerKey), c.oidcIssuer); err != nil { 133 | return err 134 | } 135 | if err := ctx.LocalStorage().Set(c.getKey(oidcClientIDKey), c.oidcClientID); err != nil { 136 | return err 137 | } 138 | 139 | return ctx.LocalStorage().Set(c.getKey(oidcRedirectURLKey), c.oidcRedirectURL) 140 | } 141 | 142 | func (c *SetupProvider) rehydrateFromURL(ctx app.Context) bool { 143 | // Read state from URL 144 | query := app.Window().URL().Query() 145 | 146 | backendURL := query.Get(backendURLKey) 147 | oidcIssuer := query.Get(oidcIssuerKey) 148 | oidcClientID := query.Get(oidcClientIDKey) 149 | oidcRedirectURL := query.Get(oidcRedirectURLKey) 150 | 151 | // If all values are set, set them in the data provider 152 | if backendURL != "" && oidcIssuer != "" && oidcClientID != "" && oidcRedirectURL != "" { 153 | c.backendURL = backendURL 154 | c.oidcIssuer = oidcIssuer 155 | c.oidcClientID = oidcClientID 156 | c.oidcRedirectURL = oidcRedirectURL 157 | 158 | return true 159 | } 160 | 161 | return false 162 | } 163 | 164 | func (c *SetupProvider) rehydrateFromStorage(ctx app.Context) bool { 165 | // Read state from storage 166 | backendURL := "" 167 | oidcIssuer := "" 168 | oidcClientID := "" 169 | oidcRedirectURL := "" 170 | 171 | if err := ctx.LocalStorage().Get(c.getKey(backendURLKey), &backendURL); err != nil { 172 | c.invalidate(err) 173 | 174 | return false 175 | } 176 | if err := ctx.LocalStorage().Get(c.getKey(oidcIssuerKey), &oidcIssuer); err != nil { 177 | c.invalidate(err) 178 | 179 | return false 180 | } 181 | if err := ctx.LocalStorage().Get(c.getKey(oidcClientIDKey), &oidcClientID); err != nil { 182 | c.invalidate(err) 183 | 184 | return false 185 | } 186 | if err := ctx.LocalStorage().Get(c.getKey(oidcRedirectURLKey), &oidcRedirectURL); err != nil { 187 | c.invalidate(err) 188 | 189 | return false 190 | } 191 | 192 | // If all values are set, set them in the data provider 193 | if backendURL != "" && oidcIssuer != "" && oidcClientID != "" && oidcRedirectURL != "" { 194 | c.backendURL = backendURL 195 | c.oidcIssuer = oidcIssuer 196 | c.oidcClientID = oidcClientID 197 | c.oidcRedirectURL = oidcRedirectURL 198 | 199 | return true 200 | } 201 | 202 | return false 203 | } 204 | 205 | func (c *SetupProvider) rehydrateAuthenticationFromURL() bool { 206 | // Read state from URL 207 | query := app.Window().URL().Query() 208 | 209 | state := query.Get(c.StateQueryParameter) 210 | code := query.Get(c.CodeQueryParameter) 211 | 212 | // If all values are set, set them in the data provider 213 | if state != "" && code != "" { 214 | return true 215 | } 216 | 217 | return false 218 | } 219 | 220 | func (c *SetupProvider) getKey(key string) string { 221 | // Get a prefixed key 222 | return fmt.Sprintf("%v.%v", c.StoragePrefix, key) 223 | } 224 | 225 | func (c *SetupProvider) OnMount(ctx app.Context) { 226 | // Initialize state 227 | c.backendURL = "" 228 | c.oidcIssuer = "" 229 | c.oidcClientID = "" 230 | c.oidcRedirectURL = "" 231 | c.ready = false 232 | 233 | // If rehydrated from URL, validate & apply 234 | if c.rehydrateFromURL(ctx) { 235 | // Auto-apply if configured 236 | // Disabled until a flow for handling wrong input details has been implemented 237 | // c.validate() 238 | } 239 | 240 | // If rehydrated from storage, validate & apply 241 | if c.rehydrateFromStorage(ctx) { 242 | // Auto-apply if configured 243 | // Disabled until a flow for handling wrong input details has been implemented 244 | // c.validate() 245 | } 246 | 247 | // If rehydrated authentication from URL, continue 248 | if c.rehydrateAuthenticationFromURL() { 249 | // Auto-apply if configured; set ready state 250 | c.err = nil 251 | c.ready = true 252 | } 253 | } 254 | -------------------------------------------------------------------------------- /pkg/servers/dhcp.go: -------------------------------------------------------------------------------- 1 | package servers 2 | 3 | import ( 4 | "net" 5 | 6 | "github.com/pojntfx/bofied/pkg/eventing" 7 | "github.com/pojntfx/bofied/pkg/transcoding" 8 | ) 9 | 10 | const ( 11 | DHCPServerBootMenuPromptBIOS = "PXE" 12 | DHCPServerBootMenuDescriptionBIOS = "Boot from bofied (BIOS)" 13 | ) 14 | 15 | type DHCPServer struct { 16 | UDPServer 17 | } 18 | 19 | func NewDHCPServer(listenAddress string, advertisedIP string, eventHandler *eventing.EventHandler) *DHCPServer { 20 | return &DHCPServer{ 21 | UDPServer: UDPServer{ 22 | listenAddress: listenAddress, 23 | advertisedIP: advertisedIP, 24 | handlePacket: func(conn *net.UDPConn, _ *net.UDPAddr, braddr *net.UDPAddr, rawIncomingUDPPacket []byte) (int, error) { 25 | return handleDHCPPacket(conn, braddr, rawIncomingUDPPacket, net.ParseIP(advertisedIP).To4(), eventHandler.Emit) 26 | }, 27 | }, 28 | } 29 | } 30 | 31 | func handleDHCPPacket(conn *net.UDPConn, braddr *net.UDPAddr, rawIncomingUDPPacket []byte, advertisedIP net.IP, emit func(f string, v ...interface{})) (int, error) { 32 | // Decode packet 33 | incomingDHCPPacket, err := transcoding.DecodeDHCPPacket(rawIncomingUDPPacket) 34 | if err != nil { 35 | return 0, err 36 | } 37 | 38 | // Ignore non-PXE packets 39 | if !incomingDHCPPacket.IsPXE { 40 | return 0, nil 41 | } 42 | 43 | // Encode packet 44 | outgoingDHCPPacket := transcoding.EncodeDHCPPacket( 45 | incomingDHCPPacket.ClientHWAddr, 46 | incomingDHCPPacket.Xid, 47 | advertisedIP, 48 | incomingDHCPPacket.ClientIdentifierOpt, 49 | incomingDHCPPacket.Arch, 50 | DHCPServerBootMenuPromptBIOS, 51 | DHCPServerBootMenuDescriptionBIOS, 52 | ) 53 | 54 | emit(`sending %v bytes of DHCP packets to client "%v"`, len(outgoingDHCPPacket), braddr) 55 | 56 | // Broadcast the packet 57 | return conn.WriteToUDP(outgoingDHCPPacket, braddr) 58 | } 59 | -------------------------------------------------------------------------------- /pkg/servers/extended_http.go: -------------------------------------------------------------------------------- 1 | package servers 2 | 3 | import ( 4 | "net/http" 5 | 6 | "github.com/pojntfx/bofied/pkg/authorization" 7 | "github.com/pojntfx/bofied/pkg/constants" 8 | "github.com/pojntfx/bofied/pkg/eventing" 9 | "github.com/pojntfx/bofied/pkg/validators" 10 | "github.com/rs/cors" 11 | "golang.org/x/net/webdav" 12 | ) 13 | 14 | const ( 15 | WebDAVRealmDescription = `bofied protected area. You can find your credentials (username and password/token) with the "Mount Folder" option in the frontend.` 16 | HTTPPrefix = "/public" 17 | WebDAVPrefix = "/private" 18 | GRPCPrefix = "/grpc" 19 | ) 20 | 21 | type ExtendedHTTPServer struct { 22 | FileServer 23 | 24 | eventsServerHandler http.Handler 25 | eventHandler *eventing.EventHandler 26 | 27 | oidcValidator *validators.OIDCValidator 28 | } 29 | 30 | func NewExtendedHTTPServer( 31 | workingDir string, 32 | listenAddress string, 33 | oidcValidator *validators.OIDCValidator, 34 | eventsServerHandler http.Handler, 35 | eventHandler *eventing.EventHandler, 36 | ) *ExtendedHTTPServer { 37 | return &ExtendedHTTPServer{ 38 | FileServer: FileServer{ 39 | workingDir: workingDir, 40 | listenAddress: listenAddress, 41 | }, 42 | 43 | eventsServerHandler: eventsServerHandler, 44 | eventHandler: eventHandler, 45 | 46 | oidcValidator: oidcValidator, 47 | } 48 | } 49 | 50 | func (s *ExtendedHTTPServer) GetWebDAVHandler(prefix string) webdav.Handler { 51 | return webdav.Handler{ 52 | Prefix: prefix, 53 | FileSystem: webdav.Dir(s.workingDir), 54 | LockSystem: webdav.NewMemLS(), 55 | } 56 | } 57 | 58 | func (s *ExtendedHTTPServer) GetHTTPHandler() http.Handler { 59 | return eventing.LogRequestHandler( 60 | http.FileServer( 61 | http.Dir(s.workingDir), 62 | ), 63 | s.eventHandler, 64 | ) 65 | } 66 | 67 | func (s *ExtendedHTTPServer) ListenAndServe() error { 68 | webDAVHandler := s.GetWebDAVHandler(WebDAVPrefix) 69 | httpHandler := s.GetHTTPHandler() 70 | 71 | mux := http.NewServeMux() 72 | 73 | mux.Handle( 74 | HTTPPrefix+"/", 75 | http.StripPrefix(HTTPPrefix, httpHandler), 76 | ) 77 | mux.Handle( 78 | WebDAVPrefix+"/", 79 | cors.New(cors.Options{ 80 | AllowedMethods: []string{ 81 | "GET", 82 | "PUT", 83 | "PROPFIND", 84 | "MKCOL", 85 | "MOVE", 86 | "COPY", 87 | "DELETE", 88 | }, 89 | AllowCredentials: true, 90 | AllowedHeaders: []string{ 91 | "*", 92 | }, 93 | }).Handler( 94 | authorization.OIDCOverBasicAuth( 95 | &webDAVHandler, 96 | constants.OIDCOverBasicAuthUsername, 97 | s.oidcValidator, 98 | WebDAVRealmDescription, 99 | ), 100 | ), 101 | ) 102 | mux.Handle( 103 | GRPCPrefix, 104 | s.eventsServerHandler, 105 | ) 106 | 107 | return http.ListenAndServe( 108 | s.listenAddress, 109 | mux, 110 | ) 111 | } 112 | -------------------------------------------------------------------------------- /pkg/servers/file_server.go: -------------------------------------------------------------------------------- 1 | package servers 2 | 3 | type FileServer struct { 4 | workingDir string 5 | listenAddress string 6 | } 7 | -------------------------------------------------------------------------------- /pkg/servers/grpc.go: -------------------------------------------------------------------------------- 1 | package servers 2 | 3 | import ( 4 | "net" 5 | "sync" 6 | 7 | api "github.com/pojntfx/bofied/pkg/api/proto/v1" 8 | "github.com/pojntfx/bofied/pkg/services" 9 | "github.com/pojntfx/bofied/pkg/websocketproxy" 10 | "google.golang.org/grpc" 11 | "google.golang.org/grpc/reflection" 12 | ) 13 | 14 | type GRPCServer struct { 15 | listenAddress string 16 | 17 | eventsService *services.EventsService 18 | metadataService *services.MetadataService 19 | 20 | proxy *websocketproxy.WebSocketProxyServer 21 | } 22 | 23 | func NewGRPCServer(listenAddress string, eventsService *services.EventsService, metadataService *services.MetadataService) (*GRPCServer, *websocketproxy.WebSocketProxyServer) { 24 | proxy := websocketproxy.NewWebSocketProxyServer() 25 | 26 | return &GRPCServer{ 27 | listenAddress: listenAddress, 28 | eventsService: eventsService, 29 | metadataService: metadataService, 30 | proxy: proxy, 31 | }, proxy 32 | } 33 | 34 | func (s *GRPCServer) ListenAndServe() error { 35 | listener, err := net.Listen("tcp", s.listenAddress) 36 | if err != nil { 37 | return err 38 | } 39 | 40 | server := grpc.NewServer() 41 | reflection.Register(server) 42 | 43 | api.RegisterEventsServiceServer(server, s.eventsService) 44 | api.RegisterMetadataServiceServer(server, s.metadataService) 45 | 46 | doneChan := make(chan struct{}) 47 | errChan := make(chan error) 48 | 49 | var wg sync.WaitGroup 50 | wg.Add(2) 51 | 52 | go func() { 53 | wg.Wait() 54 | 55 | close(doneChan) 56 | }() 57 | 58 | go func() { 59 | if err := server.Serve(listener); err != nil { 60 | errChan <- err 61 | } 62 | 63 | wg.Done() 64 | }() 65 | 66 | go func() { 67 | if err := server.Serve(s.proxy); err != nil { 68 | errChan <- err 69 | } 70 | 71 | wg.Done() 72 | }() 73 | 74 | select { 75 | case <-doneChan: 76 | return nil 77 | case <-errChan: 78 | return err 79 | } 80 | } 81 | -------------------------------------------------------------------------------- /pkg/servers/proxy_dhcp.go: -------------------------------------------------------------------------------- 1 | package servers 2 | 3 | import ( 4 | "net" 5 | 6 | "github.com/pojntfx/bofied/pkg/config" 7 | "github.com/pojntfx/bofied/pkg/eventing" 8 | "github.com/pojntfx/bofied/pkg/transcoding" 9 | ) 10 | 11 | type ProxyDHCPServer struct { 12 | UDPServer 13 | } 14 | 15 | func NewProxyDHCPServer(listenAddress string, advertisedIP string, configFileLocation string, eventHandler *eventing.EventHandler, pureConfig bool) *ProxyDHCPServer { 16 | return &ProxyDHCPServer{ 17 | UDPServer: UDPServer{ 18 | listenAddress: listenAddress, 19 | advertisedIP: advertisedIP, 20 | handlePacket: func(conn *net.UDPConn, raddr *net.UDPAddr, braddr *net.UDPAddr, rawIncomingUDPPacket []byte) (int, error) { 21 | return handleProxyDHCPPacket(conn, raddr, braddr, rawIncomingUDPPacket, net.ParseIP(advertisedIP).To4(), configFileLocation, eventHandler.Emit, pureConfig) 22 | }, 23 | }, 24 | } 25 | } 26 | 27 | func handleProxyDHCPPacket(conn *net.UDPConn, raddr *net.UDPAddr, _ *net.UDPAddr, rawIncomingUDPPacket []byte, advertisedIP net.IP, configFileLocation string, emit func(f string, v ...interface{}), pureConfig bool) (int, error) { 28 | // Decode packet 29 | incomingDHCPPacket, err := transcoding.DecodeDHCPPacket(rawIncomingUDPPacket) 30 | if err != nil { 31 | return 0, err 32 | } 33 | 34 | // Ignore non-PXE packets 35 | if !incomingDHCPPacket.IsPXE { 36 | return 0, nil 37 | } 38 | 39 | emit( 40 | `handling proxyDHCP for client with IP %v, MAC %v, architecture %v and architecture ID %v`, 41 | raddr.IP.String(), 42 | incomingDHCPPacket.ClientHWAddr.String(), 43 | config.GetNameForArchId(incomingDHCPPacket.Arch), 44 | incomingDHCPPacket.Arch, 45 | ) 46 | 47 | // Get the boot file name 48 | bootFileName, err := config.GetFileName( 49 | configFileLocation, 50 | raddr.IP.String(), 51 | incomingDHCPPacket.ClientHWAddr.String(), 52 | config.GetNameForArchId(incomingDHCPPacket.Arch), 53 | incomingDHCPPacket.Arch, 54 | pureConfig, 55 | func(s string) { 56 | if s != "" { 57 | emit("from config: %v", s) 58 | } 59 | }, 60 | ) 61 | if err != nil { 62 | emit("could not process config: %v", err) 63 | 64 | return 0, err 65 | } 66 | 67 | // Encode packet 68 | outgoingDHCPPacket := transcoding.EncodeProxyDHCPPacket( 69 | incomingDHCPPacket.ClientHWAddr, 70 | incomingDHCPPacket.Xid, 71 | advertisedIP, 72 | incomingDHCPPacket.ClientIdentifierOpt, 73 | raddr.IP.To4(), 74 | bootFileName, 75 | ) 76 | 77 | emit(`sending %v bytes of proxyDHCP packets to client "%v"`, len(outgoingDHCPPacket), raddr) 78 | 79 | // Send the packet to the client 80 | return conn.WriteToUDP(outgoingDHCPPacket, raddr) 81 | } 82 | -------------------------------------------------------------------------------- /pkg/servers/tftp.go: -------------------------------------------------------------------------------- 1 | package servers 2 | 3 | import ( 4 | "errors" 5 | "io" 6 | "os" 7 | "path/filepath" 8 | "strings" 9 | 10 | tftp "github.com/pin/tftp/v3" 11 | "github.com/pojntfx/bofied/pkg/eventing" 12 | ) 13 | 14 | type TFTPServer struct { 15 | FileServer 16 | 17 | eventHandler *eventing.EventHandler 18 | } 19 | 20 | func NewTFTPServer(workingDir string, listenAddress string, eventHandler *eventing.EventHandler) *TFTPServer { 21 | return &TFTPServer{ 22 | FileServer: FileServer{ 23 | workingDir: workingDir, 24 | listenAddress: listenAddress, 25 | }, 26 | 27 | eventHandler: eventHandler, 28 | } 29 | } 30 | 31 | func (s *TFTPServer) ListenAndServe() error { 32 | h := tftp.NewServer( 33 | func(filename string, rf io.ReaderFrom) error { 34 | // Get remote IP 35 | raddr := rf.(tftp.OutgoingTransfer).RemoteAddr() 36 | 37 | // Prevent accessing any parent directories 38 | fullFilename := filepath.Join(s.workingDir, filename) 39 | if strings.Contains(filename, "..") { 40 | s.eventHandler.Emit(`could not send file: get request to file "%v" by client "%v" blocked because it is located outside the working directory "%v"`, fullFilename, raddr.String(), s.workingDir) 41 | 42 | return errors.New("unauthorized: tried to access file outside working directory") 43 | } 44 | 45 | // Open file to send 46 | file, err := os.Open(fullFilename) 47 | if err != nil { 48 | s.eventHandler.Emit(`could not open file "%v" for client "%v": %v`, fullFilename, raddr.String(), err) 49 | 50 | return err 51 | } 52 | 53 | // Send the file to the client 54 | n, err := rf.ReadFrom(file) 55 | if err != nil { 56 | s.eventHandler.Emit(`could not sent file "%v" to client "%v": %v`, fullFilename, raddr.String(), err) 57 | 58 | return err 59 | } 60 | 61 | s.eventHandler.Emit(`sent file "%v" (%v bytes) to client "%v"`, fullFilename, n, raddr.String()) 62 | 63 | return nil 64 | }, 65 | nil, 66 | ) 67 | 68 | return h.ListenAndServe(s.listenAddress) 69 | } 70 | -------------------------------------------------------------------------------- /pkg/servers/udp_server.go: -------------------------------------------------------------------------------- 1 | package servers 2 | 3 | import ( 4 | "errors" 5 | "log" 6 | "net" 7 | 8 | "github.com/pojntfx/bofied/pkg/utils" 9 | ) 10 | 11 | const ( 12 | UDPServerReadBufSize = 1024 13 | ) 14 | 15 | type UDPServer struct { 16 | listenAddress string 17 | advertisedIP string 18 | handlePacket func(conn *net.UDPConn, raddr *net.UDPAddr, braddr *net.UDPAddr, rawIncomingUDPPacket []byte) (int, error) 19 | } 20 | 21 | func (s *UDPServer) ListenAndServe() error { 22 | // Parse the addresses 23 | laddr, err := net.ResolveUDPAddr("udp", s.listenAddress) 24 | if err != nil { 25 | return err 26 | } 27 | 28 | aaddr, err := net.ResolveIPAddr("ip", s.advertisedIP) 29 | if err != nil { 30 | return err 31 | } 32 | 33 | // Get all interfaces 34 | ifaces, err := net.Interfaces() 35 | if err != nil { 36 | return err 37 | } 38 | 39 | // Find the broadcast address of the interface with the advertised IP 40 | broadcastIP := "" 41 | ifaceLoop: 42 | for _, iface := range ifaces { 43 | addrs, err := iface.Addrs() 44 | if err != nil { 45 | return err 46 | } 47 | 48 | for _, addr := range addrs { 49 | ipnet, ok := addr.(*net.IPNet) 50 | if !ok { 51 | continue 52 | } 53 | 54 | if ipnet.IP.Equal(aaddr.IP) { 55 | broadcastIP, err = utils.GetBroadcastAddress(ipnet) 56 | if err != nil { 57 | return err 58 | } 59 | 60 | break ifaceLoop 61 | } 62 | } 63 | } 64 | 65 | // Return if no interface with the advertised IP could be found 66 | if broadcastIP == "" { 67 | return errors.New("could not resolve broadcast IP") 68 | } 69 | 70 | // Construct the broadcast address 71 | braddr, err := net.ResolveUDPAddr("udp", net.JoinHostPort(broadcastIP, "68")) 72 | if err != nil { 73 | return err 74 | } 75 | 76 | // Listen 77 | conn, err := net.ListenUDP("udp", laddr) 78 | if err != nil { 79 | return err 80 | } 81 | 82 | // Loop over packets 83 | for { 84 | // Read packet into buffer 85 | buf := make([]byte, UDPServerReadBufSize) 86 | length, raddr, err := conn.ReadFromUDP(buf) 87 | if err != nil { 88 | return err 89 | } 90 | 91 | // Handle the read packet 92 | go func() { 93 | if _, err := s.handlePacket(conn, raddr, braddr, buf[:length]); err != nil { 94 | log.Println("could not handle packet:", err) 95 | } 96 | }() 97 | } 98 | } 99 | -------------------------------------------------------------------------------- /pkg/services/events.go: -------------------------------------------------------------------------------- 1 | package services 2 | 3 | //go:generate sh -c "mkdir -p ../api/proto/v1 && protoc --go_out=paths=source_relative:../api/proto/v1 --go-grpc_out=paths=source_relative:../api/proto/v1 -I=../../api/proto/v1 ../../api/proto/v1/*.proto" 4 | 5 | import ( 6 | "fmt" 7 | "log" 8 | "time" 9 | 10 | api "github.com/pojntfx/bofied/pkg/api/proto/v1" 11 | "github.com/pojntfx/bofied/pkg/eventing" 12 | "github.com/pojntfx/bofied/pkg/validators" 13 | "google.golang.org/grpc/codes" 14 | "google.golang.org/grpc/status" 15 | ) 16 | 17 | const ( 18 | AuthorizationMetadataKey = "X-Bofied-Authorization" 19 | ) 20 | 21 | type EventsService struct { 22 | api.UnimplementedEventsServiceServer 23 | 24 | eventsHandler *eventing.EventHandler 25 | 26 | contextValidator *validators.ContextValidator 27 | } 28 | 29 | func NewEventsService(eventsHandler *eventing.EventHandler, contextValidator *validators.ContextValidator) *EventsService { 30 | return &EventsService{ 31 | eventsHandler: eventsHandler, 32 | 33 | contextValidator: contextValidator, 34 | } 35 | } 36 | 37 | func (s *EventsService) SubscribeToEvents(_ *api.Empty, stream api.EventsService_SubscribeToEventsServer) error { 38 | // Authorize 39 | valid, err := s.contextValidator.Validate(stream.Context()) 40 | if err != nil || !valid { 41 | return status.Errorf(codes.Unauthenticated, "could not authorize: %v", err) 42 | } 43 | 44 | // Subscribe to events 45 | events, err := s.eventsHandler.Sub() 46 | if err != nil { 47 | msg := fmt.Sprintf("could not get events from messenger: %v", err) 48 | 49 | log.Println(msg) 50 | 51 | return status.Error(codes.Unknown, msg) 52 | } 53 | defer s.eventsHandler.Unsub(events) 54 | 55 | // Receive event from bus 56 | for event := range events { 57 | e := event.(eventing.Event) 58 | 59 | // Send event to client 60 | if err := stream.Send(&api.EventMessage{ 61 | CreatedAt: e.CreatedAt.Format(time.RFC3339), 62 | Message: e.Message, 63 | }); err != nil { 64 | log.Printf("could send event to client: %v\n", err) 65 | 66 | return err 67 | } 68 | } 69 | 70 | return nil 71 | } 72 | -------------------------------------------------------------------------------- /pkg/services/metadata.go: -------------------------------------------------------------------------------- 1 | package services 2 | 3 | //go:generate sh -c "mkdir -p ../api/proto/v1 && protoc --go_out=paths=source_relative:../api/proto/v1 --go-grpc_out=paths=source_relative:../api/proto/v1 -I=../../api/proto/v1 ../../api/proto/v1/*.proto" 4 | 5 | import ( 6 | "context" 7 | 8 | api "github.com/pojntfx/bofied/pkg/api/proto/v1" 9 | "github.com/pojntfx/bofied/pkg/validators" 10 | "google.golang.org/grpc/codes" 11 | "google.golang.org/grpc/status" 12 | ) 13 | 14 | type MetadataService struct { 15 | api.UnimplementedMetadataServiceServer 16 | 17 | advertisedIP string 18 | tftpPort int32 19 | httpPort int32 20 | 21 | contextValidator *validators.ContextValidator 22 | } 23 | 24 | func NewMetadataService( 25 | advertisedIP string, 26 | tftpPort int32, 27 | httpPort int32, 28 | contextValidator *validators.ContextValidator, 29 | ) *MetadataService { 30 | return &MetadataService{ 31 | advertisedIP: advertisedIP, 32 | tftpPort: tftpPort, 33 | httpPort: httpPort, 34 | 35 | contextValidator: contextValidator, 36 | } 37 | } 38 | 39 | func (s *MetadataService) GetMetadata(ctx context.Context, _ *api.Empty) (*api.MetadataMessage, error) { 40 | // Authorize 41 | valid, err := s.contextValidator.Validate(ctx) 42 | if err != nil || !valid { 43 | return nil, status.Errorf(codes.Unauthenticated, "could not authorize: %v", err) 44 | } 45 | 46 | // Return the constructed message 47 | return &api.MetadataMessage{ 48 | AdvertisedIP: s.advertisedIP, 49 | TFTPPort: s.tftpPort, 50 | HTTPPort: s.httpPort, 51 | }, nil 52 | } 53 | -------------------------------------------------------------------------------- /pkg/transcoding/dhcp.go: -------------------------------------------------------------------------------- 1 | package transcoding 2 | 3 | import ( 4 | "errors" 5 | "net" 6 | 7 | "github.com/google/gopacket" 8 | "github.com/google/gopacket/layers" 9 | ) 10 | 11 | const ( 12 | DHCPBaseClassID = "PXEClient" 13 | DHCPEFIArch = 7 // X86-64_EFI 14 | DHCPOptUUIDGUIDClientIdentifier = 97 // Option: (97) UUID/GUID-based Client Identifier 15 | DHCPRootPath = 17 // Option: (17) Root Path 16 | ) 17 | 18 | type DecodedDHCPPacket struct { 19 | IsPXE bool 20 | ClientHWAddr net.HardwareAddr 21 | Xid uint32 22 | ClientIdentifierOpt layers.DHCPOption 23 | Arch int 24 | } 25 | 26 | func DecodeDHCPPacket(rawIncomingUDPPacket []byte) (DecodedDHCPPacket, error) { 27 | // Decode and parse packet 28 | incomingUDPPacket := gopacket.NewPacket(rawIncomingUDPPacket, layers.LayerTypeDHCPv4, gopacket.Default) 29 | 30 | dhcpLayer := incomingUDPPacket.Layer(layers.LayerTypeDHCPv4) 31 | if dhcpLayer == nil { 32 | return DecodedDHCPPacket{}, errors.New("could not parse DHCP layer: not a DHCP layer") 33 | } 34 | 35 | incomingDHCPPacket, ok := dhcpLayer.(*layers.DHCPv4) 36 | if !ok { 37 | return DecodedDHCPPacket{}, errors.New("could not parse DHCP layer: invalid DHCP layer") 38 | } 39 | 40 | // Parse DHCP options 41 | isPXE := false 42 | arch := 0 43 | clientIdentifierOpt := layers.DHCPOption{} 44 | if incomingDHCPPacket.Operation == layers.DHCPOpRequest { 45 | for _, option := range incomingDHCPPacket.Options { 46 | switch option.Type { 47 | case layers.DHCPOptClassID: 48 | pxe, a, err := ParsePXEClassIdentifier(string(option.Data)) 49 | if err != nil { 50 | return DecodedDHCPPacket{}, err 51 | } 52 | 53 | isPXE = pxe 54 | arch = a 55 | case DHCPOptUUIDGUIDClientIdentifier: 56 | clientIdentifierOpt = option 57 | } 58 | } 59 | } 60 | 61 | return DecodedDHCPPacket{ 62 | IsPXE: isPXE, 63 | ClientHWAddr: incomingDHCPPacket.ClientHWAddr, 64 | Xid: incomingDHCPPacket.Xid, 65 | ClientIdentifierOpt: clientIdentifierOpt, 66 | Arch: arch, 67 | }, nil 68 | } 69 | 70 | func EncodeDHCPPacket( 71 | clientHWAddr net.HardwareAddr, 72 | xid uint32, 73 | advertisedIP net.IP, 74 | clientIdentifierOpt layers.DHCPOption, 75 | arch int, 76 | bootMenuPromptBIOS string, 77 | bootMenuDescriptionBIOS string, 78 | ) []byte { 79 | // Create the outgoing packet 80 | outgoingDHCPPacket := &layers.DHCPv4{ 81 | Operation: layers.DHCPOpReply, 82 | HardwareType: layers.LinkTypeEthernet, 83 | HardwareLen: uint8(len(clientHWAddr)), 84 | Xid: xid, 85 | ClientIP: net.ParseIP("0.0.0.0").To4(), 86 | YourClientIP: net.ParseIP("0.0.0.0").To4(), 87 | NextServerIP: advertisedIP, 88 | RelayAgentIP: net.ParseIP("0.0.0.0").To4(), 89 | ClientHWAddr: clientHWAddr, 90 | Options: layers.DHCPOptions{ 91 | layers.NewDHCPOption( 92 | layers.DHCPOptMessageType, 93 | []byte{byte(layers.DHCPMsgTypeOffer)}, 94 | ), 95 | layers.NewDHCPOption( 96 | layers.DHCPOptServerID, 97 | advertisedIP, 98 | ), 99 | layers.NewDHCPOption( 100 | layers.DHCPOptClassID, 101 | []byte(DHCPBaseClassID), 102 | ), 103 | layers.NewDHCPOption( 104 | DHCPRootPath, 105 | []byte(advertisedIP.String()), 106 | ), 107 | clientIdentifierOpt, 108 | }, 109 | } 110 | 111 | // If the packet is not intended for EFI systems, add additional required options 112 | if arch != DHCPEFIArch { 113 | // Create DHCP Option 43 suboptions 114 | subOptions := []layers.DHCPOption{ 115 | layers.NewDHCPOption( 116 | 6, // Option 43 Suboption: (6) PXE discovery control 117 | []byte{byte(0x00000003)}, // discovery control: 0x03, Disable Broadcast, Disable Multicast 118 | ), 119 | layers.NewDHCPOption( 120 | 10, // Option 43 Suboption: (10) PXE menu prompt 121 | append( // menu prompt: 00505845 122 | []byte{ 123 | 0x00, // Timeout: 0 124 | }, 125 | []byte(bootMenuPromptBIOS)..., // Prompt: PXE 126 | ), 127 | ), 128 | layers.NewDHCPOption( 129 | 8, // Option 43 Suboption: (8) PXE boot servers 130 | append( // boot servers: 80000164409af2 131 | []byte{ 132 | 0x80, 0x00, // Type: Unknown (32768) 133 | 0x01, // IP count: 1 134 | }, 135 | advertisedIP..., // IP: 100.64.154.246 136 | ), 137 | ), 138 | layers.NewDHCPOption( 139 | 9, // Option 43 Suboption: (9) PXE boot menu 140 | append( 141 | []byte{ 142 | 0x80, 0x00, // Type: Unknown (32768) 143 | byte(len(bootMenuDescriptionBIOS)), // Length: 16 144 | }, 145 | []byte(bootMenuDescriptionBIOS)..., // Description: Boot iPXE (BIOS) 146 | ), 147 | ), 148 | } 149 | 150 | // Serialize DHCP Option 43 suboptions 151 | serializedSubOptions := []byte{} 152 | for _, subOption := range subOptions { 153 | serializedSubOptions = append( 154 | serializedSubOptions, 155 | append( 156 | []byte{ 157 | byte(subOption.Type), 158 | subOption.Length, 159 | }, 160 | subOption.Data..., 161 | )..., 162 | ) 163 | } 164 | 165 | // Add DHCP Option 43 suboptions and set the next server IP to 0.0.0.0 166 | outgoingDHCPPacket.Options = append( 167 | outgoingDHCPPacket.Options, 168 | layers.NewDHCPOption( 169 | layers.DHCPOptVendorOption, 170 | serializedSubOptions, 171 | ), 172 | ) 173 | outgoingDHCPPacket.NextServerIP = net.ParseIP("0.0.0.0").To4() 174 | } 175 | 176 | // Serialize the outgoing packet 177 | buf := gopacket.NewSerializeBuffer() 178 | gopacket.SerializeLayers( 179 | buf, 180 | gopacket.SerializeOptions{ 181 | FixLengths: true, 182 | }, 183 | outgoingDHCPPacket, 184 | ) 185 | 186 | return buf.Bytes() 187 | } 188 | -------------------------------------------------------------------------------- /pkg/transcoding/proxy_dhcp.go: -------------------------------------------------------------------------------- 1 | package transcoding 2 | 3 | import ( 4 | "net" 5 | 6 | "github.com/google/gopacket" 7 | "github.com/google/gopacket/layers" 8 | ) 9 | 10 | func EncodeProxyDHCPPacket( 11 | clientHWAddr net.HardwareAddr, 12 | xid uint32, 13 | advertisedIP net.IP, 14 | clientIdentifierOpt layers.DHCPOption, 15 | yourClientIP net.IP, 16 | bootFileName string, 17 | ) []byte { 18 | // Create DHCP Option 43 suboptions 19 | subOption := 20 | layers.NewDHCPOption( 21 | 71, // Option 43 Suboption: (71) PXE boot item 22 | []byte{ // boot item: 80000000 23 | 0x80, 0x00, // Type: 32768 24 | 0x00, 0x00, // Layer: 0000 25 | }, 26 | ) 27 | 28 | // Serialize DHCP Option 43 suboptions 29 | serializedSubOptions := 30 | append( 31 | append( 32 | []byte{ 33 | byte(subOption.Type), 34 | subOption.Length, 35 | }, 36 | subOption.Data..., 37 | ), 38 | byte(0xff), // PXE Client End: 255 39 | ) 40 | 41 | // Create the outgoing packet 42 | outgoingDHCPPacket := &layers.DHCPv4{ 43 | Operation: layers.DHCPOpReply, 44 | HardwareType: layers.LinkTypeEthernet, 45 | HardwareLen: uint8(len(clientHWAddr)), 46 | Xid: xid, 47 | ClientIP: net.ParseIP("0.0.0.0").To4(), 48 | YourClientIP: yourClientIP, 49 | NextServerIP: advertisedIP, 50 | RelayAgentIP: net.ParseIP("0.0.0.0").To4(), 51 | ClientHWAddr: clientHWAddr, 52 | File: []byte(bootFileName), 53 | Options: layers.DHCPOptions{ 54 | layers.NewDHCPOption( 55 | layers.DHCPOptMessageType, 56 | []byte{byte(layers.DHCPMsgTypeAck)}, 57 | ), 58 | layers.NewDHCPOption( 59 | layers.DHCPOptServerID, 60 | advertisedIP, 61 | ), 62 | layers.NewDHCPOption( 63 | layers.DHCPOptClassID, 64 | []byte(DHCPBaseClassID), 65 | ), 66 | clientIdentifierOpt, 67 | layers.NewDHCPOption( 68 | layers.DHCPOptVendorOption, 69 | serializedSubOptions, 70 | ), 71 | layers.NewDHCPOption( 72 | DHCPRootPath, 73 | []byte(advertisedIP.String()), 74 | ), 75 | }, 76 | } 77 | 78 | // Serialize the outgoing packet 79 | buf := gopacket.NewSerializeBuffer() 80 | gopacket.SerializeLayers( 81 | buf, 82 | gopacket.SerializeOptions{ 83 | FixLengths: true, 84 | }, 85 | outgoingDHCPPacket, 86 | ) 87 | 88 | return buf.Bytes() 89 | } 90 | -------------------------------------------------------------------------------- /pkg/transcoding/pxe_class_identifier.go: -------------------------------------------------------------------------------- 1 | package transcoding 2 | 3 | import ( 4 | "strconv" 5 | "strings" 6 | ) 7 | 8 | func ParsePXEClassIdentifier(classID string) (isPXE bool, arch int, err error) { 9 | parts := strings.Split(classID, ":") 10 | 11 | for i, part := range parts { 12 | switch part { 13 | case "PXEClient": 14 | isPXE = true 15 | case "Arch": 16 | if len(parts) > i { 17 | arch, err = strconv.Atoi(parts[i+1]) 18 | if err != nil { 19 | return 20 | } 21 | } 22 | } 23 | } 24 | 25 | return 26 | } 27 | -------------------------------------------------------------------------------- /pkg/utils/ip.go: -------------------------------------------------------------------------------- 1 | package utils 2 | 3 | import ( 4 | "net" 5 | ) 6 | 7 | // Based on https://gist.github.com/kotakanbe/d3059af990252ba89a82 8 | func GetBroadcastAddress(ipnet *net.IPNet) (string, error) { 9 | ips := []string{} 10 | for ip := ipnet.IP.Mask(ipnet.Mask); ipnet.Contains(ip); inc(ip) { 11 | ips = append(ips, ip.String()) 12 | } 13 | 14 | // The last address is the broadcast address 15 | return ips[len(ips)-1], nil 16 | } 17 | 18 | // See http://play.golang.org/p/m8TNTtygK0 19 | func inc(ip net.IP) { 20 | for j := len(ip) - 1; j >= 0; j-- { 21 | ip[j]++ 22 | if ip[j] > 0 { 23 | break 24 | } 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /pkg/validators/check_syntax.go: -------------------------------------------------------------------------------- 1 | package validators 2 | 3 | import ( 4 | "go/parser" 5 | "go/token" 6 | ) 7 | 8 | func CheckGoSyntax(src string) error { 9 | _, err := parser.ParseFile(token.NewFileSet(), "", src, parser.ParseComments) 10 | 11 | return err 12 | } 13 | -------------------------------------------------------------------------------- /pkg/validators/context.go: -------------------------------------------------------------------------------- 1 | package validators 2 | 3 | import ( 4 | "context" 5 | "errors" 6 | "fmt" 7 | 8 | "google.golang.org/grpc/metadata" 9 | ) 10 | 11 | type ContextValidator struct { 12 | metadataKey string 13 | oidcValidator *OIDCValidator 14 | } 15 | 16 | func NewContextValidator(metadataKey string, oidcValidator *OIDCValidator) *ContextValidator { 17 | return &ContextValidator{ 18 | metadataKey: metadataKey, 19 | oidcValidator: oidcValidator, 20 | } 21 | } 22 | 23 | func (v *ContextValidator) Validate(ctx context.Context) (bool, error) { 24 | md, ok := metadata.FromIncomingContext(ctx) 25 | if !ok { 26 | return false, errors.New("could not parse metadata") 27 | } 28 | 29 | token := md.Get(v.metadataKey) 30 | if len(token) <= 0 { 31 | return false, errors.New("could not parse metadata") 32 | } 33 | 34 | idToken, err := v.oidcValidator.Validate(token[0]) 35 | if err != nil || idToken == nil { 36 | return false, fmt.Errorf("invalid token: %v", err) 37 | } 38 | 39 | return true, nil 40 | } 41 | -------------------------------------------------------------------------------- /pkg/validators/format.go: -------------------------------------------------------------------------------- 1 | package validators 2 | 3 | import "go/format" 4 | 5 | func FormatGoSrc(src string) (string, error) { 6 | s, err := format.Source([]byte(src)) 7 | if err != nil { 8 | return src, err 9 | } 10 | 11 | return string(s), err 12 | } 13 | -------------------------------------------------------------------------------- /pkg/validators/oidc.go: -------------------------------------------------------------------------------- 1 | package validators 2 | 3 | import ( 4 | "context" 5 | 6 | "github.com/coreos/go-oidc/v3/oidc" 7 | ) 8 | 9 | type OIDCValidator struct { 10 | Issuer string 11 | ClientID string 12 | 13 | verifier *oidc.IDTokenVerifier 14 | } 15 | 16 | func NewOIDCValidator(issuer string, clientID string) *OIDCValidator { 17 | return &OIDCValidator{ClientID: clientID, Issuer: issuer} 18 | } 19 | 20 | func (v *OIDCValidator) Open() error { 21 | provider, err := oidc.NewProvider(context.Background(), v.Issuer) 22 | if err != nil { 23 | return err 24 | } 25 | 26 | v.verifier = provider.Verifier(&oidc.Config{ClientID: v.ClientID}) 27 | 28 | return nil 29 | } 30 | 31 | func (v *OIDCValidator) Validate(token string) (*oidc.IDToken, error) { 32 | idToken, err := v.verifier.Verify(context.Background(), token) 33 | if err != nil { 34 | return nil, err 35 | } 36 | 37 | return idToken, nil 38 | } 39 | -------------------------------------------------------------------------------- /pkg/websocketproxy/client.go: -------------------------------------------------------------------------------- 1 | package websocketproxy 2 | 3 | import ( 4 | "context" 5 | "net" 6 | "time" 7 | 8 | "nhooyr.io/websocket" 9 | ) 10 | 11 | type WebSocketProxyClient struct { 12 | timeout time.Duration 13 | } 14 | 15 | func NewWebSocketProxyClient(timeout time.Duration) *WebSocketProxyClient { 16 | return &WebSocketProxyClient{timeout} 17 | } 18 | 19 | func (p *WebSocketProxyClient) Dialer(ctx context.Context, url string) (net.Conn, error) { 20 | ctx, cancel := context.WithTimeout(ctx, p.timeout) 21 | defer cancel() 22 | 23 | conn, _, err := websocket.Dial(ctx, url, nil) 24 | if err != nil { 25 | return nil, err 26 | } 27 | 28 | return websocket.NetConn(context.Background(), conn, websocket.MessageBinary), nil 29 | } 30 | -------------------------------------------------------------------------------- /pkg/websocketproxy/server.go: -------------------------------------------------------------------------------- 1 | package websocketproxy 2 | 3 | import ( 4 | "fmt" 5 | "log" 6 | "net" 7 | "net/http" 8 | 9 | "nhooyr.io/websocket" 10 | ) 11 | 12 | type WebSocketProxyServerAddr struct { 13 | network string 14 | address string 15 | } 16 | 17 | func (a *WebSocketProxyServerAddr) Network() string { 18 | return a.network 19 | } 20 | 21 | func (a *WebSocketProxyServerAddr) String() string { 22 | return a.address 23 | } 24 | 25 | type WebSocketProxyServer struct { 26 | stopChan chan struct{} 27 | errorChan chan error 28 | connectionChan chan net.Conn 29 | } 30 | 31 | func NewWebSocketProxyServer() *WebSocketProxyServer { 32 | return &WebSocketProxyServer{ 33 | stopChan: make(chan struct{}), 34 | errorChan: make(chan error, 1), 35 | connectionChan: make(chan net.Conn), 36 | } 37 | } 38 | 39 | func (p *WebSocketProxyServer) ServeHTTP(wr http.ResponseWriter, r *http.Request) { 40 | conn, err := websocket.Accept(wr, r, &websocket.AcceptOptions{ 41 | InsecureSkipVerify: true, // CORS 42 | }) 43 | if err != nil { 44 | log.Printf("could not accept on WebSocket: %v\n", err) 45 | 46 | return 47 | } 48 | defer conn.Close(websocket.StatusInternalError, "fail") 49 | 50 | ctx := r.Context() 51 | 52 | select { 53 | case <-p.stopChan: 54 | return 55 | 56 | default: 57 | p.connectionChan <- websocket.NetConn(ctx, conn, websocket.MessageBinary) 58 | 59 | select { 60 | case <-p.stopChan: 61 | case <-r.Context().Done(): 62 | } 63 | } 64 | 65 | conn.Close(websocket.StatusNormalClosure, "ok") 66 | } 67 | 68 | func (p *WebSocketProxyServer) Accept() (net.Conn, error) { 69 | select { 70 | case <-p.stopChan: 71 | return nil, fmt.Errorf("server stopped") 72 | 73 | case err := <-p.errorChan: 74 | _ = p.Close() 75 | 76 | return nil, err 77 | 78 | case c := <-p.connectionChan: 79 | return c, nil 80 | } 81 | } 82 | 83 | func (p *WebSocketProxyServer) Close() error { 84 | select { 85 | case <-p.stopChan: 86 | 87 | default: 88 | close(p.stopChan) 89 | } 90 | 91 | return nil 92 | } 93 | 94 | func (p *WebSocketProxyServer) Addr() net.Addr { 95 | return &WebSocketProxyServerAddr{} 96 | } 97 | -------------------------------------------------------------------------------- /web/icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/pojntfx/bofied/c07c42f01e293bb8dbc2db0b01b6dd9cdd3ed1ab/web/icon.png -------------------------------------------------------------------------------- /web/index.css: -------------------------------------------------------------------------------- 1 | :root { 2 | --pf-x-base-color: #0066cc; 3 | } 4 | 5 | @media (prefers-color-scheme: dark) { 6 | :root { 7 | --pf-x-base-color: #92c5f9; 8 | } 9 | } 10 | 11 | @supports (color: AccentColor) { 12 | :root { 13 | --pf-x-base-color: color-mix(in srgb, AccentColor 60%, black); 14 | } 15 | } 16 | 17 | @media (prefers-color-scheme: dark) { 18 | @supports (color: AccentColor) { 19 | :root { 20 | --pf-x-base-color: oklch(from AccentColor max(0.85, l) c h); 21 | } 22 | } 23 | } 24 | 25 | :root { 26 | --pf-t--global--color--brand--100: color-mix( 27 | in srgb, 28 | var(--pf-x-base-color) 80%, 29 | white 30 | ); 31 | --pf-t--global--color--brand--200: var(--pf-x-base-color); 32 | --pf-t--global--color--brand--300: color-mix( 33 | in srgb, 34 | var(--pf-x-base-color) 80%, 35 | black 36 | ); 37 | } 38 | 39 | @media (prefers-color-scheme: dark) { 40 | :root { 41 | --pf-t--global--color--brand--100: color-mix( 42 | in srgb, 43 | var(--pf-x-base-color) 80%, 44 | black 45 | ); 46 | --pf-t--global--color--brand--200: var(--pf-x-base-color); 47 | --pf-t--global--color--brand--300: color-mix( 48 | in srgb, 49 | var(--pf-x-base-color) 80%, 50 | white 51 | ); 52 | } 53 | } 54 | 55 | :root { 56 | --pf-t--global--color--severity--none--100: var( 57 | --pf-t--global--color--brand--100 58 | ); 59 | --pf-t--global--text--color--link--100: var( 60 | --pf-t--global--color--brand--100 61 | ); 62 | --pf-t--global--text--color--link--200: var( 63 | --pf-t--global--color--brand--200 64 | ); 65 | --pf-t--global--text--color--link--300: var( 66 | --pf-t--global--color--brand--300 67 | ); 68 | --pf-t--global--dark--color--brand--100: color-mix( 69 | in srgb, 70 | var(--pf-x-base-color) 80%, 71 | white 72 | ); 73 | --pf-t--global--dark--color--brand--200: var(--pf-x-base-color); 74 | --pf-t--global--dark--color--brand--300: color-mix( 75 | in srgb, 76 | var(--pf-x-base-color) 80%, 77 | black 78 | ); 79 | } 80 | 81 | @media (prefers-color-scheme: dark) { 82 | :root { 83 | --pf-t--global--dark--color--brand--100: color-mix( 84 | in srgb, 85 | var(--pf-x-base-color) 80%, 86 | black 87 | ); 88 | --pf-t--global--dark--color--brand--200: var(--pf-x-base-color); 89 | --pf-t--global--dark--color--brand--300: color-mix( 90 | in srgb, 91 | var(--pf-x-base-color) 80%, 92 | white 93 | ); 94 | } 95 | } 96 | 97 | :root { 98 | --pf-t--global--dark--color--severity--none--100: var( 99 | --pf-t--global--dark--color--brand--100 100 | ); 101 | --pf-t--global--dark--text--color--link--100: var( 102 | --pf-t--global--dark--color--brand--100 103 | ); 104 | --pf-t--global--dark--text--color--link--200: var( 105 | --pf-t--global--dark--color--brand--200 106 | ); 107 | --pf-t--global--dark--text--color--link--300: var( 108 | --pf-t--global--dark--color--brand--300 109 | ); 110 | } 111 | 112 | .pf-v6-x-c-brand--main { 113 | max-width: 12.5rem; 114 | } 115 | 116 | .pf-v6-x-c-brand--nav { 117 | height: 50px; 118 | } 119 | 120 | .pf-v6-x-ws-router { 121 | height: 100vh; 122 | } 123 | 124 | .pf-v6-x-u-resize-none { 125 | resize: none; 126 | } 127 | 128 | /* A nested dialog */ 129 | .pf-v6-x-m-modal-overlay { 130 | z-index: calc(var(--pf-v6-global--ZIndex--lg) + 1); 131 | } 132 | 133 | /* Backdrop which doesn't overlap */ 134 | .pf-v6-x-c-backdrop--nested { 135 | padding-top: var( 136 | --pf-v6-c-page__header--MinHeight 137 | ); /* TODO: This does depend on the navbar being no higher than the min value; in the future, object queries might be used. */ 138 | } 139 | 140 | .pf-v6-x-m-gap-md { 141 | gap: var(--pf-v6-global--spacer--md); 142 | } 143 | 144 | /* Prevent unnecessary vertical scrolling in variable height text editors */ 145 | .pf-v6-x-m-overflow-y-hidden { 146 | overflow-y: hidden; 147 | } 148 | 149 | /* Re-add old selectable card state styling for plain cards */ 150 | .pf-v6-c-card.pf-m-plain.pf-m-selectable { 151 | cursor: pointer; 152 | outline: var(--pf-v6-c-card--m-selectable--BorderWidth) solid 153 | var(--pf-v6-c-card--BorderColor, transparent); 154 | } 155 | 156 | .pf-v6-c-card.pf-m-plain.pf-m-selectable:hover, 157 | .pf-v6-c-card.pf-m-plain.pf-m-selectable:focus { 158 | --pf-v6-c-card--BorderColor: var( 159 | --pf-v6-c-card--m-selectable--hover--BorderColor 160 | ); 161 | } 162 | 163 | .pf-v6-x-u-position-absolute { 164 | position: absolute; 165 | } 166 | 167 | @media (max-width: 48rem) { 168 | .pf-v6-c-menu.pf-v6-x-u-position-absolute { 169 | left: 0; 170 | width: 100%; 171 | } 172 | } 173 | 174 | @media (prefers-color-scheme: light) { 175 | .pf-v6-c-brand--dark { 176 | display: none; 177 | } 178 | } 179 | 180 | @media (prefers-color-scheme: dark) { 181 | .pf-v6-c-brand--light { 182 | display: none; 183 | } 184 | } 185 | 186 | .pf-v6-x-dropdown-menu { 187 | z-index: 100; 188 | } 189 | 190 | .pf-v6-c-breadcrumb__list { 191 | flex-wrap: nowrap; 192 | } 193 | 194 | .pf-v6-c-breadcrumb__link { 195 | white-space: nowrap; 196 | } 197 | -------------------------------------------------------------------------------- /web/logo-dark.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/pojntfx/bofied/c07c42f01e293bb8dbc2db0b01b6dd9cdd3ed1ab/web/logo-dark.png -------------------------------------------------------------------------------- /web/logo-light.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/pojntfx/bofied/c07c42f01e293bb8dbc2db0b01b6dd9cdd3ed1ab/web/logo-light.png --------------------------------------------------------------------------------