mirror of
https://github.com/Maronato/go-finger.git
synced 2025-03-15 00:34:47 +00:00
initial
This commit is contained in:
commit
0a17831dae
15 changed files with 1308 additions and 0 deletions
169
.dockerignore
Normal file
169
.dockerignore
Normal file
|
@ -0,0 +1,169 @@
|
|||
# ---> DB
|
||||
*.db
|
||||
*.db-shm
|
||||
*.db-wal
|
||||
results.bin
|
||||
fingers.yml
|
||||
finger
|
||||
|
||||
# ---> Go
|
||||
# If you prefer the allow list template instead of the deny list, see community template:
|
||||
# https://github.com/github/gitignore/blob/main/community/Golang/Go.AllowList.gitignore
|
||||
#
|
||||
# Binaries for programs and plugins
|
||||
*.exe
|
||||
*.exe~
|
||||
*.dll
|
||||
*.so
|
||||
*.dylib
|
||||
|
||||
# Test binary, built with `go test -c`
|
||||
*.test
|
||||
|
||||
# Output of the go coverage tool, specifically when used with LiteIDE
|
||||
*.out
|
||||
|
||||
# Dependency directories (remove the comment below to include it)
|
||||
# vendor/
|
||||
|
||||
# Go workspace file
|
||||
go.work
|
||||
|
||||
/goshort
|
||||
|
||||
# ---> Node
|
||||
#
|
||||
# Logs
|
||||
logs
|
||||
*.log
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
lerna-debug.log*
|
||||
.pnpm-debug.log*
|
||||
|
||||
# Diagnostic reports (https://nodejs.org/api/report.html)
|
||||
report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json
|
||||
|
||||
# Runtime data
|
||||
pids
|
||||
*.pid
|
||||
*.seed
|
||||
*.pid.lock
|
||||
|
||||
# Directory for instrumented libs generated by jscoverage/JSCover
|
||||
lib-cov
|
||||
|
||||
# Coverage directory used by tools like istanbul
|
||||
coverage
|
||||
*.lcov
|
||||
|
||||
# nyc test coverage
|
||||
.nyc_output
|
||||
|
||||
# Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files)
|
||||
.grunt
|
||||
|
||||
# Bower dependency directory (https://bower.io/)
|
||||
bower_components
|
||||
|
||||
# node-waf configuration
|
||||
.lock-wscript
|
||||
|
||||
# Compiled binary addons (https://nodejs.org/api/addons.html)
|
||||
build/Release
|
||||
|
||||
# Dependency directories
|
||||
node_modules/
|
||||
jspm_packages/
|
||||
|
||||
# Snowpack dependency directory (https://snowpack.dev/)
|
||||
web_modules/
|
||||
|
||||
# TypeScript cache
|
||||
*.tsbuildinfo
|
||||
|
||||
# Optional npm cache directory
|
||||
.npm
|
||||
|
||||
# Optional eslint cache
|
||||
.eslintcache
|
||||
|
||||
# Optional stylelint cache
|
||||
.stylelintcache
|
||||
|
||||
# Microbundle cache
|
||||
.rpt2_cache/
|
||||
.rts2_cache_cjs/
|
||||
.rts2_cache_es/
|
||||
.rts2_cache_umd/
|
||||
|
||||
# Optional REPL history
|
||||
.node_repl_history
|
||||
|
||||
# Output of 'npm pack'
|
||||
*.tgz
|
||||
|
||||
# Yarn Integrity file
|
||||
.yarn-integrity
|
||||
|
||||
# dotenv environment variable files
|
||||
.env
|
||||
.env.development.local
|
||||
.env.test.local
|
||||
.env.production.local
|
||||
.env.local
|
||||
|
||||
# parcel-bundler cache (https://parceljs.org/)
|
||||
.cache
|
||||
.parcel-cache
|
||||
|
||||
# Next.js build output
|
||||
.next
|
||||
out
|
||||
|
||||
# Nuxt.js build / generate output
|
||||
.nuxt
|
||||
dist
|
||||
|
||||
# Gatsby files
|
||||
.cache/
|
||||
# Comment in the public line in if your project uses Gatsby and not Next.js
|
||||
# https://nextjs.org/blog/next-9-1#public-directory-support
|
||||
# public
|
||||
|
||||
# vuepress build output
|
||||
.vuepress/dist
|
||||
|
||||
# vuepress v2.x temp and cache directory
|
||||
.temp
|
||||
.cache
|
||||
|
||||
# Docusaurus cache and generated files
|
||||
.docusaurus
|
||||
|
||||
# Serverless directories
|
||||
.serverless/
|
||||
|
||||
# FuseBox cache
|
||||
.fusebox/
|
||||
|
||||
# DynamoDB Local files
|
||||
.dynamodb/
|
||||
|
||||
# TernJS port file
|
||||
.tern-port
|
||||
|
||||
# Stores VSCode versions used for testing VSCode extensions
|
||||
.vscode-test
|
||||
|
||||
# yarn v2
|
||||
.yarn/cache
|
||||
.yarn/unplugged
|
||||
.yarn/build-state.yml
|
||||
.yarn/install-state.gz
|
||||
.pnp.*
|
||||
|
||||
# >> Git and github
|
||||
.github
|
||||
.git
|
32
.github/workflows/go.yml
vendored
Normal file
32
.github/workflows/go.yml
vendored
Normal file
|
@ -0,0 +1,32 @@
|
|||
name: Go
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
pull_request:
|
||||
branches:
|
||||
- main
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
checks:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v3
|
||||
- uses: actions/setup-go@v4
|
||||
with:
|
||||
go-version: "1.21"
|
||||
cache: false
|
||||
|
||||
- name: golangci-lint
|
||||
uses: golangci/golangci-lint-action@v3
|
||||
with:
|
||||
version: v1.54
|
||||
|
||||
- name: build
|
||||
run: make build
|
||||
|
||||
- name: test
|
||||
run: make test
|
84
.github/workflows/release.yml
vendored
Normal file
84
.github/workflows/release.yml
vendored
Normal file
|
@ -0,0 +1,84 @@
|
|||
name: Release
|
||||
# Controls when the workflow will run
|
||||
on:
|
||||
release:
|
||||
types:
|
||||
- published
|
||||
|
||||
jobs:
|
||||
buildpush:
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: read
|
||||
packages: write
|
||||
steps:
|
||||
- # Get the repository's code
|
||||
name: Checkout
|
||||
uses: actions/checkout@v2
|
||||
- # https://github.com/vegardit/docker-gitea-act-runner/issues/23
|
||||
name: Fix docker sock permissions
|
||||
run: sudo chmod 666 /var/run/docker.sock
|
||||
- # https://github.com/docker/setup-qemu-action
|
||||
name: Set up QEMU
|
||||
uses: docker/setup-qemu-action@v1
|
||||
- # https://github.com/docker/setup-buildx-action
|
||||
name: Set up Docker Buildx
|
||||
id: buildx
|
||||
uses: docker/setup-buildx-action@v1
|
||||
- # https://github.com/docker/login-action
|
||||
name: Log in to the Container registry
|
||||
uses: docker/login-action@v2
|
||||
with:
|
||||
# Maybe there is a default env var for this?
|
||||
registry: git.maronato.dev
|
||||
username: ${{ github.repository_owner }}}
|
||||
# Ideally, we should only need to set "permissions: package: write", but
|
||||
# Gitea is having issues with that. For now, this is a manually created
|
||||
# token available user-wise, with the "package:write" permission.
|
||||
password: ${{ secrets.PACKAGE_WRITE_TOKEN }}
|
||||
- # https://github.com/docker/metadata-action
|
||||
# Generate tags and labels for the image
|
||||
# according to the commit and the branch
|
||||
name: Docker meta
|
||||
id: meta
|
||||
uses: docker/metadata-action@v4
|
||||
with:
|
||||
# The container image name needs the custom registry in it.
|
||||
# Maybe there is a default env var for this?
|
||||
images: git.maronato.dev/${{ github.repository }}
|
||||
tags: |
|
||||
type=ref,event=branch
|
||||
type=ref,event=pr
|
||||
type=semver,pattern={{version}}
|
||||
type=semver,pattern={{major}}.{{minor}}
|
||||
- # httos://github.com/actions/cache
|
||||
name: Cache Docker layers
|
||||
uses: actions/cache@v3
|
||||
with:
|
||||
path: |
|
||||
/go/pkg/mod/
|
||||
/tmp/.go-build-cache
|
||||
/tmp/.buildx-cache
|
||||
key: ${{ runner.os }}-buildx-${{ github.sha }}
|
||||
restore-keys: |
|
||||
${{ runner.os }}-buildx-
|
||||
- # https://github.com/docker/build-push-action
|
||||
name: Build and push
|
||||
uses: docker/build-push-action@v2
|
||||
with:
|
||||
context: .
|
||||
build-args: |
|
||||
VERSION=${{ steps.meta.outputs.version }}
|
||||
platforms: linux/amd64,linux/arm64
|
||||
push: ${{ github.event_name != 'pull_request' }}
|
||||
tags: ${{ steps.meta.outputs.tags }}
|
||||
labels: ${{ steps.meta.outputs.labels }}
|
||||
cache-from: type=local,src=/tmp/.buildx-cache
|
||||
cache-to: type=local,dest=/tmp/.buildx-cache-new,mode=max
|
||||
- # Temp fix
|
||||
# https://github.com/docker/build-push-action/issues/252
|
||||
# https://github.com/moby/buildkit/issues/1896
|
||||
name: Move cache
|
||||
run: |
|
||||
rm -rf /tmp/.buildx-cache
|
||||
mv /tmp/.buildx-cache-new /tmp/.buildx-cache
|
165
.gitignore
vendored
Normal file
165
.gitignore
vendored
Normal file
|
@ -0,0 +1,165 @@
|
|||
# ---> DB
|
||||
*.db
|
||||
*.db-shm
|
||||
*.db-wal
|
||||
results.bin
|
||||
fingers.yml
|
||||
finger
|
||||
|
||||
# ---> Go
|
||||
# If you prefer the allow list template instead of the deny list, see community template:
|
||||
# https://github.com/github/gitignore/blob/main/community/Golang/Go.AllowList.gitignore
|
||||
#
|
||||
# Binaries for programs and plugins
|
||||
*.exe
|
||||
*.exe~
|
||||
*.dll
|
||||
*.so
|
||||
*.dylib
|
||||
|
||||
# Test binary, built with `go test -c`
|
||||
*.test
|
||||
|
||||
# Output of the go coverage tool, specifically when used with LiteIDE
|
||||
*.out
|
||||
|
||||
# Dependency directories (remove the comment below to include it)
|
||||
# vendor/
|
||||
|
||||
# Go workspace file
|
||||
go.work
|
||||
|
||||
/goshort
|
||||
|
||||
# ---> Node
|
||||
#
|
||||
# Logs
|
||||
logs
|
||||
*.log
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
lerna-debug.log*
|
||||
.pnpm-debug.log*
|
||||
|
||||
# Diagnostic reports (https://nodejs.org/api/report.html)
|
||||
report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json
|
||||
|
||||
# Runtime data
|
||||
pids
|
||||
*.pid
|
||||
*.seed
|
||||
*.pid.lock
|
||||
|
||||
# Directory for instrumented libs generated by jscoverage/JSCover
|
||||
lib-cov
|
||||
|
||||
# Coverage directory used by tools like istanbul
|
||||
coverage
|
||||
*.lcov
|
||||
|
||||
# nyc test coverage
|
||||
.nyc_output
|
||||
|
||||
# Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files)
|
||||
.grunt
|
||||
|
||||
# Bower dependency directory (https://bower.io/)
|
||||
bower_components
|
||||
|
||||
# node-waf configuration
|
||||
.lock-wscript
|
||||
|
||||
# Compiled binary addons (https://nodejs.org/api/addons.html)
|
||||
build/Release
|
||||
|
||||
# Dependency directories
|
||||
node_modules/
|
||||
jspm_packages/
|
||||
|
||||
# Snowpack dependency directory (https://snowpack.dev/)
|
||||
web_modules/
|
||||
|
||||
# TypeScript cache
|
||||
*.tsbuildinfo
|
||||
|
||||
# Optional npm cache directory
|
||||
.npm
|
||||
|
||||
# Optional eslint cache
|
||||
.eslintcache
|
||||
|
||||
# Optional stylelint cache
|
||||
.stylelintcache
|
||||
|
||||
# Microbundle cache
|
||||
.rpt2_cache/
|
||||
.rts2_cache_cjs/
|
||||
.rts2_cache_es/
|
||||
.rts2_cache_umd/
|
||||
|
||||
# Optional REPL history
|
||||
.node_repl_history
|
||||
|
||||
# Output of 'npm pack'
|
||||
*.tgz
|
||||
|
||||
# Yarn Integrity file
|
||||
.yarn-integrity
|
||||
|
||||
# dotenv environment variable files
|
||||
.env
|
||||
.env.development.local
|
||||
.env.test.local
|
||||
.env.production.local
|
||||
.env.local
|
||||
|
||||
# parcel-bundler cache (https://parceljs.org/)
|
||||
.cache
|
||||
.parcel-cache
|
||||
|
||||
# Next.js build output
|
||||
.next
|
||||
out
|
||||
|
||||
# Nuxt.js build / generate output
|
||||
.nuxt
|
||||
dist
|
||||
|
||||
# Gatsby files
|
||||
.cache/
|
||||
# Comment in the public line in if your project uses Gatsby and not Next.js
|
||||
# https://nextjs.org/blog/next-9-1#public-directory-support
|
||||
# public
|
||||
|
||||
# vuepress build output
|
||||
.vuepress/dist
|
||||
|
||||
# vuepress v2.x temp and cache directory
|
||||
.temp
|
||||
.cache
|
||||
|
||||
# Docusaurus cache and generated files
|
||||
.docusaurus
|
||||
|
||||
# Serverless directories
|
||||
.serverless/
|
||||
|
||||
# FuseBox cache
|
||||
.fusebox/
|
||||
|
||||
# DynamoDB Local files
|
||||
.dynamodb/
|
||||
|
||||
# TernJS port file
|
||||
.tern-port
|
||||
|
||||
# Stores VSCode versions used for testing VSCode extensions
|
||||
.vscode-test
|
||||
|
||||
# yarn v2
|
||||
.yarn/cache
|
||||
.yarn/unplugged
|
||||
.yarn/build-state.yml
|
||||
.yarn/install-state.gz
|
||||
.pnp.*
|
104
.golangci.yml
Normal file
104
.golangci.yml
Normal file
|
@ -0,0 +1,104 @@
|
|||
linters:
|
||||
disable-all: true
|
||||
enable:
|
||||
- asciicheck
|
||||
- bidichk
|
||||
- bodyclose
|
||||
- containedctx
|
||||
- contextcheck
|
||||
# - cyclop
|
||||
- decorder
|
||||
# - dogsled
|
||||
# - dupl
|
||||
- durationcheck
|
||||
- errcheck
|
||||
- errchkjson
|
||||
- errname
|
||||
- errorlint
|
||||
- execinquery
|
||||
- exhaustive
|
||||
- exportloopref
|
||||
- forbidigo
|
||||
- forcetypeassert
|
||||
- gci
|
||||
- gochecknoglobals
|
||||
- gochecknoinits
|
||||
# - gocognit
|
||||
- goconst
|
||||
- gocritic
|
||||
- gocyclo
|
||||
- godot
|
||||
- goerr113
|
||||
- gofmt
|
||||
- gofumpt
|
||||
- goheader
|
||||
- goimports
|
||||
- gomnd
|
||||
- gomodguard
|
||||
- gosec
|
||||
- gosimple
|
||||
- gosmopolitan
|
||||
- govet
|
||||
- importas
|
||||
- ineffassign
|
||||
- ireturn
|
||||
- loggercheck
|
||||
- maintidx
|
||||
- makezero
|
||||
- mirror
|
||||
- misspell
|
||||
- musttag
|
||||
- nakedret
|
||||
- nestif
|
||||
- nilerr
|
||||
- nilnil
|
||||
- nlreturn
|
||||
- noctx
|
||||
- nolintlint
|
||||
- nosprintfhostport
|
||||
- prealloc
|
||||
- predeclared
|
||||
- promlinter
|
||||
- reassign
|
||||
- revive
|
||||
- rowserrcheck
|
||||
- sqlclosecheck
|
||||
- staticcheck
|
||||
- stylecheck
|
||||
- tagalign
|
||||
- tagliatelle
|
||||
- testpackage
|
||||
- thelper
|
||||
- tparallel
|
||||
- typecheck
|
||||
- unconvert
|
||||
- unparam
|
||||
- unused
|
||||
- usestdlibvars
|
||||
# - varnamelen
|
||||
- wastedassign
|
||||
- whitespace
|
||||
- wrapcheck
|
||||
- wsl
|
||||
|
||||
linters-settings:
|
||||
varnamelen:
|
||||
min-name-length: 2
|
||||
ignore-decls:
|
||||
- l *zap.Logger
|
||||
- l *slog.Logger
|
||||
- l slog.Logger
|
||||
- w http.ResponseWriter
|
||||
- r chi.Router
|
||||
- fs *flag.FlagSet
|
||||
- r *http.Request
|
||||
- eg *errgroup.Group
|
||||
- sw *statuswriter.StatusWriter
|
||||
- db *bun.DB
|
||||
gocritic:
|
||||
enabled-tags:
|
||||
- diagnostic
|
||||
- style
|
||||
- performance
|
||||
- experimental
|
||||
- opinionated
|
1
.tool-versions
Normal file
1
.tool-versions
Normal file
|
@ -0,0 +1 @@
|
|||
golang 1.21.0
|
3
.vscode/settings.json
vendored
Normal file
3
.vscode/settings.json
vendored
Normal file
|
@ -0,0 +1,3 @@
|
|||
{
|
||||
"editor.tabSize": 2
|
||||
}
|
48
Dockerfile
Normal file
48
Dockerfile
Normal file
|
@ -0,0 +1,48 @@
|
|||
# Load golang image
|
||||
FROM golang:1.21-alpine as builder
|
||||
|
||||
RUN apk add make
|
||||
|
||||
ARG VERSION=undefined
|
||||
|
||||
WORKDIR /go/src/app
|
||||
|
||||
# Set our build environment
|
||||
ENV GOCACHE=/tmp/.go-build-cache
|
||||
# This variable communicates to the service that it's running inside
|
||||
# a docker container.
|
||||
ENV ENV_DOCKER=true
|
||||
|
||||
# Copy dockerignore files
|
||||
COPY .dockerignore ./
|
||||
|
||||
# Install go deps using the cache
|
||||
COPY go.mod go.sum ./
|
||||
RUN --mount=type=cache,target=/tmp/.go-build-cache \
|
||||
go mod download -x
|
||||
|
||||
COPY Makefile ./
|
||||
|
||||
# Copy source files
|
||||
COPY main.go ./
|
||||
|
||||
# Build it
|
||||
RUN --mount=type=cache,target=/tmp/.go-build-cache \
|
||||
make backend VERSION=$VERSION
|
||||
|
||||
# Now create a new image with just the binary
|
||||
FROM gcr.io/distroless/static-debian11:nonroot
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
# Set our runtime environment
|
||||
ENV ENV_DOCKER=true
|
||||
|
||||
COPY --from=builder /go/src/app/finger /usr/local/bin/finger
|
||||
|
||||
HEALTHCHECK CMD [ "finger", "healthcheck" ]
|
||||
|
||||
EXPOSE 8080
|
||||
|
||||
ENTRYPOINT [ "finger" ]
|
||||
CMD [ "serve" ]
|
23
Makefile
Normal file
23
Makefile
Normal file
|
@ -0,0 +1,23 @@
|
|||
BINARY_NAME=finger
|
||||
VERSION=$(shell git describe --tags --abbrev=0 || echo "undefined")
|
||||
|
||||
all: lint build test
|
||||
|
||||
build:
|
||||
go build -ldflags="-X 'main.version=${VERSION}'" -o ${BINARY_NAME} main.go
|
||||
|
||||
test:
|
||||
go test -v ./...
|
||||
|
||||
serve:
|
||||
go run main.go serve
|
||||
|
||||
clean:
|
||||
go clean
|
||||
rm ${BINARY_NAME}
|
||||
|
||||
lint:
|
||||
golangci-lint run
|
||||
|
||||
lint-fix:
|
||||
golangci-lint run --fix
|
0
README.md
Normal file
0
README.md
Normal file
10
go.mod
Normal file
10
go.mod
Normal file
|
@ -0,0 +1,10 @@
|
|||
module git.maronato.dev/maronato/finger
|
||||
|
||||
go 1.21.0
|
||||
|
||||
require (
|
||||
github.com/peterbourgon/ff/v4 v4.0.0-alpha.3
|
||||
golang.org/x/exp v0.0.0-20230905200255-921286631fa9
|
||||
golang.org/x/sync v0.3.0
|
||||
gopkg.in/yaml.v3 v3.0.1
|
||||
)
|
14
go.sum
Normal file
14
go.sum
Normal file
|
@ -0,0 +1,14 @@
|
|||
github.com/pelletier/go-toml/v2 v2.0.9 h1:uH2qQXheeefCCkuBBSLi7jCiSmj3VRh2+Goq2N7Xxu0=
|
||||
github.com/pelletier/go-toml/v2 v2.0.9/go.mod h1:tJU2Z3ZkXwnxa4DPO899bsyIoywizdUvyaeZurnPPDc=
|
||||
github.com/peterbourgon/ff/v4 v4.0.0-alpha.3 h1:fpyiFVEJvxIFljxM4l5ANSk/UGlM1gyU+hPAr9jhB7M=
|
||||
github.com/peterbourgon/ff/v4 v4.0.0-alpha.3/go.mod h1:H/13DK46DKXy7EaIxPhk2Y0EC8aubKm35nBjBe8AAGc=
|
||||
golang.org/x/exp v0.0.0-20230905200255-921286631fa9 h1:GoHiUyI/Tp2nVkLI2mCxVkOjsbSXD66ic0XW0js0R9g=
|
||||
golang.org/x/exp v0.0.0-20230905200255-921286631fa9/go.mod h1:S2oDrQGGwySpoQPVqRShND87VCbxmc6bL1Yd2oYrm6k=
|
||||
golang.org/x/sync v0.3.0 h1:ftCYgMx6zT/asHUrPw8BLLscYtGznsLAnjq5RH9P66E=
|
||||
golang.org/x/sync v0.3.0/go.mod h1:FU7BRWz2tNW+3quACPkgCx/L+uEAv1htQ0V83Z9Rj+Y=
|
||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
|
||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY=
|
||||
gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ=
|
||||
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
570
main.go
Normal file
570
main.go
Normal file
|
@ -0,0 +1,570 @@
|
|||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/mail"
|
||||
"net/url"
|
||||
"os"
|
||||
"os/signal"
|
||||
"syscall"
|
||||
"time"
|
||||
|
||||
"github.com/peterbourgon/ff/v4"
|
||||
"github.com/peterbourgon/ff/v4/ffhelp"
|
||||
"golang.org/x/exp/slog"
|
||||
"golang.org/x/sync/errgroup"
|
||||
"gopkg.in/yaml.v3"
|
||||
)
|
||||
|
||||
const appName = "finger"
|
||||
|
||||
// Version of the application.
|
||||
var version = "dev"
|
||||
|
||||
func main() {
|
||||
// Run the server
|
||||
if err := Run(); err != nil {
|
||||
fmt.Fprintf(os.Stderr, "%v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
}
|
||||
|
||||
func Run() error {
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
defer cancel()
|
||||
|
||||
// Allow graceful shutdown
|
||||
trapSignalsCrossPlatform(cancel)
|
||||
|
||||
cfg := &Config{}
|
||||
|
||||
// Create a logger and add it to the context
|
||||
l := NewLogger(cfg)
|
||||
ctx = WithLogger(ctx, l)
|
||||
|
||||
// Create a new root command
|
||||
subcommands := []*ff.Command{
|
||||
NewServerCmd(cfg),
|
||||
NewHealthcheckCmd(cfg),
|
||||
}
|
||||
cmd := NewRootCmd(cfg, subcommands)
|
||||
|
||||
// Parse and run
|
||||
if err := cmd.ParseAndRun(ctx, os.Args[1:], ff.WithEnvVarPrefix("WF")); err != nil {
|
||||
if errors.Is(err, ff.ErrHelp) || errors.Is(err, ff.ErrNoExec) {
|
||||
fmt.Fprintf(os.Stderr, "\n%s\n", ffhelp.Command(cmd))
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
return fmt.Errorf("error running command: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func NewServerCmd(cfg *Config) *ff.Command {
|
||||
return &ff.Command{
|
||||
Name: "serve",
|
||||
Usage: "serve [flags]",
|
||||
ShortHelp: "Start the webfinger server",
|
||||
Exec: func(ctx context.Context, args []string) error {
|
||||
l := LoggerFromContext(ctx)
|
||||
|
||||
// Parse the webfinger files
|
||||
fingermap, err := ParseFingerFile(ctx, cfg)
|
||||
if err != nil {
|
||||
return fmt.Errorf("error parsing finger files: %w", err)
|
||||
}
|
||||
|
||||
l.Info(fmt.Sprintf("Loaded %d webfingers", len(fingermap)))
|
||||
|
||||
// Start the server
|
||||
if err := StartServer(ctx, cfg, fingermap); err != nil {
|
||||
return fmt.Errorf("error running server: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func NewHealthcheckCmd(cfg *Config) *ff.Command {
|
||||
return &ff.Command{
|
||||
Name: "healthcheck",
|
||||
Usage: "healthcheck [flags]",
|
||||
ShortHelp: "Check if the server is running",
|
||||
Exec: func(ctx context.Context, args []string) error {
|
||||
// Create a new client
|
||||
client := &http.Client{
|
||||
Timeout: 5 * time.Second, //nolint:gomnd // We want to use a constant
|
||||
}
|
||||
|
||||
// Create a new request
|
||||
reqURL := url.URL{
|
||||
Scheme: "http",
|
||||
Host: net.JoinHostPort(cfg.Host, cfg.Port),
|
||||
Path: "/healthz",
|
||||
}
|
||||
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, reqURL.String(), http.NoBody)
|
||||
if err != nil {
|
||||
return fmt.Errorf("error creating request: %w", err)
|
||||
}
|
||||
|
||||
// Send the request
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
return fmt.Errorf("error sending request: %w", err)
|
||||
}
|
||||
|
||||
defer resp.Body.Close()
|
||||
|
||||
// Check the response
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return fmt.Errorf("server returned status %d", resp.StatusCode) //nolint:goerr113 // We want to return an error
|
||||
}
|
||||
|
||||
return nil
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
type loggerCtxKey struct{}
|
||||
|
||||
// NewLogger creates a new logger with the given debug level.
|
||||
func NewLogger(cfg *Config) *slog.Logger {
|
||||
level := slog.LevelInfo
|
||||
addSource := false
|
||||
|
||||
if cfg.Debug {
|
||||
level = slog.LevelDebug
|
||||
addSource = true
|
||||
}
|
||||
|
||||
return slog.New(
|
||||
slog.NewJSONHandler(os.Stderr, &slog.HandlerOptions{
|
||||
Level: level,
|
||||
AddSource: addSource,
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
func LoggerFromContext(ctx context.Context) *slog.Logger {
|
||||
l, ok := ctx.Value(loggerCtxKey{}).(*slog.Logger)
|
||||
if !ok {
|
||||
panic("logger not found in context")
|
||||
}
|
||||
|
||||
return l
|
||||
}
|
||||
|
||||
func WithLogger(ctx context.Context, l *slog.Logger) context.Context {
|
||||
return context.WithValue(ctx, loggerCtxKey{}, l)
|
||||
}
|
||||
|
||||
// https://github.com/caddyserver/caddy/blob/fbb0ecfa322aa7710a3448453fd3ae40f037b8d1/sigtrap.go#L37
|
||||
// trapSignalsCrossPlatform captures SIGINT or interrupt (depending
|
||||
// on the OS), which initiates a graceful shutdown. A second SIGINT
|
||||
// or interrupt will forcefully exit the process immediately.
|
||||
func trapSignalsCrossPlatform(cancel context.CancelFunc) {
|
||||
go func() {
|
||||
shutdown := make(chan os.Signal, 1)
|
||||
signal.Notify(shutdown, os.Interrupt, syscall.SIGINT)
|
||||
|
||||
for i := 0; true; i++ {
|
||||
<-shutdown
|
||||
|
||||
if i > 0 {
|
||||
fmt.Printf("\nForce quit\n") //nolint:forbidigo // We want to print to stdout
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
fmt.Printf("\nGracefully shutting down. Press Ctrl+C again to force quit\n") //nolint:forbidigo // We want to print to stdout
|
||||
cancel()
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
type Config struct {
|
||||
Debug bool
|
||||
Host string
|
||||
Port string
|
||||
urnPath string
|
||||
fingerPath string
|
||||
}
|
||||
|
||||
// NewRootCmd parses the command line flags and returns a Config struct.
|
||||
func NewRootCmd(cfg *Config, subcommands []*ff.Command) *ff.Command {
|
||||
fs := ff.NewFlagSet(appName)
|
||||
|
||||
for _, cmd := range subcommands {
|
||||
cmd.Flags = ff.NewFlagSet(cmd.Name).SetParent(fs)
|
||||
}
|
||||
|
||||
cmd := &ff.Command{
|
||||
Name: appName,
|
||||
Usage: fmt.Sprintf("%s <command> [flags]", appName),
|
||||
ShortHelp: fmt.Sprintf("(%s) A webfinger server", version),
|
||||
Flags: fs,
|
||||
Subcommands: subcommands,
|
||||
}
|
||||
|
||||
// Use 0.0.0.0 as the default host if on docker
|
||||
defaultHost := "localhost"
|
||||
if os.Getenv("ENV_DOCKER") == "true" {
|
||||
defaultHost = "0.0.0.0"
|
||||
}
|
||||
|
||||
fs.BoolVar(&cfg.Debug, 'd', "debug", "Enable debug logging")
|
||||
fs.StringVar(&cfg.Host, 'h', "host", defaultHost, "Host to listen on")
|
||||
fs.StringVar(&cfg.Port, 'p', "port", "8080", "Port to listen on")
|
||||
fs.StringVar(&cfg.urnPath, 'u', "urn-file", "urns.yml", "Path to the URNs file")
|
||||
fs.StringVar(&cfg.fingerPath, 'f', "finger-file", "fingers.yml", "Path to the fingers file")
|
||||
|
||||
return cmd
|
||||
}
|
||||
|
||||
type Link struct {
|
||||
Rel string `json:"rel"`
|
||||
Href string `json:"href,omitempty"`
|
||||
}
|
||||
|
||||
type WebFinger struct {
|
||||
Subject string `json:"subject"`
|
||||
Links []Link `json:"links,omitempty"`
|
||||
Properties map[string]string `json:"properties,omitempty"`
|
||||
}
|
||||
|
||||
type WebFingerMap map[string]*WebFinger
|
||||
|
||||
func ParseFingerFile(ctx context.Context, cfg *Config) (WebFingerMap, error) {
|
||||
l := LoggerFromContext(ctx)
|
||||
|
||||
urnMap := make(map[string]string)
|
||||
fingerData := make(map[string]map[string]string)
|
||||
|
||||
fingermap := make(WebFingerMap)
|
||||
|
||||
// Read URNs file
|
||||
file, err := os.ReadFile(cfg.urnPath)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("error opening URNs file: %w", err)
|
||||
}
|
||||
|
||||
if err := yaml.Unmarshal(file, &urnMap); err != nil {
|
||||
return nil, fmt.Errorf("error unmarshalling URNs file: %w", err)
|
||||
}
|
||||
|
||||
// The URNs file must be a map of strings to valid URLs
|
||||
for _, v := range urnMap {
|
||||
if _, err := url.Parse(v); err != nil {
|
||||
return nil, fmt.Errorf("error parsing URN URIs: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
l.Debug("URNs file parsed successfully", slog.Int("number", len(urnMap)), slog.Any("data", urnMap))
|
||||
|
||||
// Read webfingers file
|
||||
file, err = os.ReadFile(cfg.fingerPath)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("error opening fingers file: %w", err)
|
||||
}
|
||||
|
||||
if err := yaml.Unmarshal(file, &fingerData); err != nil {
|
||||
return nil, fmt.Errorf("error unmarshalling fingers file: %w", err)
|
||||
}
|
||||
|
||||
l.Debug("Fingers file parsed successfully", slog.Int("number", len(fingerData)), slog.Any("data", fingerData))
|
||||
|
||||
// Parse the webfinger file
|
||||
for k, v := range fingerData {
|
||||
resource := k
|
||||
|
||||
// Remove leading acct: if present
|
||||
if len(k) > 5 && resource[:5] == "acct:" {
|
||||
resource = resource[5:]
|
||||
}
|
||||
|
||||
// The key must be a URL or email address
|
||||
if _, err := mail.ParseAddress(resource); err != nil {
|
||||
if _, err := url.Parse(resource); err != nil {
|
||||
return nil, fmt.Errorf("error parsing webfinger key (%s): %w", k, err)
|
||||
}
|
||||
} else {
|
||||
// Add acct: back to the key if it is an email address
|
||||
resource = fmt.Sprintf("acct:%s", resource)
|
||||
}
|
||||
|
||||
// Create a new webfinger
|
||||
webfinger := &WebFinger{
|
||||
Subject: resource,
|
||||
}
|
||||
|
||||
// Parse the fields
|
||||
for field, value := range v {
|
||||
fieldUrn := field
|
||||
|
||||
// If the key is not already an URN, try to find it in the URNs file
|
||||
if _, err := url.Parse(field); err != nil {
|
||||
if _, ok := urnMap[field]; ok {
|
||||
fieldUrn = urnMap[field]
|
||||
}
|
||||
}
|
||||
|
||||
// If the value is a valid URI, add it to the links
|
||||
if _, err := url.Parse(value); err == nil {
|
||||
webfinger.Links = append(webfinger.Links, Link{
|
||||
Rel: fieldUrn,
|
||||
Href: value,
|
||||
})
|
||||
} else {
|
||||
// Otherwise add it to the properties
|
||||
if webfinger.Properties == nil {
|
||||
webfinger.Properties = make(map[string]string)
|
||||
}
|
||||
|
||||
webfinger.Properties[fieldUrn] = value
|
||||
}
|
||||
}
|
||||
|
||||
// Add the webfinger to the map
|
||||
fingermap[resource] = webfinger
|
||||
}
|
||||
|
||||
l.Debug("Webfinger map built successfully", slog.Int("number", len(fingermap)), slog.Any("data", fingermap))
|
||||
|
||||
return fingermap, nil
|
||||
}
|
||||
|
||||
func WebfingerHandler(_ *Config, webmap WebFingerMap) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
ctx := r.Context()
|
||||
l := LoggerFromContext(ctx)
|
||||
|
||||
// Only handle GET requests
|
||||
if r.Method != http.MethodGet {
|
||||
l.Debug("Method not allowed")
|
||||
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
// Get the query params
|
||||
q := r.URL.Query()
|
||||
|
||||
// Get the resource
|
||||
resource := q.Get("resource")
|
||||
if resource == "" {
|
||||
l.Debug("No resource provided")
|
||||
http.Error(w, "No resource provided", http.StatusBadRequest)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
// Get and validate resource
|
||||
webfinger, ok := webmap[resource]
|
||||
if !ok {
|
||||
l.Debug("Resource not found")
|
||||
http.Error(w, "Resource not found", http.StatusNotFound)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
// Set the content type
|
||||
w.Header().Set("Content-Type", "application/jrd+json")
|
||||
|
||||
// Write the response
|
||||
if err := json.NewEncoder(w).Encode(webfinger); err != nil {
|
||||
l.Debug("Error encoding json")
|
||||
http.Error(w, "Error encoding json", http.StatusInternalServerError)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
l.Debug("Webfinger request successful")
|
||||
})
|
||||
}
|
||||
|
||||
func HealthCheckHandler(_ *Config) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(http.StatusOK)
|
||||
})
|
||||
}
|
||||
|
||||
type ResponseWrapper struct {
|
||||
http.ResponseWriter
|
||||
|
||||
status int
|
||||
}
|
||||
|
||||
func WrapResponseWriter(w http.ResponseWriter) *ResponseWrapper {
|
||||
return &ResponseWrapper{w, 0}
|
||||
}
|
||||
|
||||
func (w *ResponseWrapper) WriteHeader(code int) {
|
||||
w.status = code
|
||||
w.ResponseWriter.WriteHeader(code)
|
||||
}
|
||||
|
||||
func (w *ResponseWrapper) Status() int {
|
||||
return w.status
|
||||
}
|
||||
|
||||
func (w *ResponseWrapper) Write(b []byte) (int, error) {
|
||||
if w.status == 0 {
|
||||
w.status = http.StatusOK
|
||||
}
|
||||
|
||||
size, err := w.ResponseWriter.Write(b)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("error writing response: %w", err)
|
||||
}
|
||||
|
||||
return size, nil
|
||||
}
|
||||
|
||||
func (w *ResponseWrapper) Unwrap() http.ResponseWriter {
|
||||
return w.ResponseWriter
|
||||
}
|
||||
|
||||
func LoggingMiddleware(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
ctx := r.Context()
|
||||
l := LoggerFromContext(ctx)
|
||||
|
||||
start := time.Now()
|
||||
|
||||
// Wrap the response writer
|
||||
wrapped := WrapResponseWriter(w)
|
||||
|
||||
// Call the next handler
|
||||
next.ServeHTTP(wrapped, r)
|
||||
|
||||
status := wrapped.Status()
|
||||
|
||||
// Log the request
|
||||
lg := l.With(
|
||||
slog.String("method", r.Method),
|
||||
slog.String("path", r.URL.Path),
|
||||
slog.Int("status", status),
|
||||
slog.String("remote", r.RemoteAddr),
|
||||
slog.Duration("duration", time.Since(start)),
|
||||
)
|
||||
|
||||
switch {
|
||||
case status >= http.StatusInternalServerError:
|
||||
lg.Error("Server error")
|
||||
case status >= http.StatusBadRequest:
|
||||
lg.Info("Client error")
|
||||
default:
|
||||
lg.Info("Request completed")
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
const (
|
||||
// ReadTimeout is the maximum duration for reading the entire
|
||||
// request, including the body.
|
||||
ReadTimeout = 5 * time.Second
|
||||
// WriteTimeout is the maximum duration before timing out
|
||||
// writes of the response.
|
||||
WriteTimeout = 10 * time.Second
|
||||
// IdleTimeout is the maximum amount of time to wait for the
|
||||
// next request when keep-alives are enabled.
|
||||
IdleTimeout = 30 * time.Second
|
||||
// ReadHeaderTimeout is the amount of time allowed to read
|
||||
// request headers.
|
||||
ReadHeaderTimeout = 2 * time.Second
|
||||
// RequestTimeout is the maximum duration for the entire
|
||||
// request.
|
||||
RequestTimeout = 7 * 24 * time.Hour
|
||||
)
|
||||
|
||||
func StartServer(ctx context.Context, cfg *Config, webmap WebFingerMap) error {
|
||||
l := LoggerFromContext(ctx)
|
||||
|
||||
// Create the server mux
|
||||
mux := http.NewServeMux()
|
||||
mux.Handle("/.well-known/webfinger", WebfingerHandler(cfg, webmap))
|
||||
mux.Handle("/healthz", HealthCheckHandler(cfg))
|
||||
|
||||
// Create a new server
|
||||
srv := &http.Server{
|
||||
Addr: net.JoinHostPort(cfg.Host, cfg.Port),
|
||||
BaseContext: func(_ net.Listener) context.Context {
|
||||
return ctx
|
||||
},
|
||||
Handler: LoggingMiddleware(
|
||||
RecoveryHandler(
|
||||
http.TimeoutHandler(mux, RequestTimeout, "request timed out"),
|
||||
),
|
||||
),
|
||||
ReadHeaderTimeout: ReadHeaderTimeout,
|
||||
ReadTimeout: ReadTimeout,
|
||||
WriteTimeout: WriteTimeout,
|
||||
IdleTimeout: IdleTimeout,
|
||||
}
|
||||
|
||||
// Create the errorgroup that will manage the server execution
|
||||
eg, egCtx := errgroup.WithContext(ctx)
|
||||
|
||||
// Start the server
|
||||
eg.Go(func() error {
|
||||
l.Info("Starting server", slog.String("addr", srv.Addr))
|
||||
|
||||
// Use the global context for the server
|
||||
srv.BaseContext = func(_ net.Listener) context.Context {
|
||||
return egCtx
|
||||
}
|
||||
|
||||
return srv.ListenAndServe() //nolint:wrapcheck // We wrap the error in the errgroup
|
||||
})
|
||||
// Gracefully shutdown the server when the context is done
|
||||
eg.Go(func() error {
|
||||
// Wait for the context to be done
|
||||
<-egCtx.Done()
|
||||
|
||||
l.Info("Shutting down server")
|
||||
// Disable the cancel since we don't wan't to force
|
||||
// the server to shutdown if the context is canceled.
|
||||
noCancelCtx := context.WithoutCancel(egCtx)
|
||||
|
||||
return srv.Shutdown(noCancelCtx) //nolint:wrapcheck // We wrap the error in the errgroup
|
||||
})
|
||||
|
||||
srv.RegisterOnShutdown(func() {
|
||||
l.Info("Server shutdown complete")
|
||||
})
|
||||
|
||||
// Ignore the error if the context was canceled
|
||||
if err := eg.Wait(); err != nil && ctx.Err() == nil {
|
||||
return fmt.Errorf("server exited with error: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func RecoveryHandler(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
ctx := r.Context()
|
||||
l := LoggerFromContext(ctx)
|
||||
|
||||
defer func() {
|
||||
err := recover()
|
||||
if err != nil {
|
||||
l.Error("Panic", slog.Any("error", err))
|
||||
http.Error(w, "Internal Server Error", http.StatusInternalServerError)
|
||||
|
||||
return
|
||||
}
|
||||
}()
|
||||
|
||||
next.ServeHTTP(w, r)
|
||||
})
|
||||
}
|
60
main_test.go
Normal file
60
main_test.go
Normal file
|
@ -0,0 +1,60 @@
|
|||
package main_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
|
||||
finger "git.maronato.dev/maronato/finger"
|
||||
)
|
||||
|
||||
func BenchmarkGetWebfinger(b *testing.B) {
|
||||
ctx := context.Background()
|
||||
cfg := &finger.Config{}
|
||||
l := finger.NewLogger(cfg)
|
||||
|
||||
ctx = finger.WithLogger(ctx, l)
|
||||
resource := "acct:user@example.com"
|
||||
webmap := finger.WebFingerMap{
|
||||
resource: {
|
||||
Subject: resource,
|
||||
Links: []finger.Link{
|
||||
{
|
||||
Rel: "http://webfinger.net/rel/avatar",
|
||||
Href: "https://example.com/avatar.png",
|
||||
},
|
||||
},
|
||||
Properties: map[string]string{
|
||||
"example": "value",
|
||||
},
|
||||
},
|
||||
"acct:other": {
|
||||
Subject: "acct:other",
|
||||
Links: []finger.Link{
|
||||
{
|
||||
Rel: "http://webfinger.net/rel/avatar",
|
||||
Href: "https://example.com/avatar.png",
|
||||
},
|
||||
},
|
||||
Properties: map[string]string{
|
||||
"example": "value",
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
handler := finger.WebfingerHandler(&finger.Config{}, webmap)
|
||||
|
||||
r, _ := http.NewRequestWithContext(
|
||||
ctx,
|
||||
http.MethodGet,
|
||||
fmt.Sprintf("/.well-known/webfinger?resource=%s", resource),
|
||||
http.NoBody,
|
||||
)
|
||||
|
||||
for i := 0; i < b.N; i++ {
|
||||
w := httptest.NewRecorder()
|
||||
handler.ServeHTTP(w, r)
|
||||
}
|
||||
}
|
25
urns.yml
Normal file
25
urns.yml
Normal file
|
@ -0,0 +1,25 @@
|
|||
# maps string keys to best practice fully qualified URNs
|
||||
|
||||
# some references:
|
||||
# http://webfinger.net/rel/
|
||||
# http://www.packetizer.com/webfinger/link_relations.html
|
||||
|
||||
# names of people
|
||||
name: "http://schema.org/name"
|
||||
full_name: "http://schema.org/name"
|
||||
|
||||
# pictures of people
|
||||
avatar: http://webfinger.net/rel/avatar
|
||||
picture: http://webfinger.net/rel/avatar
|
||||
photo: http://webfinger.net/rel/avatar
|
||||
|
||||
# homepages of people
|
||||
profile_page: http://webfinger.net/rel/profile-page
|
||||
profile: http://webfinger.net/rel/profile-page
|
||||
website: http://webfinger.net/rel/profile-page
|
||||
url: http://webfinger.net/rel/profile-page
|
||||
homepage: http://webfinger.net/rel/profile-page
|
||||
|
||||
# OpenID Connect
|
||||
openid: http://openid.net/specs/connect/1.0/issuer
|
||||
open_id: http://openid.net/specs/connect/1.0/issuer
|
Loading…
Add table
Reference in a new issue