feat: initial commit
This commit is contained in:
@@ -0,0 +1,75 @@
|
||||
name: build
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
tags:
|
||||
- '*'
|
||||
pull_request:
|
||||
|
||||
jobs:
|
||||
build:
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- name: Check out repository code
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Set up Go
|
||||
uses: actions/setup-go@v5
|
||||
with:
|
||||
go-version-file: go.mod
|
||||
|
||||
- name: Build
|
||||
run: go build ./...
|
||||
|
||||
test:
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- name: Check out repository code
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Set up Go
|
||||
uses: actions/setup-go@v5
|
||||
with:
|
||||
go-version-file: go.mod
|
||||
|
||||
- name: Test
|
||||
run: go test ./...
|
||||
|
||||
build-image:
|
||||
runs-on: ubuntu-latest
|
||||
needs:
|
||||
- build
|
||||
- test
|
||||
|
||||
steps:
|
||||
- name: Check out repository code
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Docker metadata
|
||||
id: meta
|
||||
uses: docker/metadata-action@v5
|
||||
with:
|
||||
images: registry.mkz.me/mycroft/scoreboard-api
|
||||
tags: |
|
||||
type=raw,value=latest,enable={{is_default_branch}}
|
||||
type=ref,event=tag
|
||||
|
||||
- name: Docker login
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
registry: registry.mkz.me
|
||||
username: ${{ secrets.REGISTRY_USERNAME }}
|
||||
password: ${{ secrets.REGISTRY_PASSWORD }}
|
||||
|
||||
- name: Build docker image
|
||||
uses: docker/build-push-action@v6
|
||||
with:
|
||||
context: .
|
||||
file: ./Containerfile
|
||||
pull: true
|
||||
push: true
|
||||
tags: ${{ steps.meta.outputs.tags }}
|
||||
@@ -0,0 +1,4 @@
|
||||
/scoreboard-apid
|
||||
/anubis-smoke
|
||||
*.out
|
||||
/dist/
|
||||
@@ -0,0 +1,33 @@
|
||||
# syntax=docker/dockerfile:1
|
||||
|
||||
# --- build stage ---
|
||||
FROM golang:1.26 AS build
|
||||
|
||||
WORKDIR /src
|
||||
|
||||
# Cached dependency layer.
|
||||
COPY go.mod go.sum ./
|
||||
RUN --mount=type=cache,target=/go/pkg/mod \
|
||||
go mod download
|
||||
|
||||
# Build the static daemon.
|
||||
COPY . .
|
||||
RUN --mount=type=cache,target=/go/pkg/mod \
|
||||
--mount=type=cache,target=/root/.cache/go-build \
|
||||
CGO_ENABLED=0 GOOS=linux go build \
|
||||
-trimpath -ldflags="-s -w" \
|
||||
-o /out/scoreboard-apid ./cmd/scoreboard-apid
|
||||
|
||||
# --- runtime stage ---
|
||||
# distroless static includes CA certificates (needed for HTTPS to root-me.org)
|
||||
# and runs as a non-root user.
|
||||
FROM gcr.io/distroless/static-debian12:nonroot
|
||||
|
||||
COPY --from=build /out/scoreboard-apid /usr/local/bin/scoreboard-apid
|
||||
|
||||
EXPOSE 8080 9090
|
||||
|
||||
ENV HTTP_ADDR=:8080 \
|
||||
GRPC_ADDR=:9090
|
||||
|
||||
ENTRYPOINT ["/usr/local/bin/scoreboard-apid"]
|
||||
@@ -0,0 +1,87 @@
|
||||
# scoreboard-api
|
||||
|
||||
A small Go daemon that scrapes the first pages of the
|
||||
[root-me.org](https://www.root-me.org) scoreboard and serves them over **HTTP/JSON**
|
||||
and **gRPC**.
|
||||
|
||||
root-me.org is fronted by [Anubis](https://github.com/TecharoHQ/anubis), a
|
||||
proof-of-work anti-scraper proxy. This daemon includes a **native Go Anubis
|
||||
client** — no headless browser — that transparently solves the challenge and
|
||||
caches the resulting auth cookie.
|
||||
|
||||
## How it works
|
||||
|
||||
1. **Anubis** (`internal/anubis`) — an `http.RoundTripper` that detects the
|
||||
interstitial, solves the challenge, calls `pass-challenge`, stores the auth
|
||||
cookie, and replays the original request. root-me uses Anubis's `preact`
|
||||
challenge: the answer is `SHA256(randomData)` plus a short server-enforced
|
||||
wait (it is *not* a nonce proof-of-work). A nonce solver for the `fast`/`slow`
|
||||
challenges is also included for portability.
|
||||
2. **Scraper** (`internal/scraper`) — fetches the public, login-free Rankings
|
||||
fragment
|
||||
(`?page=structure&inc=modeles/classement&lang=en&ajah=1&debut_classement=N`),
|
||||
50 rows per page, and parses each row into an `Entry`.
|
||||
3. **Store + refresher** (`internal/scoreboard`) — an atomically-swapped
|
||||
in-memory snapshot with `byRank`/`byUsername` indexes, refreshed on an
|
||||
interval. The last good snapshot keeps serving if a refresh fails.
|
||||
4. **Servers** (`internal/server`) — gRPC and a `net/http` JSON mux, both reading
|
||||
the same store (no scraping in the request path).
|
||||
|
||||
`Rank` is the canonical 1-based position in score-descending order (root-me's
|
||||
own displayed rank is noisy around ties, so it is not used for ordering).
|
||||
|
||||
## Build & run
|
||||
|
||||
```sh
|
||||
go build ./cmd/scoreboard-apid
|
||||
./scoreboard-apid # HTTP :8080, gRPC :9090, refresh every 10m
|
||||
```
|
||||
|
||||
### Configuration (flags or env)
|
||||
|
||||
| Flag | Env | Default |
|
||||
|------|-----|---------|
|
||||
| `-http-addr` | `HTTP_ADDR` | `:8080` |
|
||||
| `-grpc-addr` | `GRPC_ADDR` | `:9090` |
|
||||
| `-refresh-interval` | `REFRESH_INTERVAL` | `10m` |
|
||||
| `-pages` | `SCOREBOARD_PAGES` | `4` (→ top 200) |
|
||||
| `-user-agent` | `USER_AGENT` | a Firefox UA |
|
||||
| `-request-timeout` | `REQUEST_TIMEOUT` | `30s` |
|
||||
| `-log-level` | `LOG_LEVEL` | `info` |
|
||||
|
||||
## HTTP endpoints
|
||||
|
||||
```sh
|
||||
curl localhost:8080/readyz
|
||||
curl 'localhost:8080/v1/scoreboard?limit=10&offset=0'
|
||||
curl localhost:8080/v1/scoreboard/rank/1
|
||||
curl localhost:8080/v1/scoreboard/user/skav
|
||||
```
|
||||
|
||||
## gRPC
|
||||
|
||||
Server reflection is enabled:
|
||||
|
||||
```sh
|
||||
grpcurl -plaintext localhost:9090 list scoreboard.v1.ScoreboardService
|
||||
grpcurl -plaintext -d '{"limit":2}' localhost:9090 scoreboard.v1.ScoreboardService/ListScoreboard
|
||||
grpcurl -plaintext -d '{"rank":1}' localhost:9090 scoreboard.v1.ScoreboardService/GetByRank
|
||||
grpcurl -plaintext -d '{"username":"skav"}' localhost:9090 scoreboard.v1.ScoreboardService/GetByUsername
|
||||
```
|
||||
|
||||
## Development
|
||||
|
||||
```sh
|
||||
go test ./...
|
||||
go generate ./... # regenerate gen/ from proto/ (needs protoc + plugins)
|
||||
```
|
||||
|
||||
`cmd/anubis-smoke` is a small helper that solves Anubis for a URL and dumps the
|
||||
page — handy for refreshing the parser test fixture.
|
||||
|
||||
## Notes
|
||||
|
||||
- An official JSON API exists at `https://api.www.root-me.org/classement`
|
||||
(not behind Anubis) but requires an `api_key`. This daemon deliberately uses
|
||||
the public HTML fragment so no credentials are needed.
|
||||
- Be a good citizen: the default 10-minute refresh keeps load on root-me low.
|
||||
@@ -0,0 +1,54 @@
|
||||
// Command anubis-smoke is a throwaway tool: it solves the Anubis challenge for
|
||||
// a URL and writes the resulting page to stdout (or a file), so we can inspect
|
||||
// the real scoreboard HTML and pin scraper selectors.
|
||||
package main
|
||||
|
||||
import (
|
||||
"flag"
|
||||
"fmt"
|
||||
"io"
|
||||
"log/slog"
|
||||
"os"
|
||||
|
||||
"git.mkz.me/mycroft/root-me-api/internal/anubis"
|
||||
)
|
||||
|
||||
func main() {
|
||||
url := flag.String("url", "https://www.root-me.org/?page=classement&lang=en", "URL to fetch")
|
||||
out := flag.String("out", "", "write body to this file (default stdout)")
|
||||
flag.Parse()
|
||||
|
||||
slog.SetLogLoggerLevel(slog.LevelDebug)
|
||||
|
||||
tr, err := anubis.New()
|
||||
if err != nil {
|
||||
fatal(err)
|
||||
}
|
||||
resp, err := tr.Client().Get(*url)
|
||||
if err != nil {
|
||||
fatal(err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
body, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
fatal(err)
|
||||
}
|
||||
|
||||
fmt.Fprintf(os.Stderr, "status=%d bytes=%d\n", resp.StatusCode, len(body))
|
||||
|
||||
w := os.Stdout
|
||||
if *out != "" {
|
||||
f, err := os.Create(*out)
|
||||
if err != nil {
|
||||
fatal(err)
|
||||
}
|
||||
defer f.Close()
|
||||
w = f
|
||||
}
|
||||
w.Write(body)
|
||||
}
|
||||
|
||||
func fatal(err error) {
|
||||
fmt.Fprintln(os.Stderr, "error:", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
@@ -0,0 +1,130 @@
|
||||
// Command scoreboard-apid serves the root-me.org scoreboard over HTTP/JSON and
|
||||
// gRPC. It scrapes the public Rankings fragment through a native Anubis
|
||||
// proof-of-work solver, caching the result in memory and refreshing on an
|
||||
// interval.
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"net"
|
||||
"net/http"
|
||||
"os"
|
||||
"os/signal"
|
||||
"syscall"
|
||||
"time"
|
||||
|
||||
"google.golang.org/grpc"
|
||||
"google.golang.org/grpc/reflection"
|
||||
|
||||
pb "git.mkz.me/mycroft/root-me-api/gen/scoreboard/v1"
|
||||
"git.mkz.me/mycroft/root-me-api/internal/anubis"
|
||||
"git.mkz.me/mycroft/root-me-api/internal/config"
|
||||
"git.mkz.me/mycroft/root-me-api/internal/scoreboard"
|
||||
"git.mkz.me/mycroft/root-me-api/internal/scraper"
|
||||
"git.mkz.me/mycroft/root-me-api/internal/server"
|
||||
)
|
||||
|
||||
func main() {
|
||||
if err := run(os.Args[1:]); err != nil {
|
||||
slog.Error("fatal", "err", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
}
|
||||
|
||||
func run(args []string) error {
|
||||
cfg, err := config.Load(args)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
logger := setupLogger(cfg.LogLevel)
|
||||
slog.SetDefault(logger)
|
||||
|
||||
// Anubis-solving HTTP client.
|
||||
tr, err := anubis.New()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
tr.UserAgent = cfg.UserAgent
|
||||
tr.Logger = logger
|
||||
client := tr.Client()
|
||||
client.Timeout = cfg.RequestTimeout
|
||||
|
||||
// Scraper + store + refresher.
|
||||
sc := scraper.New(client, logger)
|
||||
store := scoreboard.NewStore()
|
||||
fetch := func(ctx context.Context) ([]scoreboard.Entry, error) {
|
||||
return sc.Fetch(ctx, cfg.Pages)
|
||||
}
|
||||
refresher := scoreboard.NewRefresher(store, fetch, cfg.RefreshInterval, logger)
|
||||
|
||||
ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM)
|
||||
defer stop()
|
||||
|
||||
// Warm start: try once before serving so /readyz flips green quickly. A
|
||||
// failure here is non-fatal — the refresher will retry on its interval.
|
||||
warmCtx, cancel := context.WithTimeout(ctx, cfg.RequestTimeout*time.Duration(cfg.Pages+1))
|
||||
if err := refresher.RefreshOnce(warmCtx); err != nil {
|
||||
logger.Warn("initial scoreboard load failed; serving will start unready", "err", err)
|
||||
}
|
||||
cancel()
|
||||
|
||||
go refresher.Run(ctx)
|
||||
|
||||
// HTTP server.
|
||||
httpSrv := &http.Server{
|
||||
Addr: cfg.HTTPAddr,
|
||||
Handler: server.HTTPHandler(store, refresher, logger),
|
||||
ReadHeaderTimeout: 10 * time.Second,
|
||||
}
|
||||
// gRPC server.
|
||||
grpcSrv := grpc.NewServer()
|
||||
pb.RegisterScoreboardServiceServer(grpcSrv, server.NewGRPC(store))
|
||||
reflection.Register(grpcSrv)
|
||||
|
||||
grpcLis, err := net.Listen("tcp", cfg.GRPCAddr)
|
||||
if err != nil {
|
||||
return fmt.Errorf("grpc listen: %w", err)
|
||||
}
|
||||
|
||||
errCh := make(chan error, 2)
|
||||
go func() {
|
||||
logger.Info("HTTP server listening", "addr", cfg.HTTPAddr)
|
||||
if err := httpSrv.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) {
|
||||
errCh <- fmt.Errorf("http: %w", err)
|
||||
}
|
||||
}()
|
||||
go func() {
|
||||
logger.Info("gRPC server listening", "addr", cfg.GRPCAddr)
|
||||
if err := grpcSrv.Serve(grpcLis); err != nil {
|
||||
errCh <- fmt.Errorf("grpc: %w", err)
|
||||
}
|
||||
}()
|
||||
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
logger.Info("shutdown signal received")
|
||||
case err := <-errCh:
|
||||
logger.Error("server error", "err", err)
|
||||
}
|
||||
|
||||
// Graceful shutdown.
|
||||
shutCtx, shutCancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||
defer shutCancel()
|
||||
if err := httpSrv.Shutdown(shutCtx); err != nil {
|
||||
logger.Warn("http shutdown", "err", err)
|
||||
}
|
||||
grpcSrv.GracefulStop()
|
||||
logger.Info("stopped")
|
||||
return nil
|
||||
}
|
||||
|
||||
func setupLogger(level string) *slog.Logger {
|
||||
var lvl slog.Level
|
||||
if err := lvl.UnmarshalText([]byte(level)); err != nil {
|
||||
lvl = slog.LevelInfo
|
||||
}
|
||||
return slog.New(slog.NewTextHandler(os.Stderr, &slog.HandlerOptions{Level: lvl}))
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
// Package scoreboardv1 contains the generated gRPC/protobuf code for the
|
||||
// scoreboard service.
|
||||
//
|
||||
// Regenerate with `go generate ./...` (requires protoc, protoc-gen-go and
|
||||
// protoc-gen-go-grpc on PATH — install the latter two with:
|
||||
//
|
||||
// go install google.golang.org/protobuf/cmd/protoc-gen-go@latest
|
||||
// go install google.golang.org/grpc/cmd/protoc-gen-go-grpc@latest
|
||||
package scoreboardv1
|
||||
|
||||
//go:generate protoc --proto_path=../../../proto --go_out=../../.. --go_opt=module=git.mkz.me/mycroft/root-me-api --go-grpc_out=../../.. --go-grpc_opt=module=git.mkz.me/mycroft/root-me-api scoreboard/v1/scoreboard.proto
|
||||
@@ -0,0 +1,399 @@
|
||||
// Code generated by protoc-gen-go. DO NOT EDIT.
|
||||
// versions:
|
||||
// protoc-gen-go v1.36.11
|
||||
// protoc v7.35.0
|
||||
// source: scoreboard/v1/scoreboard.proto
|
||||
|
||||
package scoreboardv1
|
||||
|
||||
import (
|
||||
protoreflect "google.golang.org/protobuf/reflect/protoreflect"
|
||||
protoimpl "google.golang.org/protobuf/runtime/protoimpl"
|
||||
reflect "reflect"
|
||||
sync "sync"
|
||||
unsafe "unsafe"
|
||||
)
|
||||
|
||||
const (
|
||||
// Verify that this generated code is sufficiently up-to-date.
|
||||
_ = protoimpl.EnforceVersion(20 - protoimpl.MinVersion)
|
||||
// Verify that runtime/protoimpl is sufficiently up-to-date.
|
||||
_ = protoimpl.EnforceVersion(protoimpl.MaxVersion - 20)
|
||||
)
|
||||
|
||||
// Entry is a single scoreboard row.
|
||||
type Entry struct {
|
||||
state protoimpl.MessageState `protogen:"open.v1"`
|
||||
Rank int32 `protobuf:"varint,1,opt,name=rank,proto3" json:"rank,omitempty"`
|
||||
Username string `protobuf:"bytes,2,opt,name=username,proto3" json:"username,omitempty"`
|
||||
ProfilePath string `protobuf:"bytes,3,opt,name=profile_path,json=profilePath,proto3" json:"profile_path,omitempty"`
|
||||
Country string `protobuf:"bytes,4,opt,name=country,proto3" json:"country,omitempty"`
|
||||
Grade string `protobuf:"bytes,5,opt,name=grade,proto3" json:"grade,omitempty"`
|
||||
Score int32 `protobuf:"varint,6,opt,name=score,proto3" json:"score,omitempty"`
|
||||
unknownFields protoimpl.UnknownFields
|
||||
sizeCache protoimpl.SizeCache
|
||||
}
|
||||
|
||||
func (x *Entry) Reset() {
|
||||
*x = Entry{}
|
||||
mi := &file_scoreboard_v1_scoreboard_proto_msgTypes[0]
|
||||
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||
ms.StoreMessageInfo(mi)
|
||||
}
|
||||
|
||||
func (x *Entry) String() string {
|
||||
return protoimpl.X.MessageStringOf(x)
|
||||
}
|
||||
|
||||
func (*Entry) ProtoMessage() {}
|
||||
|
||||
func (x *Entry) ProtoReflect() protoreflect.Message {
|
||||
mi := &file_scoreboard_v1_scoreboard_proto_msgTypes[0]
|
||||
if x != nil {
|
||||
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||
if ms.LoadMessageInfo() == nil {
|
||||
ms.StoreMessageInfo(mi)
|
||||
}
|
||||
return ms
|
||||
}
|
||||
return mi.MessageOf(x)
|
||||
}
|
||||
|
||||
// Deprecated: Use Entry.ProtoReflect.Descriptor instead.
|
||||
func (*Entry) Descriptor() ([]byte, []int) {
|
||||
return file_scoreboard_v1_scoreboard_proto_rawDescGZIP(), []int{0}
|
||||
}
|
||||
|
||||
func (x *Entry) GetRank() int32 {
|
||||
if x != nil {
|
||||
return x.Rank
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
func (x *Entry) GetUsername() string {
|
||||
if x != nil {
|
||||
return x.Username
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func (x *Entry) GetProfilePath() string {
|
||||
if x != nil {
|
||||
return x.ProfilePath
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func (x *Entry) GetCountry() string {
|
||||
if x != nil {
|
||||
return x.Country
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func (x *Entry) GetGrade() string {
|
||||
if x != nil {
|
||||
return x.Grade
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func (x *Entry) GetScore() int32 {
|
||||
if x != nil {
|
||||
return x.Score
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
type ListScoreboardRequest struct {
|
||||
state protoimpl.MessageState `protogen:"open.v1"`
|
||||
// Zero-based offset into the ranked list.
|
||||
Offset int32 `protobuf:"varint,1,opt,name=offset,proto3" json:"offset,omitempty"`
|
||||
// Maximum entries to return; 0 means "all from offset".
|
||||
Limit int32 `protobuf:"varint,2,opt,name=limit,proto3" json:"limit,omitempty"`
|
||||
unknownFields protoimpl.UnknownFields
|
||||
sizeCache protoimpl.SizeCache
|
||||
}
|
||||
|
||||
func (x *ListScoreboardRequest) Reset() {
|
||||
*x = ListScoreboardRequest{}
|
||||
mi := &file_scoreboard_v1_scoreboard_proto_msgTypes[1]
|
||||
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||
ms.StoreMessageInfo(mi)
|
||||
}
|
||||
|
||||
func (x *ListScoreboardRequest) String() string {
|
||||
return protoimpl.X.MessageStringOf(x)
|
||||
}
|
||||
|
||||
func (*ListScoreboardRequest) ProtoMessage() {}
|
||||
|
||||
func (x *ListScoreboardRequest) ProtoReflect() protoreflect.Message {
|
||||
mi := &file_scoreboard_v1_scoreboard_proto_msgTypes[1]
|
||||
if x != nil {
|
||||
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||
if ms.LoadMessageInfo() == nil {
|
||||
ms.StoreMessageInfo(mi)
|
||||
}
|
||||
return ms
|
||||
}
|
||||
return mi.MessageOf(x)
|
||||
}
|
||||
|
||||
// Deprecated: Use ListScoreboardRequest.ProtoReflect.Descriptor instead.
|
||||
func (*ListScoreboardRequest) Descriptor() ([]byte, []int) {
|
||||
return file_scoreboard_v1_scoreboard_proto_rawDescGZIP(), []int{1}
|
||||
}
|
||||
|
||||
func (x *ListScoreboardRequest) GetOffset() int32 {
|
||||
if x != nil {
|
||||
return x.Offset
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
func (x *ListScoreboardRequest) GetLimit() int32 {
|
||||
if x != nil {
|
||||
return x.Limit
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
type ListScoreboardResponse struct {
|
||||
state protoimpl.MessageState `protogen:"open.v1"`
|
||||
Entries []*Entry `protobuf:"bytes,1,rep,name=entries,proto3" json:"entries,omitempty"`
|
||||
// Total entries in the current snapshot.
|
||||
Total int32 `protobuf:"varint,2,opt,name=total,proto3" json:"total,omitempty"`
|
||||
// Snapshot time (unix seconds) the data was fetched.
|
||||
FetchedAt int64 `protobuf:"varint,3,opt,name=fetched_at,json=fetchedAt,proto3" json:"fetched_at,omitempty"`
|
||||
unknownFields protoimpl.UnknownFields
|
||||
sizeCache protoimpl.SizeCache
|
||||
}
|
||||
|
||||
func (x *ListScoreboardResponse) Reset() {
|
||||
*x = ListScoreboardResponse{}
|
||||
mi := &file_scoreboard_v1_scoreboard_proto_msgTypes[2]
|
||||
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||
ms.StoreMessageInfo(mi)
|
||||
}
|
||||
|
||||
func (x *ListScoreboardResponse) String() string {
|
||||
return protoimpl.X.MessageStringOf(x)
|
||||
}
|
||||
|
||||
func (*ListScoreboardResponse) ProtoMessage() {}
|
||||
|
||||
func (x *ListScoreboardResponse) ProtoReflect() protoreflect.Message {
|
||||
mi := &file_scoreboard_v1_scoreboard_proto_msgTypes[2]
|
||||
if x != nil {
|
||||
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||
if ms.LoadMessageInfo() == nil {
|
||||
ms.StoreMessageInfo(mi)
|
||||
}
|
||||
return ms
|
||||
}
|
||||
return mi.MessageOf(x)
|
||||
}
|
||||
|
||||
// Deprecated: Use ListScoreboardResponse.ProtoReflect.Descriptor instead.
|
||||
func (*ListScoreboardResponse) Descriptor() ([]byte, []int) {
|
||||
return file_scoreboard_v1_scoreboard_proto_rawDescGZIP(), []int{2}
|
||||
}
|
||||
|
||||
func (x *ListScoreboardResponse) GetEntries() []*Entry {
|
||||
if x != nil {
|
||||
return x.Entries
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (x *ListScoreboardResponse) GetTotal() int32 {
|
||||
if x != nil {
|
||||
return x.Total
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
func (x *ListScoreboardResponse) GetFetchedAt() int64 {
|
||||
if x != nil {
|
||||
return x.FetchedAt
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
type GetByRankRequest struct {
|
||||
state protoimpl.MessageState `protogen:"open.v1"`
|
||||
Rank int32 `protobuf:"varint,1,opt,name=rank,proto3" json:"rank,omitempty"`
|
||||
unknownFields protoimpl.UnknownFields
|
||||
sizeCache protoimpl.SizeCache
|
||||
}
|
||||
|
||||
func (x *GetByRankRequest) Reset() {
|
||||
*x = GetByRankRequest{}
|
||||
mi := &file_scoreboard_v1_scoreboard_proto_msgTypes[3]
|
||||
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||
ms.StoreMessageInfo(mi)
|
||||
}
|
||||
|
||||
func (x *GetByRankRequest) String() string {
|
||||
return protoimpl.X.MessageStringOf(x)
|
||||
}
|
||||
|
||||
func (*GetByRankRequest) ProtoMessage() {}
|
||||
|
||||
func (x *GetByRankRequest) ProtoReflect() protoreflect.Message {
|
||||
mi := &file_scoreboard_v1_scoreboard_proto_msgTypes[3]
|
||||
if x != nil {
|
||||
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||
if ms.LoadMessageInfo() == nil {
|
||||
ms.StoreMessageInfo(mi)
|
||||
}
|
||||
return ms
|
||||
}
|
||||
return mi.MessageOf(x)
|
||||
}
|
||||
|
||||
// Deprecated: Use GetByRankRequest.ProtoReflect.Descriptor instead.
|
||||
func (*GetByRankRequest) Descriptor() ([]byte, []int) {
|
||||
return file_scoreboard_v1_scoreboard_proto_rawDescGZIP(), []int{3}
|
||||
}
|
||||
|
||||
func (x *GetByRankRequest) GetRank() int32 {
|
||||
if x != nil {
|
||||
return x.Rank
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
type GetByUsernameRequest struct {
|
||||
state protoimpl.MessageState `protogen:"open.v1"`
|
||||
Username string `protobuf:"bytes,1,opt,name=username,proto3" json:"username,omitempty"`
|
||||
unknownFields protoimpl.UnknownFields
|
||||
sizeCache protoimpl.SizeCache
|
||||
}
|
||||
|
||||
func (x *GetByUsernameRequest) Reset() {
|
||||
*x = GetByUsernameRequest{}
|
||||
mi := &file_scoreboard_v1_scoreboard_proto_msgTypes[4]
|
||||
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||
ms.StoreMessageInfo(mi)
|
||||
}
|
||||
|
||||
func (x *GetByUsernameRequest) String() string {
|
||||
return protoimpl.X.MessageStringOf(x)
|
||||
}
|
||||
|
||||
func (*GetByUsernameRequest) ProtoMessage() {}
|
||||
|
||||
func (x *GetByUsernameRequest) ProtoReflect() protoreflect.Message {
|
||||
mi := &file_scoreboard_v1_scoreboard_proto_msgTypes[4]
|
||||
if x != nil {
|
||||
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||
if ms.LoadMessageInfo() == nil {
|
||||
ms.StoreMessageInfo(mi)
|
||||
}
|
||||
return ms
|
||||
}
|
||||
return mi.MessageOf(x)
|
||||
}
|
||||
|
||||
// Deprecated: Use GetByUsernameRequest.ProtoReflect.Descriptor instead.
|
||||
func (*GetByUsernameRequest) Descriptor() ([]byte, []int) {
|
||||
return file_scoreboard_v1_scoreboard_proto_rawDescGZIP(), []int{4}
|
||||
}
|
||||
|
||||
func (x *GetByUsernameRequest) GetUsername() string {
|
||||
if x != nil {
|
||||
return x.Username
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
var File_scoreboard_v1_scoreboard_proto protoreflect.FileDescriptor
|
||||
|
||||
const file_scoreboard_v1_scoreboard_proto_rawDesc = "" +
|
||||
"\n" +
|
||||
"\x1escoreboard/v1/scoreboard.proto\x12\rscoreboard.v1\"\xa0\x01\n" +
|
||||
"\x05Entry\x12\x12\n" +
|
||||
"\x04rank\x18\x01 \x01(\x05R\x04rank\x12\x1a\n" +
|
||||
"\busername\x18\x02 \x01(\tR\busername\x12!\n" +
|
||||
"\fprofile_path\x18\x03 \x01(\tR\vprofilePath\x12\x18\n" +
|
||||
"\acountry\x18\x04 \x01(\tR\acountry\x12\x14\n" +
|
||||
"\x05grade\x18\x05 \x01(\tR\x05grade\x12\x14\n" +
|
||||
"\x05score\x18\x06 \x01(\x05R\x05score\"E\n" +
|
||||
"\x15ListScoreboardRequest\x12\x16\n" +
|
||||
"\x06offset\x18\x01 \x01(\x05R\x06offset\x12\x14\n" +
|
||||
"\x05limit\x18\x02 \x01(\x05R\x05limit\"}\n" +
|
||||
"\x16ListScoreboardResponse\x12.\n" +
|
||||
"\aentries\x18\x01 \x03(\v2\x14.scoreboard.v1.EntryR\aentries\x12\x14\n" +
|
||||
"\x05total\x18\x02 \x01(\x05R\x05total\x12\x1d\n" +
|
||||
"\n" +
|
||||
"fetched_at\x18\x03 \x01(\x03R\tfetchedAt\"&\n" +
|
||||
"\x10GetByRankRequest\x12\x12\n" +
|
||||
"\x04rank\x18\x01 \x01(\x05R\x04rank\"2\n" +
|
||||
"\x14GetByUsernameRequest\x12\x1a\n" +
|
||||
"\busername\x18\x01 \x01(\tR\busername2\x82\x02\n" +
|
||||
"\x11ScoreboardService\x12]\n" +
|
||||
"\x0eListScoreboard\x12$.scoreboard.v1.ListScoreboardRequest\x1a%.scoreboard.v1.ListScoreboardResponse\x12B\n" +
|
||||
"\tGetByRank\x12\x1f.scoreboard.v1.GetByRankRequest\x1a\x14.scoreboard.v1.Entry\x12J\n" +
|
||||
"\rGetByUsername\x12#.scoreboard.v1.GetByUsernameRequest\x1a\x14.scoreboard.v1.EntryBBZ@git.mkz.me/mycroft/scoreboard-api/gen/scoreboard/v1;scoreboardv1b\x06proto3"
|
||||
|
||||
var (
|
||||
file_scoreboard_v1_scoreboard_proto_rawDescOnce sync.Once
|
||||
file_scoreboard_v1_scoreboard_proto_rawDescData []byte
|
||||
)
|
||||
|
||||
func file_scoreboard_v1_scoreboard_proto_rawDescGZIP() []byte {
|
||||
file_scoreboard_v1_scoreboard_proto_rawDescOnce.Do(func() {
|
||||
file_scoreboard_v1_scoreboard_proto_rawDescData = protoimpl.X.CompressGZIP(unsafe.Slice(unsafe.StringData(file_scoreboard_v1_scoreboard_proto_rawDesc), len(file_scoreboard_v1_scoreboard_proto_rawDesc)))
|
||||
})
|
||||
return file_scoreboard_v1_scoreboard_proto_rawDescData
|
||||
}
|
||||
|
||||
var file_scoreboard_v1_scoreboard_proto_msgTypes = make([]protoimpl.MessageInfo, 5)
|
||||
var file_scoreboard_v1_scoreboard_proto_goTypes = []any{
|
||||
(*Entry)(nil), // 0: scoreboard.v1.Entry
|
||||
(*ListScoreboardRequest)(nil), // 1: scoreboard.v1.ListScoreboardRequest
|
||||
(*ListScoreboardResponse)(nil), // 2: scoreboard.v1.ListScoreboardResponse
|
||||
(*GetByRankRequest)(nil), // 3: scoreboard.v1.GetByRankRequest
|
||||
(*GetByUsernameRequest)(nil), // 4: scoreboard.v1.GetByUsernameRequest
|
||||
}
|
||||
var file_scoreboard_v1_scoreboard_proto_depIdxs = []int32{
|
||||
0, // 0: scoreboard.v1.ListScoreboardResponse.entries:type_name -> scoreboard.v1.Entry
|
||||
1, // 1: scoreboard.v1.ScoreboardService.ListScoreboard:input_type -> scoreboard.v1.ListScoreboardRequest
|
||||
3, // 2: scoreboard.v1.ScoreboardService.GetByRank:input_type -> scoreboard.v1.GetByRankRequest
|
||||
4, // 3: scoreboard.v1.ScoreboardService.GetByUsername:input_type -> scoreboard.v1.GetByUsernameRequest
|
||||
2, // 4: scoreboard.v1.ScoreboardService.ListScoreboard:output_type -> scoreboard.v1.ListScoreboardResponse
|
||||
0, // 5: scoreboard.v1.ScoreboardService.GetByRank:output_type -> scoreboard.v1.Entry
|
||||
0, // 6: scoreboard.v1.ScoreboardService.GetByUsername:output_type -> scoreboard.v1.Entry
|
||||
4, // [4:7] is the sub-list for method output_type
|
||||
1, // [1:4] is the sub-list for method input_type
|
||||
1, // [1:1] is the sub-list for extension type_name
|
||||
1, // [1:1] is the sub-list for extension extendee
|
||||
0, // [0:1] is the sub-list for field type_name
|
||||
}
|
||||
|
||||
func init() { file_scoreboard_v1_scoreboard_proto_init() }
|
||||
func file_scoreboard_v1_scoreboard_proto_init() {
|
||||
if File_scoreboard_v1_scoreboard_proto != nil {
|
||||
return
|
||||
}
|
||||
type x struct{}
|
||||
out := protoimpl.TypeBuilder{
|
||||
File: protoimpl.DescBuilder{
|
||||
GoPackagePath: reflect.TypeOf(x{}).PkgPath(),
|
||||
RawDescriptor: unsafe.Slice(unsafe.StringData(file_scoreboard_v1_scoreboard_proto_rawDesc), len(file_scoreboard_v1_scoreboard_proto_rawDesc)),
|
||||
NumEnums: 0,
|
||||
NumMessages: 5,
|
||||
NumExtensions: 0,
|
||||
NumServices: 1,
|
||||
},
|
||||
GoTypes: file_scoreboard_v1_scoreboard_proto_goTypes,
|
||||
DependencyIndexes: file_scoreboard_v1_scoreboard_proto_depIdxs,
|
||||
MessageInfos: file_scoreboard_v1_scoreboard_proto_msgTypes,
|
||||
}.Build()
|
||||
File_scoreboard_v1_scoreboard_proto = out.File
|
||||
file_scoreboard_v1_scoreboard_proto_goTypes = nil
|
||||
file_scoreboard_v1_scoreboard_proto_depIdxs = nil
|
||||
}
|
||||
@@ -0,0 +1,207 @@
|
||||
// Code generated by protoc-gen-go-grpc. DO NOT EDIT.
|
||||
// versions:
|
||||
// - protoc-gen-go-grpc v1.6.2
|
||||
// - protoc v7.35.0
|
||||
// source: scoreboard/v1/scoreboard.proto
|
||||
|
||||
package scoreboardv1
|
||||
|
||||
import (
|
||||
context "context"
|
||||
grpc "google.golang.org/grpc"
|
||||
codes "google.golang.org/grpc/codes"
|
||||
status "google.golang.org/grpc/status"
|
||||
)
|
||||
|
||||
// This is a compile-time assertion to ensure that this generated file
|
||||
// is compatible with the grpc package it is being compiled against.
|
||||
// Requires gRPC-Go v1.64.0 or later.
|
||||
const _ = grpc.SupportPackageIsVersion9
|
||||
|
||||
const (
|
||||
ScoreboardService_ListScoreboard_FullMethodName = "/scoreboard.v1.ScoreboardService/ListScoreboard"
|
||||
ScoreboardService_GetByRank_FullMethodName = "/scoreboard.v1.ScoreboardService/GetByRank"
|
||||
ScoreboardService_GetByUsername_FullMethodName = "/scoreboard.v1.ScoreboardService/GetByUsername"
|
||||
)
|
||||
|
||||
// ScoreboardServiceClient is the client API for ScoreboardService service.
|
||||
//
|
||||
// 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.
|
||||
//
|
||||
// ScoreboardService serves the cached root-me.org scoreboard.
|
||||
type ScoreboardServiceClient interface {
|
||||
// ListScoreboard returns a page of entries ordered by rank.
|
||||
ListScoreboard(ctx context.Context, in *ListScoreboardRequest, opts ...grpc.CallOption) (*ListScoreboardResponse, error)
|
||||
// GetByRank returns the entry at the given canonical rank.
|
||||
GetByRank(ctx context.Context, in *GetByRankRequest, opts ...grpc.CallOption) (*Entry, error)
|
||||
// GetByUsername returns the entry for a username (case-insensitive).
|
||||
GetByUsername(ctx context.Context, in *GetByUsernameRequest, opts ...grpc.CallOption) (*Entry, error)
|
||||
}
|
||||
|
||||
type scoreboardServiceClient struct {
|
||||
cc grpc.ClientConnInterface
|
||||
}
|
||||
|
||||
func NewScoreboardServiceClient(cc grpc.ClientConnInterface) ScoreboardServiceClient {
|
||||
return &scoreboardServiceClient{cc}
|
||||
}
|
||||
|
||||
func (c *scoreboardServiceClient) ListScoreboard(ctx context.Context, in *ListScoreboardRequest, opts ...grpc.CallOption) (*ListScoreboardResponse, error) {
|
||||
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
|
||||
out := new(ListScoreboardResponse)
|
||||
err := c.cc.Invoke(ctx, ScoreboardService_ListScoreboard_FullMethodName, in, out, cOpts...)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (c *scoreboardServiceClient) GetByRank(ctx context.Context, in *GetByRankRequest, opts ...grpc.CallOption) (*Entry, error) {
|
||||
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
|
||||
out := new(Entry)
|
||||
err := c.cc.Invoke(ctx, ScoreboardService_GetByRank_FullMethodName, in, out, cOpts...)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (c *scoreboardServiceClient) GetByUsername(ctx context.Context, in *GetByUsernameRequest, opts ...grpc.CallOption) (*Entry, error) {
|
||||
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
|
||||
out := new(Entry)
|
||||
err := c.cc.Invoke(ctx, ScoreboardService_GetByUsername_FullMethodName, in, out, cOpts...)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// ScoreboardServiceServer is the server API for ScoreboardService service.
|
||||
// All implementations must embed UnimplementedScoreboardServiceServer
|
||||
// for forward compatibility.
|
||||
//
|
||||
// ScoreboardService serves the cached root-me.org scoreboard.
|
||||
type ScoreboardServiceServer interface {
|
||||
// ListScoreboard returns a page of entries ordered by rank.
|
||||
ListScoreboard(context.Context, *ListScoreboardRequest) (*ListScoreboardResponse, error)
|
||||
// GetByRank returns the entry at the given canonical rank.
|
||||
GetByRank(context.Context, *GetByRankRequest) (*Entry, error)
|
||||
// GetByUsername returns the entry for a username (case-insensitive).
|
||||
GetByUsername(context.Context, *GetByUsernameRequest) (*Entry, error)
|
||||
mustEmbedUnimplementedScoreboardServiceServer()
|
||||
}
|
||||
|
||||
// UnimplementedScoreboardServiceServer must be embedded to have
|
||||
// forward compatible implementations.
|
||||
//
|
||||
// NOTE: this should be embedded by value instead of pointer to avoid a nil
|
||||
// pointer dereference when methods are called.
|
||||
type UnimplementedScoreboardServiceServer struct{}
|
||||
|
||||
func (UnimplementedScoreboardServiceServer) ListScoreboard(context.Context, *ListScoreboardRequest) (*ListScoreboardResponse, error) {
|
||||
return nil, status.Error(codes.Unimplemented, "method ListScoreboard not implemented")
|
||||
}
|
||||
func (UnimplementedScoreboardServiceServer) GetByRank(context.Context, *GetByRankRequest) (*Entry, error) {
|
||||
return nil, status.Error(codes.Unimplemented, "method GetByRank not implemented")
|
||||
}
|
||||
func (UnimplementedScoreboardServiceServer) GetByUsername(context.Context, *GetByUsernameRequest) (*Entry, error) {
|
||||
return nil, status.Error(codes.Unimplemented, "method GetByUsername not implemented")
|
||||
}
|
||||
func (UnimplementedScoreboardServiceServer) mustEmbedUnimplementedScoreboardServiceServer() {}
|
||||
func (UnimplementedScoreboardServiceServer) testEmbeddedByValue() {}
|
||||
|
||||
// UnsafeScoreboardServiceServer may be embedded to opt out of forward compatibility for this service.
|
||||
// Use of this interface is not recommended, as added methods to ScoreboardServiceServer will
|
||||
// result in compilation errors.
|
||||
type UnsafeScoreboardServiceServer interface {
|
||||
mustEmbedUnimplementedScoreboardServiceServer()
|
||||
}
|
||||
|
||||
func RegisterScoreboardServiceServer(s grpc.ServiceRegistrar, srv ScoreboardServiceServer) {
|
||||
// If the following call panics, it indicates UnimplementedScoreboardServiceServer was
|
||||
// embedded by pointer and is nil. This will cause panics if an
|
||||
// unimplemented method is ever invoked, so we test this at initialization
|
||||
// time to prevent it from happening at runtime later due to I/O.
|
||||
if t, ok := srv.(interface{ testEmbeddedByValue() }); ok {
|
||||
t.testEmbeddedByValue()
|
||||
}
|
||||
s.RegisterService(&ScoreboardService_ServiceDesc, srv)
|
||||
}
|
||||
|
||||
func _ScoreboardService_ListScoreboard_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
|
||||
in := new(ListScoreboardRequest)
|
||||
if err := dec(in); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if interceptor == nil {
|
||||
return srv.(ScoreboardServiceServer).ListScoreboard(ctx, in)
|
||||
}
|
||||
info := &grpc.UnaryServerInfo{
|
||||
Server: srv,
|
||||
FullMethod: ScoreboardService_ListScoreboard_FullMethodName,
|
||||
}
|
||||
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
|
||||
return srv.(ScoreboardServiceServer).ListScoreboard(ctx, req.(*ListScoreboardRequest))
|
||||
}
|
||||
return interceptor(ctx, in, info, handler)
|
||||
}
|
||||
|
||||
func _ScoreboardService_GetByRank_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
|
||||
in := new(GetByRankRequest)
|
||||
if err := dec(in); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if interceptor == nil {
|
||||
return srv.(ScoreboardServiceServer).GetByRank(ctx, in)
|
||||
}
|
||||
info := &grpc.UnaryServerInfo{
|
||||
Server: srv,
|
||||
FullMethod: ScoreboardService_GetByRank_FullMethodName,
|
||||
}
|
||||
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
|
||||
return srv.(ScoreboardServiceServer).GetByRank(ctx, req.(*GetByRankRequest))
|
||||
}
|
||||
return interceptor(ctx, in, info, handler)
|
||||
}
|
||||
|
||||
func _ScoreboardService_GetByUsername_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
|
||||
in := new(GetByUsernameRequest)
|
||||
if err := dec(in); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if interceptor == nil {
|
||||
return srv.(ScoreboardServiceServer).GetByUsername(ctx, in)
|
||||
}
|
||||
info := &grpc.UnaryServerInfo{
|
||||
Server: srv,
|
||||
FullMethod: ScoreboardService_GetByUsername_FullMethodName,
|
||||
}
|
||||
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
|
||||
return srv.(ScoreboardServiceServer).GetByUsername(ctx, req.(*GetByUsernameRequest))
|
||||
}
|
||||
return interceptor(ctx, in, info, handler)
|
||||
}
|
||||
|
||||
// ScoreboardService_ServiceDesc is the grpc.ServiceDesc for ScoreboardService service.
|
||||
// It's only intended for direct use with grpc.RegisterService,
|
||||
// and not to be introspected or modified (even as a copy)
|
||||
var ScoreboardService_ServiceDesc = grpc.ServiceDesc{
|
||||
ServiceName: "scoreboard.v1.ScoreboardService",
|
||||
HandlerType: (*ScoreboardServiceServer)(nil),
|
||||
Methods: []grpc.MethodDesc{
|
||||
{
|
||||
MethodName: "ListScoreboard",
|
||||
Handler: _ScoreboardService_ListScoreboard_Handler,
|
||||
},
|
||||
{
|
||||
MethodName: "GetByRank",
|
||||
Handler: _ScoreboardService_GetByRank_Handler,
|
||||
},
|
||||
{
|
||||
MethodName: "GetByUsername",
|
||||
Handler: _ScoreboardService_GetByUsername_Handler,
|
||||
},
|
||||
},
|
||||
Streams: []grpc.StreamDesc{},
|
||||
Metadata: "scoreboard/v1/scoreboard.proto",
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
module git.mkz.me/mycroft/root-me-api
|
||||
|
||||
go 1.26.3
|
||||
|
||||
require (
|
||||
github.com/PuerkitoBio/goquery v1.12.0
|
||||
golang.org/x/sync v0.20.0
|
||||
google.golang.org/grpc v1.81.1
|
||||
google.golang.org/protobuf v1.36.11
|
||||
)
|
||||
|
||||
require (
|
||||
github.com/andybalholm/cascadia v1.3.3 // indirect
|
||||
golang.org/x/net v0.52.0 // indirect
|
||||
golang.org/x/sys v0.42.0 // indirect
|
||||
golang.org/x/text v0.35.0 // indirect
|
||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20260226221140-a57be14db171 // indirect
|
||||
)
|
||||
@@ -0,0 +1,109 @@
|
||||
github.com/PuerkitoBio/goquery v1.12.0 h1:pAcL4g3WRXekcB9AU/y1mbKez2dbY2AajVhtkO8RIBo=
|
||||
github.com/PuerkitoBio/goquery v1.12.0/go.mod h1:802ej+gV2y7bbIhOIoPY5sT183ZW0YFofScC4q/hIpQ=
|
||||
github.com/andybalholm/cascadia v1.3.3 h1:AG2YHrzJIm4BZ19iwJ/DAua6Btl3IwJX+VI4kktS1LM=
|
||||
github.com/andybalholm/cascadia v1.3.3/go.mod h1:xNd9bqTn98Ln4DwST8/nG+H0yuB8Hmgu1YHNnWw0GeA=
|
||||
github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=
|
||||
github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
|
||||
github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI=
|
||||
github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
|
||||
github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag=
|
||||
github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE=
|
||||
github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek=
|
||||
github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps=
|
||||
github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
|
||||
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
|
||||
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
|
||||
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
|
||||
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
||||
github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
|
||||
go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64=
|
||||
go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y=
|
||||
go.opentelemetry.io/otel v1.43.0 h1:mYIM03dnh5zfN7HautFE4ieIig9amkNANT+xcVxAj9I=
|
||||
go.opentelemetry.io/otel v1.43.0/go.mod h1:JuG+u74mvjvcm8vj8pI5XiHy1zDeoCS2LB1spIq7Ay0=
|
||||
go.opentelemetry.io/otel/metric v1.43.0 h1:d7638QeInOnuwOONPp4JAOGfbCEpYb+K6DVWvdxGzgM=
|
||||
go.opentelemetry.io/otel/metric v1.43.0/go.mod h1:RDnPtIxvqlgO8GRW18W6Z/4P462ldprJtfxHxyKd2PY=
|
||||
go.opentelemetry.io/otel/sdk v1.43.0 h1:pi5mE86i5rTeLXqoF/hhiBtUNcrAGHLKQdhg4h4V9Dg=
|
||||
go.opentelemetry.io/otel/sdk v1.43.0/go.mod h1:P+IkVU3iWukmiit/Yf9AWvpyRDlUeBaRg6Y+C58QHzg=
|
||||
go.opentelemetry.io/otel/sdk/metric v1.43.0 h1:S88dyqXjJkuBNLeMcVPRFXpRw2fuwdvfCGLEo89fDkw=
|
||||
go.opentelemetry.io/otel/sdk/metric v1.43.0/go.mod h1:C/RJtwSEJ5hzTiUz5pXF1kILHStzb9zFlIEe85bhj6A=
|
||||
go.opentelemetry.io/otel/trace v1.43.0 h1:BkNrHpup+4k4w+ZZ86CZoHHEkohws8AY+WTX09nk+3A=
|
||||
go.opentelemetry.io/otel/trace v1.43.0/go.mod h1:/QJhyVBUUswCphDVxq+8mld+AvhXZLhe+8WVFxiFff0=
|
||||
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
|
||||
golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
|
||||
golang.org/x/crypto v0.13.0/go.mod h1:y6Z2r+Rw4iayiXXAIxJIDAJ1zMW4yaTpebo8fPOliYc=
|
||||
golang.org/x/crypto v0.19.0/go.mod h1:Iy9bg/ha4yyC70EfRS8jz+B6ybOBKMaSxLj6P6oBDfU=
|
||||
golang.org/x/crypto v0.23.0/go.mod h1:CKFgDieR+mRhux2Lsu27y0fO304Db0wZe70UKqHu0v8=
|
||||
golang.org/x/crypto v0.31.0/go.mod h1:kDsLvtWBEx7MV9tJOj9bnXsPbxwJQ6csT/x4KIN4Ssk=
|
||||
golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
|
||||
golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
|
||||
golang.org/x/mod v0.12.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
|
||||
golang.org/x/mod v0.15.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c=
|
||||
golang.org/x/mod v0.17.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c=
|
||||
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
|
||||
golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
|
||||
golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=
|
||||
golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs=
|
||||
golang.org/x/net v0.10.0/go.mod h1:0qNGK6F8kojg2nk9dLZ2mShWaEBan6FAoqfSigmmuDg=
|
||||
golang.org/x/net v0.15.0/go.mod h1:idbUs1IY1+zTqbi8yxTbhexhEEk5ur9LInksu6HrEpk=
|
||||
golang.org/x/net v0.21.0/go.mod h1:bIjVDfnllIU7BJ2DNgfnXvpSvtn8VRwhlsaeUTyUS44=
|
||||
golang.org/x/net v0.25.0/go.mod h1:JkAGAh7GEvH74S6FOH42FLoXpXbE/aqXSrIQjXgsiwM=
|
||||
golang.org/x/net v0.33.0/go.mod h1:HXLR5J+9DxmrqMwG9qjGCxZ+zKXxBru04zlTvWlWuN4=
|
||||
golang.org/x/net v0.52.0 h1:He/TN1l0e4mmR3QqHMT2Xab3Aj3L9qjbhRm78/6jrW0=
|
||||
golang.org/x/net v0.52.0/go.mod h1:R1MAz7uMZxVMualyPXb+VaqGSa3LIaUqk0eEt3w36Sw=
|
||||
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.3.0/go.mod h1:FU7BRWz2tNW+3quACPkgCx/L+uEAv1htQ0V83Z9Rj+Y=
|
||||
golang.org/x/sync v0.6.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
|
||||
golang.org/x/sync v0.7.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
|
||||
golang.org/x/sync v0.10.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
|
||||
golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4=
|
||||
golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
|
||||
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
||||
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.17.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
||||
golang.org/x/sys v0.20.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
||||
golang.org/x/sys v0.28.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
||||
golang.org/x/sys v0.42.0 h1:omrd2nAlyT5ESRdCLYdm3+fMfNFE/+Rf4bDIQImRJeo=
|
||||
golang.org/x/sys v0.42.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
|
||||
golang.org/x/telemetry v0.0.0-20240228155512-f48c80bd79b2/go.mod h1:TeRTkGYfJXctD9OcfyVLyj2J3IxLnKwHJR8f4D8a3YE=
|
||||
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
|
||||
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
|
||||
golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k=
|
||||
golang.org/x/term v0.8.0/go.mod h1:xPskH00ivmX89bAKVGSKKtLOWNx2+17Eiy94tnKShWo=
|
||||
golang.org/x/term v0.12.0/go.mod h1:owVbMEjm3cBLCHdkQu9b1opXd4ETQWc3BhuQGKgXgvU=
|
||||
golang.org/x/term v0.17.0/go.mod h1:lLRBjIVuehSbZlaOtGMbcMncT+aqLLLmKrsjNrUguwk=
|
||||
golang.org/x/term v0.20.0/go.mod h1:8UkIAJTvZgivsXaD6/pH6U9ecQzZ45awqEOzuCvwpFY=
|
||||
golang.org/x/term v0.27.0/go.mod h1:iMsnZpn0cago0GOrHO2+Y7u7JPn5AylBrcoWkElMTSM=
|
||||
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
|
||||
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
|
||||
golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
|
||||
golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
|
||||
golang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8=
|
||||
golang.org/x/text v0.13.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE=
|
||||
golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
|
||||
golang.org/x/text v0.15.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
|
||||
golang.org/x/text v0.21.0/go.mod h1:4IBbMaMmOPCJ8SecivzSH54+73PCFmPWxNTLm+vZkEQ=
|
||||
golang.org/x/text v0.35.0 h1:JOVx6vVDFokkpaq1AEptVzLTpDe9KGpj5tR4/X+ybL8=
|
||||
golang.org/x/text v0.35.0/go.mod h1:khi/HExzZJ2pGnjenulevKNX1W67CUy0AsXcNubPGCA=
|
||||
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
|
||||
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
|
||||
golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc=
|
||||
golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU=
|
||||
golang.org/x/tools v0.13.0/go.mod h1:HvlwmtVNQAhOuCjW7xxvovg8wbNq7LwfXh/k7wXUl58=
|
||||
golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d/go.mod h1:aiJjzUbINMkxbQROHiO6hDPo2LHcIPhhQsa9DLh0yGk=
|
||||
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||
gonum.org/v1/gonum v0.17.0 h1:VbpOemQlsSMrYmn7T2OUvQ4dqxQXU+ouZFQsZOx50z4=
|
||||
gonum.org/v1/gonum v0.17.0/go.mod h1:El3tOrEuMpv2UdMrbNlKEh9vd86bmQ6vqIcDwxEOc1E=
|
||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20260226221140-a57be14db171 h1:ggcbiqK8WWh6l1dnltU4BgWGIGo+EVYxCaAPih/zQXQ=
|
||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20260226221140-a57be14db171/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8=
|
||||
google.golang.org/grpc v1.81.1 h1:VnnIIZ88UzOOKLukQi+ImGz8O1Wdp8nAGGnvOfEIWQQ=
|
||||
google.golang.org/grpc v1.81.1/go.mod h1:xGH9GfzOyMTGIOXBJmXt+BX/V0kcdQbdcuwQ/zNw42I=
|
||||
google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE=
|
||||
google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
|
||||
@@ -0,0 +1,111 @@
|
||||
package anubis
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"regexp"
|
||||
)
|
||||
|
||||
// Anubis embeds several JSON blobs in its interstitial as
|
||||
// <script id="...">{...}</script> elements. We read two:
|
||||
//
|
||||
// - anubis_challenge: the canonical challenge (id, randomData, rules)
|
||||
// - preact_info: the preact frontend's view, crucially the `redir`
|
||||
// (the fully-formed pass-challenge URL) and `challenge` string.
|
||||
//
|
||||
// root-me.org uses the "preact" challenge: the answer is simply
|
||||
// SHA-256(randomData) (no proof-of-work nonce), gated by a minimum wait of
|
||||
// difficulty*80ms server-side.
|
||||
type anubisChallenge struct {
|
||||
Challenge struct {
|
||||
ID string `json:"id"`
|
||||
Method string `json:"method"`
|
||||
RandomData string `json:"randomData"`
|
||||
IssuedAt string `json:"issuedAt"`
|
||||
} `json:"challenge"`
|
||||
Rules struct {
|
||||
Difficulty int `json:"difficulty"`
|
||||
Algorithm string `json:"algorithm"`
|
||||
} `json:"rules"`
|
||||
}
|
||||
|
||||
type preactInfo struct {
|
||||
Challenge string `json:"challenge"`
|
||||
Difficulty int `json:"difficulty"`
|
||||
Redir string `json:"redir"` // relative pass-challenge URL with id+redir baked in
|
||||
}
|
||||
|
||||
// challenge is the normalized view the transport acts on.
|
||||
type challenge struct {
|
||||
algorithm string
|
||||
id string
|
||||
randomData string
|
||||
difficulty int
|
||||
redir string // preact: server-provided relative pass-challenge URL
|
||||
}
|
||||
|
||||
func scriptRe(id string) *regexp.Regexp {
|
||||
return regexp.MustCompile(
|
||||
`(?is)<script[^>]*\bid=["']` + regexp.QuoteMeta(id) + `["'][^>]*>(.*?)</script>`)
|
||||
}
|
||||
|
||||
var (
|
||||
anubisChallengeRe = scriptRe("anubis_challenge")
|
||||
preactInfoRe = scriptRe("preact_info")
|
||||
)
|
||||
|
||||
// isInterstitial reports whether an HTML body is an Anubis challenge page.
|
||||
func isInterstitial(body []byte) bool {
|
||||
return anubisChallengeRe.Match(body)
|
||||
}
|
||||
|
||||
func extractJSON(re *regexp.Regexp, body []byte, into any) error {
|
||||
m := re.FindSubmatch(body)
|
||||
if m == nil {
|
||||
return fmt.Errorf("anubis: script element not found")
|
||||
}
|
||||
if err := json.Unmarshal(m[1], into); err != nil {
|
||||
return fmt.Errorf("anubis: decode embedded JSON: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// parseChallenge extracts and normalizes the Anubis challenge from
|
||||
// interstitial HTML.
|
||||
func parseChallenge(body []byte) (*challenge, error) {
|
||||
var ac anubisChallenge
|
||||
if err := extractJSON(anubisChallengeRe, body, &ac); err != nil {
|
||||
return nil, fmt.Errorf("anubis: parse anubis_challenge: %w", err)
|
||||
}
|
||||
if ac.Challenge.RandomData == "" || ac.Rules.Difficulty <= 0 {
|
||||
return nil, fmt.Errorf("anubis: incomplete challenge (randomData=%q difficulty=%d)",
|
||||
ac.Challenge.RandomData, ac.Rules.Difficulty)
|
||||
}
|
||||
|
||||
c := &challenge{
|
||||
algorithm: ac.Rules.Algorithm,
|
||||
id: ac.Challenge.ID,
|
||||
randomData: ac.Challenge.RandomData,
|
||||
difficulty: ac.Rules.Difficulty,
|
||||
}
|
||||
|
||||
// The preact challenge carries the fully-formed pass-challenge URL in
|
||||
// preact_info.redir; grab it when present.
|
||||
if c.algorithm == "preact" {
|
||||
var pi preactInfo
|
||||
if err := extractJSON(preactInfoRe, body, &pi); err != nil {
|
||||
return nil, fmt.Errorf("anubis: parse preact_info: %w", err)
|
||||
}
|
||||
if pi.Redir == "" {
|
||||
return nil, fmt.Errorf("anubis: preact_info missing redir")
|
||||
}
|
||||
c.redir = pi.Redir
|
||||
if pi.Challenge != "" {
|
||||
c.randomData = pi.Challenge
|
||||
}
|
||||
if pi.Difficulty > 0 {
|
||||
c.difficulty = pi.Difficulty
|
||||
}
|
||||
}
|
||||
return c, nil
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
package anubis
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// resolveRef resolves a (possibly relative) URL reference against a base URL,
|
||||
// preserving the base's scheme and host. Anubis's preact redir is host-relative
|
||||
// (e.g. "/.within.website/...").
|
||||
func resolveRef(base *url.URL, ref string) *url.URL {
|
||||
u, err := url.Parse(ref)
|
||||
if err != nil {
|
||||
// Fall back to a copy of base; the caller's cookie check will catch it.
|
||||
c := *base
|
||||
return &c
|
||||
}
|
||||
return base.ResolveReference(u)
|
||||
}
|
||||
|
||||
// maybeHTML reports whether a response could be an HTML page (and thus possibly
|
||||
// an Anubis interstitial). Non-HTML responses are passed through untouched.
|
||||
func maybeHTML(resp *http.Response) bool {
|
||||
ct := resp.Header.Get("Content-Type")
|
||||
if ct == "" {
|
||||
return true // unknown; be safe and inspect
|
||||
}
|
||||
return strings.Contains(strings.ToLower(ct), "text/html")
|
||||
}
|
||||
|
||||
// withBody returns resp with its body replaced by an in-memory reader over b,
|
||||
// so the (already consumed) body can be read again by the caller.
|
||||
func withBody(resp *http.Response, b []byte) *http.Response {
|
||||
resp.Body = io.NopCloser(bytes.NewReader(b))
|
||||
resp.ContentLength = int64(len(b))
|
||||
resp.Header.Del("Content-Length")
|
||||
return resp
|
||||
}
|
||||
|
||||
// applyCookies replaces the request's Cookie header with the cookies the shared
|
||||
// jar holds for the request URL. The replayed request goes through the base
|
||||
// transport directly (bypassing the client jar), so we must attach the freshly
|
||||
// obtained auth cookie ourselves.
|
||||
func (t *Transport) applyCookies(req *http.Request) {
|
||||
req.Header.Del("Cookie")
|
||||
for _, c := range t.Jar.Cookies(req.URL) {
|
||||
req.AddCookie(c)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
// Package anubis implements a native Go client for getting past the Anubis
|
||||
// (TecharoHQ) proof-of-work anti-scraper proxy without a headless browser.
|
||||
//
|
||||
// The challenge is embedded in the interstitial HTML as a JSON document inside
|
||||
// a <script id="anubis_challenge"> element. The client must find an integer
|
||||
// nonce such that SHA-256(randomData + nonce) has `difficulty` leading hex-zero
|
||||
// digits, then submit it to the pass-challenge endpoint to obtain a signed-JWT
|
||||
// auth cookie.
|
||||
package anubis
|
||||
|
||||
import (
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"strconv"
|
||||
)
|
||||
|
||||
// sha256Hex returns the hex-encoded SHA-256 of s. This is the answer to a
|
||||
// "preact" Anubis challenge: result = SHA256(randomData).
|
||||
func sha256Hex(s string) string {
|
||||
sum := sha256.Sum256([]byte(s))
|
||||
return hex.EncodeToString(sum[:])
|
||||
}
|
||||
|
||||
// Solve performs the Anubis proof-of-work: it searches for the smallest nonce
|
||||
// (starting at 0) such that SHA-256(randomData + nonce) has `difficulty`
|
||||
// leading hex-zero digits. It returns the hex-encoded hash and the nonce.
|
||||
//
|
||||
// The zero check mirrors the Anubis worker: the first floor(difficulty/2)
|
||||
// bytes must be zero and, when difficulty is odd, the high nibble of the next
|
||||
// byte must also be zero.
|
||||
func Solve(randomData string, difficulty int) (hash string, nonce uint64) {
|
||||
prefix := []byte(randomData)
|
||||
zeroBytes := difficulty / 2
|
||||
oddNibble := difficulty%2 != 0
|
||||
|
||||
// Reusable buffer: challenge prefix + decimal nonce, refilled each round.
|
||||
buf := make([]byte, 0, len(prefix)+20)
|
||||
|
||||
for n := uint64(0); ; n++ {
|
||||
buf = append(buf[:0], prefix...)
|
||||
buf = strconv.AppendUint(buf, n, 10)
|
||||
sum := sha256.Sum256(buf)
|
||||
|
||||
if leadingZeros(sum[:], zeroBytes, oddNibble) {
|
||||
return hex.EncodeToString(sum[:]), n
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// leadingZeros reports whether sum has the required number of leading hex-zero
|
||||
// digits: zeroBytes fully-zero bytes, plus (when oddNibble) a zero high nibble
|
||||
// in the following byte.
|
||||
func leadingZeros(sum []byte, zeroBytes int, oddNibble bool) bool {
|
||||
for i := range zeroBytes {
|
||||
if sum[i] != 0 {
|
||||
return false
|
||||
}
|
||||
}
|
||||
if oddNibble {
|
||||
return sum[zeroBytes]>>4 == 0
|
||||
}
|
||||
return true
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
package anubis
|
||||
|
||||
import (
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"strconv"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestSolveProducesLeadingZeros(t *testing.T) {
|
||||
cases := []struct {
|
||||
randomData string
|
||||
difficulty int
|
||||
}{
|
||||
{"abc123", 1},
|
||||
{"deadbeefcafebabe", 2},
|
||||
{"root-me-scoreboard", 3},
|
||||
{"odd-difficulty-check", 5},
|
||||
}
|
||||
|
||||
for _, tc := range cases {
|
||||
hash, nonce := Solve(tc.randomData, tc.difficulty)
|
||||
|
||||
// The hash must have exactly `difficulty` leading '0' hex digits.
|
||||
if got := leadingZeroDigits(hash); got < tc.difficulty {
|
||||
t.Errorf("Solve(%q, %d): hash %s has %d leading zeros, want >= %d",
|
||||
tc.randomData, tc.difficulty, hash, got, tc.difficulty)
|
||||
}
|
||||
|
||||
// And it must be reproducible: SHA-256(randomData + nonce) == hash.
|
||||
want := sha256.Sum256([]byte(tc.randomData + strconv.FormatUint(nonce, 10)))
|
||||
if hex.EncodeToString(want[:]) != hash {
|
||||
t.Errorf("Solve(%q, %d): hash not reproducible from nonce %d",
|
||||
tc.randomData, tc.difficulty, nonce)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestSolveFindsSmallestNonce(t *testing.T) {
|
||||
// Brute the expected smallest nonce independently and compare.
|
||||
const data, diff = "smallest", 2 // difficulty 2 == first byte zero
|
||||
var want uint64
|
||||
for {
|
||||
sum := sha256.Sum256([]byte(data + strconv.FormatUint(want, 10)))
|
||||
if sum[0] == 0 {
|
||||
break
|
||||
}
|
||||
want++
|
||||
}
|
||||
if _, nonce := Solve(data, diff); nonce != want {
|
||||
t.Errorf("Solve smallest nonce = %d, want %d", nonce, want)
|
||||
}
|
||||
}
|
||||
|
||||
func leadingZeroDigits(hexHash string) int {
|
||||
return len(hexHash) - len(strings.TrimLeft(hexHash, "0"))
|
||||
}
|
||||
@@ -0,0 +1,269 @@
|
||||
package anubis
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"io"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"net/http/cookiejar"
|
||||
"net/url"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"golang.org/x/sync/singleflight"
|
||||
)
|
||||
|
||||
const (
|
||||
// AuthCookie is the signed-JWT cookie Anubis sets once a challenge passes.
|
||||
AuthCookie = "techaro.lol-anubis-auth"
|
||||
// passChallengePath is the endpoint that validates a solved challenge.
|
||||
passChallengePath = "/.within.website/x/cmd/anubis/api/pass-challenge"
|
||||
// DefaultUserAgent is a stable, browser-like UA. The Anubis JWT is bound to
|
||||
// request metadata (incl. User-Agent), so every request through this
|
||||
// transport must use the same value.
|
||||
DefaultUserAgent = "Mozilla/5.0 (X11; Linux x86_64; rv:128.0) Gecko/20100101 Firefox/128.0"
|
||||
)
|
||||
|
||||
// maxBodyPeek caps how much of a response body we read to detect/parse a
|
||||
// challenge. Anubis interstitials are small; real pages can be large, so we
|
||||
// only buffer when the response looks like an interstitial.
|
||||
const maxChallengePeek = 1 << 20 // 1 MiB
|
||||
|
||||
// Transport is an http.RoundTripper that transparently solves Anubis
|
||||
// proof-of-work challenges. When a request hits an interstitial, it solves the
|
||||
// PoW, calls pass-challenge to obtain the auth cookie (stored in a shared
|
||||
// jar), then replays the original request.
|
||||
//
|
||||
// A singleflight group collapses concurrent solves so only one goroutine pays
|
||||
// the PoW cost while others wait for the resulting cookie.
|
||||
type Transport struct {
|
||||
Base http.RoundTripper
|
||||
UserAgent string
|
||||
Jar http.CookieJar
|
||||
Logger *slog.Logger
|
||||
|
||||
sf singleflight.Group
|
||||
mu sync.Mutex // guards client construction
|
||||
hc *http.Client
|
||||
}
|
||||
|
||||
// New returns a Transport with a fresh cookie jar and default settings.
|
||||
func New() (*Transport, error) {
|
||||
jar, err := cookiejar.New(nil)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("anubis: create cookie jar: %w", err)
|
||||
}
|
||||
return &Transport{
|
||||
Base: http.DefaultTransport,
|
||||
UserAgent: DefaultUserAgent,
|
||||
Jar: jar,
|
||||
Logger: slog.Default(),
|
||||
}, nil
|
||||
}
|
||||
|
||||
// Client returns an *http.Client that uses this Transport and shares its cookie
|
||||
// jar, so callers benefit from both the auto-solving and the persisted cookie.
|
||||
func (t *Transport) Client() *http.Client {
|
||||
t.mu.Lock()
|
||||
defer t.mu.Unlock()
|
||||
if t.hc == nil {
|
||||
t.hc = &http.Client{Transport: t, Jar: t.Jar, Timeout: 30 * time.Second}
|
||||
}
|
||||
return t.hc
|
||||
}
|
||||
|
||||
// RoundTrip implements http.RoundTripper.
|
||||
func (t *Transport) RoundTrip(req *http.Request) (*http.Response, error) {
|
||||
t.setUA(req)
|
||||
|
||||
resp, err := t.base().RoundTrip(req)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Anubis serves interstitials as 200/text-html. Cheap content-type gate
|
||||
// before we buffer anything.
|
||||
if !maybeHTML(resp) {
|
||||
return resp, nil
|
||||
}
|
||||
|
||||
body, err := io.ReadAll(io.LimitReader(resp.Body, maxChallengePeek))
|
||||
if err != nil {
|
||||
resp.Body.Close()
|
||||
return nil, fmt.Errorf("anubis: read body: %w", err)
|
||||
}
|
||||
resp.Body.Close()
|
||||
|
||||
if !isInterstitial(body) {
|
||||
// Not a challenge; hand back a response with a replayable body.
|
||||
return withBody(resp, body), nil
|
||||
}
|
||||
|
||||
if t.Logger != nil {
|
||||
t.Logger.Debug("anubis interstitial detected", "url", req.URL.String())
|
||||
}
|
||||
// The interstitial sets a cookie-verification cookie that pass-challenge
|
||||
// requires. The outer http.Client only commits response cookies to the jar
|
||||
// after RoundTrip returns, so capture them now — before we solve.
|
||||
t.Jar.SetCookies(req.URL, resp.Cookies())
|
||||
|
||||
if err := t.solveFor(req, body); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Replay the original request now that we hold the auth cookie. The replay
|
||||
// goes through the base transport directly, so attach jar cookies manually.
|
||||
replay := req.Clone(req.Context())
|
||||
t.setUA(replay)
|
||||
t.applyCookies(replay)
|
||||
resp2, err := t.base().RoundTrip(replay)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if maybeHTML(resp2) {
|
||||
body2, err := io.ReadAll(io.LimitReader(resp2.Body, maxChallengePeek))
|
||||
resp2.Body.Close()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("anubis: read replayed body: %w", err)
|
||||
}
|
||||
if isInterstitial(body2) {
|
||||
return nil, fmt.Errorf("anubis: still challenged after passing challenge for %s", req.URL)
|
||||
}
|
||||
return withBody(resp2, body2), nil
|
||||
}
|
||||
return resp2, nil
|
||||
}
|
||||
|
||||
// solveFor parses the challenge from body, computes the answer, and calls
|
||||
// pass-challenge to install the auth cookie. Concurrent calls for the same
|
||||
// host are collapsed via singleflight.
|
||||
func (t *Transport) solveFor(req *http.Request, body []byte) error {
|
||||
key := req.URL.Host
|
||||
_, err, _ := t.sf.Do(key, func() (any, error) {
|
||||
c, err := parseChallenge(body)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
passURL, err := t.answer(req, c)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return nil, t.passChallenge(req, passURL)
|
||||
})
|
||||
return err
|
||||
}
|
||||
|
||||
// answer computes the pass-challenge URL for the given challenge, performing
|
||||
// any required wait or proof-of-work. It supports the "preact" challenge (a
|
||||
// plain SHA-256 of randomData plus a timing gate) and the "fast"/"slow"
|
||||
// proof-of-work challenges (nonce search).
|
||||
func (t *Transport) answer(orig *http.Request, c *challenge) (string, error) {
|
||||
start := time.Now()
|
||||
switch c.algorithm {
|
||||
case "preact":
|
||||
// Answer is SHA-256(randomData); the server enforces a minimum wait of
|
||||
// difficulty*80ms. Mirror the frontend's difficulty*125ms delay.
|
||||
result := sha256Hex(c.randomData)
|
||||
wait := time.Duration(c.difficulty) * 125 * time.Millisecond
|
||||
time.Sleep(wait)
|
||||
|
||||
u := resolveRef(orig.URL, c.redir)
|
||||
q := u.Query()
|
||||
q.Set("result", result)
|
||||
u.RawQuery = q.Encode()
|
||||
if t.Logger != nil {
|
||||
t.Logger.Info("answered anubis preact challenge",
|
||||
"difficulty", c.difficulty, "waited", wait)
|
||||
}
|
||||
return u.String(), nil
|
||||
|
||||
case "fast", "slow", "proofofwork", "":
|
||||
hash, nonce := Solve(c.randomData, c.difficulty)
|
||||
u := &url.URL{Scheme: orig.URL.Scheme, Host: orig.URL.Host, Path: passChallengePath}
|
||||
q := url.Values{
|
||||
"id": {c.id},
|
||||
"response": {hash},
|
||||
"nonce": {strconv.FormatUint(nonce, 10)},
|
||||
"redir": {orig.URL.RequestURI()},
|
||||
"elapsedTime": {strconv.FormatInt(time.Since(start).Milliseconds(), 10)},
|
||||
}
|
||||
u.RawQuery = q.Encode()
|
||||
if t.Logger != nil {
|
||||
t.Logger.Info("solved anubis pow challenge",
|
||||
"difficulty", c.difficulty, "nonce", nonce, "elapsed", time.Since(start))
|
||||
}
|
||||
return u.String(), nil
|
||||
|
||||
default:
|
||||
return "", fmt.Errorf("anubis: unsupported challenge algorithm %q", c.algorithm)
|
||||
}
|
||||
}
|
||||
|
||||
// passChallenge GETs the pass-challenge URL so Anubis sets the auth cookie in
|
||||
// the shared jar.
|
||||
func (t *Transport) passChallenge(orig *http.Request, passURL string) error {
|
||||
preq, err := http.NewRequestWithContext(orig.Context(), http.MethodGet, passURL, nil)
|
||||
if err != nil {
|
||||
return fmt.Errorf("anubis: build pass-challenge request: %w", err)
|
||||
}
|
||||
t.setUA(preq)
|
||||
preq.Header.Set("Referer", orig.URL.String())
|
||||
|
||||
// Don't follow the post-pass redirect; we only need the Set-Cookie. Use a
|
||||
// client bound to the shared jar so the cookie is captured.
|
||||
hc := &http.Client{
|
||||
Transport: t.base(),
|
||||
Jar: t.Jar,
|
||||
CheckRedirect: func(*http.Request, []*http.Request) error {
|
||||
return http.ErrUseLastResponse
|
||||
},
|
||||
Timeout: 30 * time.Second,
|
||||
}
|
||||
resp, err := hc.Do(preq)
|
||||
if err != nil {
|
||||
return fmt.Errorf("anubis: pass-challenge: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
io.Copy(io.Discard, io.LimitReader(resp.Body, maxChallengePeek))
|
||||
|
||||
if !t.hasAuthCookie(orig.URL) {
|
||||
return fmt.Errorf("anubis: pass-challenge did not yield %s cookie (status %d)", AuthCookie, resp.StatusCode)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// hasAuthCookie reports whether the jar holds an Anubis auth cookie for u.
|
||||
// Deployments customize the cookie prefix (default "techaro.lol-anubis-auth",
|
||||
// root-me uses "anubis-cookie-auth"), so we match any non-empty cookie whose
|
||||
// name ends in "-auth" while excluding the "-cookie-verification" helper.
|
||||
func (t *Transport) hasAuthCookie(u *url.URL) bool {
|
||||
for _, c := range t.Jar.Cookies(u) {
|
||||
if c.Value == "" || strings.Contains(c.Name, "verification") {
|
||||
continue
|
||||
}
|
||||
if c.Name == AuthCookie || strings.HasSuffix(c.Name, "-auth") {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func (t *Transport) base() http.RoundTripper {
|
||||
if t.Base != nil {
|
||||
return t.Base
|
||||
}
|
||||
return http.DefaultTransport
|
||||
}
|
||||
|
||||
func (t *Transport) setUA(req *http.Request) {
|
||||
ua := t.UserAgent
|
||||
if ua == "" {
|
||||
ua = DefaultUserAgent
|
||||
}
|
||||
req.Header.Set("User-Agent", ua)
|
||||
if req.Header.Get("Accept-Language") == "" {
|
||||
req.Header.Set("Accept-Language", "en-US,en;q=0.9")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
// Package config loads daemon configuration from flags and environment
|
||||
// variables (flags take precedence; env provides defaults).
|
||||
package config
|
||||
|
||||
import (
|
||||
"flag"
|
||||
"fmt"
|
||||
"os"
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
"git.mkz.me/mycroft/root-me-api/internal/anubis"
|
||||
)
|
||||
|
||||
// Config holds all daemon settings.
|
||||
type Config struct {
|
||||
HTTPAddr string
|
||||
GRPCAddr string
|
||||
RefreshInterval time.Duration
|
||||
Pages int
|
||||
UserAgent string
|
||||
RequestTimeout time.Duration
|
||||
LogLevel string
|
||||
}
|
||||
|
||||
// Load parses configuration from the given args, falling back to environment
|
||||
// variables and then built-in defaults.
|
||||
func Load(args []string) (*Config, error) {
|
||||
c := &Config{}
|
||||
fs := flag.NewFlagSet("scoreboard-apid", flag.ContinueOnError)
|
||||
|
||||
fs.StringVar(&c.HTTPAddr, "http-addr", env("HTTP_ADDR", ":8080"), "HTTP/JSON listen address")
|
||||
fs.StringVar(&c.GRPCAddr, "grpc-addr", env("GRPC_ADDR", ":9090"), "gRPC listen address")
|
||||
fs.DurationVar(&c.RefreshInterval, "refresh-interval", envDuration("REFRESH_INTERVAL", 10*time.Minute), "scoreboard refresh interval")
|
||||
fs.IntVar(&c.Pages, "pages", envInt("SCOREBOARD_PAGES", 4), "number of scoreboard pages to scrape (50 rows each)")
|
||||
fs.StringVar(&c.UserAgent, "user-agent", env("USER_AGENT", anubis.DefaultUserAgent), "User-Agent for root-me requests")
|
||||
fs.DurationVar(&c.RequestTimeout, "request-timeout", envDuration("REQUEST_TIMEOUT", 30*time.Second), "per-request HTTP timeout")
|
||||
fs.StringVar(&c.LogLevel, "log-level", env("LOG_LEVEL", "info"), "log level: debug, info, warn, error")
|
||||
|
||||
if err := fs.Parse(args); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if c.Pages <= 0 {
|
||||
return nil, fmt.Errorf("pages must be > 0")
|
||||
}
|
||||
if c.RefreshInterval <= 0 {
|
||||
return nil, fmt.Errorf("refresh-interval must be > 0")
|
||||
}
|
||||
return c, nil
|
||||
}
|
||||
|
||||
func env(key, def string) string {
|
||||
if v, ok := os.LookupEnv(key); ok {
|
||||
return v
|
||||
}
|
||||
return def
|
||||
}
|
||||
|
||||
func envInt(key string, def int) int {
|
||||
if v, ok := os.LookupEnv(key); ok {
|
||||
if n, err := strconv.Atoi(v); err == nil {
|
||||
return n
|
||||
}
|
||||
}
|
||||
return def
|
||||
}
|
||||
|
||||
func envDuration(key string, def time.Duration) time.Duration {
|
||||
if v, ok := os.LookupEnv(key); ok {
|
||||
if d, err := time.ParseDuration(v); err == nil {
|
||||
return d
|
||||
}
|
||||
}
|
||||
return def
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
// Package scoreboard holds the root-me scoreboard domain type and the
|
||||
// in-memory store/refresher that serve it to the transports.
|
||||
package scoreboard
|
||||
|
||||
// Entry is a single row of the root-me scoreboard.
|
||||
//
|
||||
// Rank is the canonical 1-based position in score-descending order, assigned by
|
||||
// the scraper. (root-me's own displayed rank is noisy around ties, so we don't
|
||||
// rely on it.)
|
||||
type Entry struct {
|
||||
Rank int `json:"rank"`
|
||||
Username string `json:"username"`
|
||||
// ProfilePath is the site-relative profile path, e.g. "/skav".
|
||||
ProfilePath string `json:"profile_path,omitempty"`
|
||||
// Country is the two-letter country code from the flag icon, e.g. "fr".
|
||||
Country string `json:"country,omitempty"`
|
||||
// Grade is the root-me rank/grade name, e.g. "legend".
|
||||
Grade string `json:"grade,omitempty"`
|
||||
Score int `json:"score"`
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
package scoreboard
|
||||
|
||||
import (
|
||||
"context"
|
||||
"log/slog"
|
||||
"sync/atomic"
|
||||
"time"
|
||||
)
|
||||
|
||||
// FetchFunc retrieves a fresh set of scoreboard entries.
|
||||
type FetchFunc func(ctx context.Context) ([]Entry, error)
|
||||
|
||||
// Refresher periodically refreshes a Store from a FetchFunc, keeping the last
|
||||
// good snapshot when a refresh fails.
|
||||
type Refresher struct {
|
||||
store *Store
|
||||
fetch FetchFunc
|
||||
interval time.Duration
|
||||
logger *slog.Logger
|
||||
|
||||
lastErr atomic.Pointer[refreshError]
|
||||
successN atomic.Int64
|
||||
failureN atomic.Int64
|
||||
}
|
||||
|
||||
type refreshError struct {
|
||||
At time.Time
|
||||
Err error
|
||||
}
|
||||
|
||||
// NewRefresher wires a refresher. interval must be > 0.
|
||||
func NewRefresher(store *Store, fetch FetchFunc, interval time.Duration, logger *slog.Logger) *Refresher {
|
||||
if logger == nil {
|
||||
logger = slog.Default()
|
||||
}
|
||||
return &Refresher{store: store, fetch: fetch, interval: interval, logger: logger}
|
||||
}
|
||||
|
||||
// RefreshOnce performs a single refresh, updating the store on success.
|
||||
func (r *Refresher) RefreshOnce(ctx context.Context) error {
|
||||
start := time.Now()
|
||||
entries, err := r.fetch(ctx)
|
||||
if err != nil {
|
||||
r.failureN.Add(1)
|
||||
r.lastErr.Store(&refreshError{At: time.Now(), Err: err})
|
||||
r.logger.Error("scoreboard refresh failed", "err", err, "serving_stale", r.store.Ready())
|
||||
return err
|
||||
}
|
||||
r.store.Set(entries, time.Now())
|
||||
r.successN.Add(1)
|
||||
r.logger.Info("scoreboard refreshed", "entries", len(entries), "took", time.Since(start))
|
||||
return nil
|
||||
}
|
||||
|
||||
// Run blocks, refreshing on each tick until ctx is cancelled. It does NOT
|
||||
// refresh immediately; call RefreshOnce first if you want a warm start.
|
||||
func (r *Refresher) Run(ctx context.Context) {
|
||||
t := time.NewTicker(r.interval)
|
||||
defer t.Stop()
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
r.logger.Info("scoreboard refresher stopping")
|
||||
return
|
||||
case <-t.C:
|
||||
_ = r.RefreshOnce(ctx)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Stats reports refresh counters and the last error (nil if none) for /metrics
|
||||
// or health output.
|
||||
func (r *Refresher) Stats() (success, failure int64, lastErr error, lastErrAt time.Time) {
|
||||
if e := r.lastErr.Load(); e != nil {
|
||||
lastErr, lastErrAt = e.Err, e.At
|
||||
}
|
||||
return r.successN.Load(), r.failureN.Load(), lastErr, lastErrAt
|
||||
}
|
||||
@@ -0,0 +1,112 @@
|
||||
package scoreboard
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Snapshot is an immutable view of the scoreboard at a point in time.
|
||||
type Snapshot struct {
|
||||
Entries []Entry
|
||||
FetchedAt time.Time
|
||||
byRank map[int]Entry
|
||||
byUser map[string]Entry // lower-cased username -> entry
|
||||
}
|
||||
|
||||
func newSnapshot(entries []Entry, at time.Time) *Snapshot {
|
||||
s := &Snapshot{
|
||||
Entries: entries,
|
||||
FetchedAt: at,
|
||||
byRank: make(map[int]Entry, len(entries)),
|
||||
byUser: make(map[string]Entry, len(entries)),
|
||||
}
|
||||
for _, e := range entries {
|
||||
s.byRank[e.Rank] = e
|
||||
s.byUser[strings.ToLower(e.Username)] = e
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
// Store holds the latest scoreboard snapshot and serves concurrent reads.
|
||||
// It keeps the last good snapshot when a refresh fails.
|
||||
type Store struct {
|
||||
mu sync.RWMutex
|
||||
snap *Snapshot
|
||||
}
|
||||
|
||||
// NewStore returns an empty store. Ready reports false until Set is called.
|
||||
func NewStore() *Store { return &Store{} }
|
||||
|
||||
// Set atomically replaces the current snapshot with the given entries.
|
||||
func (s *Store) Set(entries []Entry, at time.Time) {
|
||||
snap := newSnapshot(entries, at)
|
||||
s.mu.Lock()
|
||||
s.snap = snap
|
||||
s.mu.Unlock()
|
||||
}
|
||||
|
||||
// Ready reports whether a snapshot has been loaded.
|
||||
func (s *Store) Ready() bool {
|
||||
s.mu.RLock()
|
||||
defer s.mu.RUnlock()
|
||||
return s.snap != nil
|
||||
}
|
||||
|
||||
// Snapshot returns the current snapshot (nil if none loaded yet).
|
||||
func (s *Store) Snapshot() *Snapshot {
|
||||
s.mu.RLock()
|
||||
defer s.mu.RUnlock()
|
||||
return s.snap
|
||||
}
|
||||
|
||||
// List returns a page of entries ordered by rank, honoring offset and limit.
|
||||
// limit <= 0 means "all from offset". It also returns the snapshot time.
|
||||
func (s *Store) List(offset, limit int) ([]Entry, time.Time) {
|
||||
snap := s.Snapshot()
|
||||
if snap == nil {
|
||||
return nil, time.Time{}
|
||||
}
|
||||
if offset < 0 {
|
||||
offset = 0
|
||||
}
|
||||
if offset >= len(snap.Entries) {
|
||||
return []Entry{}, snap.FetchedAt
|
||||
}
|
||||
end := len(snap.Entries)
|
||||
if limit > 0 && offset+limit < end {
|
||||
end = offset + limit
|
||||
}
|
||||
out := make([]Entry, end-offset)
|
||||
copy(out, snap.Entries[offset:end])
|
||||
return out, snap.FetchedAt
|
||||
}
|
||||
|
||||
// ByRank returns the entry at the given canonical rank.
|
||||
func (s *Store) ByRank(rank int) (Entry, bool) {
|
||||
snap := s.Snapshot()
|
||||
if snap == nil {
|
||||
return Entry{}, false
|
||||
}
|
||||
e, ok := snap.byRank[rank]
|
||||
return e, ok
|
||||
}
|
||||
|
||||
// ByUsername returns the entry for the given username (case-insensitive).
|
||||
func (s *Store) ByUsername(username string) (Entry, bool) {
|
||||
snap := s.Snapshot()
|
||||
if snap == nil {
|
||||
return Entry{}, false
|
||||
}
|
||||
e, ok := snap.byUser[strings.ToLower(strings.TrimSpace(username))]
|
||||
return e, ok
|
||||
}
|
||||
|
||||
// Len returns the number of entries in the current snapshot.
|
||||
func (s *Store) Len() int {
|
||||
snap := s.Snapshot()
|
||||
if snap == nil {
|
||||
return 0
|
||||
}
|
||||
return len(snap.Entries)
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
package scoreboard
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func sample() []Entry {
|
||||
return []Entry{
|
||||
{Rank: 1, Username: "skav", Score: 26435},
|
||||
{Rank: 2, Username: "Kkameleon", Score: 26000},
|
||||
{Rank: 3, Username: "ENOENT", Score: 25900},
|
||||
}
|
||||
}
|
||||
|
||||
func TestStoreLookups(t *testing.T) {
|
||||
s := NewStore()
|
||||
if s.Ready() {
|
||||
t.Fatal("empty store should not be ready")
|
||||
}
|
||||
s.Set(sample(), time.Now())
|
||||
if !s.Ready() || s.Len() != 3 {
|
||||
t.Fatalf("ready=%v len=%d", s.Ready(), s.Len())
|
||||
}
|
||||
|
||||
if e, ok := s.ByRank(2); !ok || e.Username != "Kkameleon" {
|
||||
t.Errorf("ByRank(2) = %+v ok=%v", e, ok)
|
||||
}
|
||||
if e, ok := s.ByUsername("SKAV"); !ok || e.Rank != 1 {
|
||||
t.Errorf("ByUsername(SKAV) = %+v ok=%v (case-insensitive lookup failed)", e, ok)
|
||||
}
|
||||
if _, ok := s.ByUsername("nobody"); ok {
|
||||
t.Error("ByUsername(nobody) should miss")
|
||||
}
|
||||
|
||||
page, _ := s.List(1, 1)
|
||||
if len(page) != 1 || page[0].Username != "Kkameleon" {
|
||||
t.Errorf("List(1,1) = %+v", page)
|
||||
}
|
||||
if page, _ := s.List(10, 5); len(page) != 0 {
|
||||
t.Errorf("List past end should be empty, got %+v", page)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRefresherKeepsLastGood(t *testing.T) {
|
||||
s := NewStore()
|
||||
calls := 0
|
||||
fetch := func(context.Context) ([]Entry, error) {
|
||||
calls++
|
||||
if calls == 1 {
|
||||
return sample(), nil
|
||||
}
|
||||
return nil, errors.New("boom")
|
||||
}
|
||||
r := NewRefresher(s, fetch, time.Hour, nil)
|
||||
|
||||
if err := r.RefreshOnce(context.Background()); err != nil {
|
||||
t.Fatalf("first refresh: %v", err)
|
||||
}
|
||||
if err := r.RefreshOnce(context.Background()); err == nil {
|
||||
t.Fatal("second refresh should have failed")
|
||||
}
|
||||
// Last good snapshot must still be served.
|
||||
if s.Len() != 3 {
|
||||
t.Errorf("expected last good snapshot of 3, got %d", s.Len())
|
||||
}
|
||||
ok, fail, lastErr, _ := r.Stats()
|
||||
if ok != 1 || fail != 1 || lastErr == nil {
|
||||
t.Errorf("stats: ok=%d fail=%d lastErr=%v", ok, fail, lastErr)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,124 @@
|
||||
package scraper
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"regexp"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/PuerkitoBio/goquery"
|
||||
|
||||
"git.mkz.me/mycroft/root-me-api/internal/scoreboard"
|
||||
)
|
||||
|
||||
// The rankings ajax fragment is a single <table> whose rows each have 6 <td>:
|
||||
//
|
||||
// [0] "# <rank>"
|
||||
// [1] avatar <img alt="<username>">
|
||||
// [2] <a href="/<user>?lang=en" title="profil of <user>"><user></a>
|
||||
// [3] country flag <img src=".../pays/<cc>.svg">
|
||||
// [4] grade icon <img src=".../rang/<grade>.svg" alt="<grade>">
|
||||
// [5] score <a href="<user>?inc=score" title="<big>"><visible-score></a>
|
||||
//
|
||||
// Selectors are intentionally lenient (match on structure/attributes rather
|
||||
// than volatile CSS classes) so minor template changes don't break parsing.
|
||||
|
||||
var (
|
||||
rankRe = regexp.MustCompile(`#?\s*(\d+)`)
|
||||
flagRe = regexp.MustCompile(`/pays/([a-zA-Z]{2})\.svg`)
|
||||
gradeRe = regexp.MustCompile(`/rang/([a-zA-Z0-9_-]+)\.svg`)
|
||||
nonDigits = regexp.MustCompile(`\D+`)
|
||||
)
|
||||
|
||||
// parseRankings parses one rankings fragment into entries.
|
||||
func parseRankings(html string) ([]scoreboard.Entry, error) {
|
||||
doc, err := goquery.NewDocumentFromReader(strings.NewReader(html))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("scraper: parse html: %w", err)
|
||||
}
|
||||
|
||||
var entries []scoreboard.Entry
|
||||
var rowErr error
|
||||
|
||||
doc.Find("table tr").Each(func(_ int, tr *goquery.Selection) {
|
||||
tds := tr.Find("td")
|
||||
if tds.Length() < 6 {
|
||||
return // header row (uses <td>Position</td>... but lacks the score link) or layout row
|
||||
}
|
||||
e, ok, err := parseRow(tds)
|
||||
if err != nil {
|
||||
rowErr = err
|
||||
return
|
||||
}
|
||||
if ok {
|
||||
entries = append(entries, e)
|
||||
}
|
||||
})
|
||||
if rowErr != nil {
|
||||
return nil, rowErr
|
||||
}
|
||||
if len(entries) == 0 {
|
||||
return nil, fmt.Errorf("scraper: no rows parsed (fragment layout may have changed)")
|
||||
}
|
||||
return entries, nil
|
||||
}
|
||||
|
||||
func parseRow(tds *goquery.Selection) (scoreboard.Entry, bool, error) {
|
||||
var e scoreboard.Entry
|
||||
|
||||
// [0] rank — header row's first cell is "Position" (no digits) → skip.
|
||||
rankText := strings.TrimSpace(tds.Eq(0).Text())
|
||||
m := rankRe.FindStringSubmatch(rankText)
|
||||
if m == nil {
|
||||
return e, false, nil // not a data row
|
||||
}
|
||||
rank, err := strconv.Atoi(m[1])
|
||||
if err != nil {
|
||||
return e, false, fmt.Errorf("scraper: bad rank %q: %w", rankText, err)
|
||||
}
|
||||
e.Rank = rank
|
||||
|
||||
// [2] username + profile path.
|
||||
link := tds.Eq(2).Find("a").First()
|
||||
e.Username = strings.TrimSpace(link.Text())
|
||||
if e.Username == "" {
|
||||
// Fall back to avatar alt in [1].
|
||||
e.Username = strings.TrimSpace(tds.Eq(1).Find("img").AttrOr("alt", ""))
|
||||
}
|
||||
if href, ok := link.Attr("href"); ok {
|
||||
if p := strings.SplitN(href, "?", 2)[0]; p != "" {
|
||||
e.ProfilePath = p
|
||||
}
|
||||
}
|
||||
if e.Username == "" {
|
||||
return e, false, fmt.Errorf("scraper: row rank %d has no username", rank)
|
||||
}
|
||||
|
||||
// [3] country flag.
|
||||
if src, ok := tds.Eq(3).Find("img").Attr("src"); ok {
|
||||
if fm := flagRe.FindStringSubmatch(src); fm != nil {
|
||||
e.Country = strings.ToLower(fm[1])
|
||||
}
|
||||
}
|
||||
|
||||
// [4] grade — prefer the icon's alt, else derive from the svg filename.
|
||||
grade := tds.Eq(4).Find("img").AttrOr("alt", "")
|
||||
if grade == "" {
|
||||
if src, ok := tds.Eq(4).Find("img").Attr("src"); ok {
|
||||
if gm := gradeRe.FindStringSubmatch(src); gm != nil {
|
||||
grade = gm[1]
|
||||
}
|
||||
}
|
||||
}
|
||||
e.Grade = strings.TrimSpace(grade)
|
||||
|
||||
// [5] score — visible text of the link.
|
||||
scoreText := nonDigits.ReplaceAllString(tds.Eq(5).Text(), "")
|
||||
if scoreText != "" {
|
||||
if e.Score, err = strconv.Atoi(scoreText); err != nil {
|
||||
return e, false, fmt.Errorf("scraper: bad score %q for rank %d: %w", scoreText, rank, err)
|
||||
}
|
||||
}
|
||||
|
||||
return e, true, nil
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
package scraper
|
||||
|
||||
import (
|
||||
"os"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// TestParseRankingsFixture parses the real rankings fragment captured live
|
||||
// (internal/scraper/testdata/scoreboard_page1.html) and sanity-checks it.
|
||||
func TestParseRankingsFixture(t *testing.T) {
|
||||
html, err := os.ReadFile("testdata/scoreboard_page1.html")
|
||||
if err != nil {
|
||||
t.Fatalf("read fixture: %v", err)
|
||||
}
|
||||
|
||||
entries, err := parseRankings(string(html))
|
||||
if err != nil {
|
||||
t.Fatalf("parseRankings: %v", err)
|
||||
}
|
||||
|
||||
if len(entries) != pageSize {
|
||||
t.Errorf("got %d entries, want %d", len(entries), pageSize)
|
||||
}
|
||||
|
||||
first := entries[0]
|
||||
if first.Username == "" {
|
||||
t.Error("first username is empty")
|
||||
}
|
||||
if first.Score <= 0 {
|
||||
t.Errorf("first score = %d, want > 0", first.Score)
|
||||
}
|
||||
|
||||
// Every entry must have a username and positive score. The fragment is
|
||||
// returned in score-descending order (root-me's displayed "# N" rank is
|
||||
// noisy around ties, so we assert on score, not rank).
|
||||
prev := first.Score
|
||||
for i, e := range entries {
|
||||
if e.Username == "" {
|
||||
t.Errorf("entry %d has empty username", i)
|
||||
}
|
||||
if e.Score <= 0 {
|
||||
t.Errorf("entry %d (%s) has non-positive score %d", i, e.Username, e.Score)
|
||||
}
|
||||
if e.Score > prev {
|
||||
t.Errorf("entry %d score %d > previous %d (not descending)", i, e.Score, prev)
|
||||
}
|
||||
prev = e.Score
|
||||
}
|
||||
|
||||
t.Logf("parsed %d entries; #1 = %s (%s, grade=%s) score=%d",
|
||||
len(entries), first.Username, first.Country, first.Grade, first.Score)
|
||||
}
|
||||
@@ -0,0 +1,109 @@
|
||||
// Package scraper fetches the root-me.org scoreboard (Rankings) pages through
|
||||
// an Anubis-solving HTTP client and parses them into scoreboard entries.
|
||||
package scraper
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"io"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"sort"
|
||||
|
||||
"git.mkz.me/mycroft/root-me-api/internal/scoreboard"
|
||||
)
|
||||
|
||||
// pageSize is the number of rows per rankings page (the debut_classement step).
|
||||
const pageSize = 50
|
||||
|
||||
// rankingsURL is the public, login-free ajax fragment that renders the
|
||||
// Rankings table. %d is the debut_classement offset.
|
||||
const rankingsURL = "https://www.root-me.org/?page=structure&inc=modeles/classement&lang=en&ajah=1&debut_classement=%d"
|
||||
|
||||
// Doer is the subset of *http.Client the scraper needs; the anubis.Transport's
|
||||
// client satisfies it.
|
||||
type Doer interface {
|
||||
Do(*http.Request) (*http.Response, error)
|
||||
}
|
||||
|
||||
// Scraper fetches and parses scoreboard pages.
|
||||
type Scraper struct {
|
||||
Client Doer
|
||||
Logger *slog.Logger
|
||||
}
|
||||
|
||||
// New returns a Scraper using the given HTTP client.
|
||||
func New(client Doer, logger *slog.Logger) *Scraper {
|
||||
if logger == nil {
|
||||
logger = slog.Default()
|
||||
}
|
||||
return &Scraper{Client: client, Logger: logger}
|
||||
}
|
||||
|
||||
// Fetch retrieves the first n pages of the scoreboard and returns the merged,
|
||||
// rank-sorted entries. Pages are fetched sequentially to share one Anubis solve
|
||||
// and to stay gentle on root-me / its rate limiter.
|
||||
func (s *Scraper) Fetch(ctx context.Context, pages int) ([]scoreboard.Entry, error) {
|
||||
if pages <= 0 {
|
||||
pages = 1
|
||||
}
|
||||
var all []scoreboard.Entry
|
||||
for p := range pages {
|
||||
offset := p * pageSize
|
||||
entries, err := s.fetchPage(ctx, offset)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("scraper: page %d (offset %d): %w", p+1, offset, err)
|
||||
}
|
||||
s.Logger.Debug("fetched rankings page", "page", p+1, "offset", offset, "rows", len(entries))
|
||||
all = append(all, entries...)
|
||||
}
|
||||
|
||||
all = dedupeByUsername(all)
|
||||
// root-me returns rows in score-descending order; sort defensively (stable,
|
||||
// so ties keep scrape order) and assign canonical 1-based ranks.
|
||||
sort.SliceStable(all, func(i, j int) bool { return all[i].Score > all[j].Score })
|
||||
for i := range all {
|
||||
all[i].Rank = i + 1
|
||||
}
|
||||
return all, nil
|
||||
}
|
||||
|
||||
func (s *Scraper) fetchPage(ctx context.Context, offset int) ([]scoreboard.Entry, error) {
|
||||
u := fmt.Sprintf(rankingsURL, offset)
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, u, nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
req.Header.Set("Accept", "text/html, */*; q=0.01")
|
||||
req.Header.Set("X-Requested-With", "XMLHttpRequest")
|
||||
|
||||
resp, err := s.Client.Do(req)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
body, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("read body: %w", err)
|
||||
}
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return nil, fmt.Errorf("unexpected status %d", resp.StatusCode)
|
||||
}
|
||||
return parseRankings(string(body))
|
||||
}
|
||||
|
||||
// dedupeByUsername drops duplicate users (defensive against overlapping pages),
|
||||
// keeping the first occurrence.
|
||||
func dedupeByUsername(in []scoreboard.Entry) []scoreboard.Entry {
|
||||
seen := make(map[string]struct{}, len(in))
|
||||
out := in[:0]
|
||||
for _, e := range in {
|
||||
if _, dup := seen[e.Username]; dup {
|
||||
continue
|
||||
}
|
||||
seen[e.Username] = struct{}{}
|
||||
out = append(out, e)
|
||||
}
|
||||
return out
|
||||
}
|
||||
+453
@@ -0,0 +1,453 @@
|
||||
|
||||
<div class='ajaxbloc' data-ajax-env='iYODfeSg8w3GrshPDtWYvYtlSvssc+OzjOfobTR5c7JF00/3ITSnQ7T8AIv+1NfE3+C7Z4JO8OXTZK1t5VeYYxVLrCKmMLZTQORQwqpeoaSjcCei7+4kFUXdNmSNK9nL2LUBRrknJmKqoeEfdyxdsfvsbuTGCvXbY50HzEcUdYS+esa/bpc5Hi+AHle3z5RyrpeAvSgtctKM3/2dtxxspLq7NjNnkCv8yN9FHL1kyGke86Wa0C/R/BymQ80jVlTSoRZpyhZZoC4vwDU=' data-origin="./?page=structure&inc=modeles%2Fclassement&lang=en&ajah=1">
|
||||
<h1><img src='squelettes/img/classement.svg?1647504561' width='48' height='48' class='vmiddle' itemprop='image' /> <span itemprop="headline name">Rankings</span></h1>
|
||||
<div class="clearfix"></div>
|
||||
<p class="pagination">
|
||||
<div class="pagination-centered">
|
||||
<ul class="pagination">
|
||||
<li class="current"><a href='./?page=structure&inc=modeles%2Fclassement&lang=en&ajah=1&debut_classement=0#pagination_debut_classement' class='lien_pagination gris' rel='nofollow'>1</a></li>
|
||||
<li><a href='./?page=structure&inc=modeles%2Fclassement&lang=en&ajah=1&debut_classement=50#pagination_debut_classement' class='lien_pagination gris' rel='nofollow'>2</a></li>
|
||||
<li><a href='./?page=structure&inc=modeles%2Fclassement&lang=en&ajah=1&debut_classement=100#pagination_debut_classement' class='lien_pagination gris' rel='nofollow'>3</a></li>
|
||||
<li><a href='./?page=structure&inc=modeles%2Fclassement&lang=en&ajah=1&debut_classement=150#pagination_debut_classement' class='lien_pagination gris' rel='nofollow'>4</a></li>
|
||||
<li><a href='./?page=structure&inc=modeles%2Fclassement&lang=en&ajah=1&debut_classement=200#pagination_debut_classement' class='lien_pagination gris' rel='nofollow'>5</a></li>
|
||||
<li><a href='./?page=structure&inc=modeles%2Fclassement&lang=en&ajah=1&debut_classement=250#pagination_debut_classement' class='lien_pagination gris' rel='nofollow'>6</a></li>
|
||||
<li><a href='./?page=structure&inc=modeles%2Fclassement&lang=en&ajah=1&debut_classement=300#pagination_debut_classement' class='lien_pagination gris' rel='nofollow'>7</a></li>
|
||||
<li><a href='./?page=structure&inc=modeles%2Fclassement&lang=en&ajah=1&debut_classement=350#pagination_debut_classement' class='lien_pagination gris' rel='nofollow'>8</a></li>
|
||||
<li><a href='./?page=structure&inc=modeles%2Fclassement&lang=en&ajah=1&debut_classement=400#pagination_debut_classement' class='lien_pagination gris' rel='nofollow'>9</a></li>
|
||||
<li><a href='./?page=structure&inc=modeles%2Fclassement&lang=en&ajah=1&debut_classement=50#pagination_debut_classement' class='lien_pagination gris' rel='nofollow'>></a></li>
|
||||
<li><a href='./?page=structure&inc=modeles%2Fclassement&lang=en&ajah=1&debut_classement=372900#pagination_debut_classement' class='lien_pagination gris' rel='nofollow'>...</a></li>
|
||||
</ul>
|
||||
</div>
|
||||
</p>
|
||||
<table class="text-center" style="width: 100%">
|
||||
<thead>
|
||||
<tr class="row_first">
|
||||
<td>Position</td>
|
||||
<td class="mauto">Avatar</td>
|
||||
<td>User <a class="mediabox pageajax" data-box-type="ajax" href="./?page=structure&inc=inclusions%2Flegende&lang=en#compte" title="Account type"><img src="squelettes/img/question_mark.svg" width="16" height="16" alt="Account type"/></a></td>
|
||||
<td class="show-for-medium-up">Lang</td>
|
||||
<td class="show-for-medium-up">Rank <a class="mediabox pageajax" data-box-type="ajax" href="./?page=structure&inc=inclusions%2Flegende&lang=en#score" title="Explanation for the scores"><img src="squelettes/img/question_mark.svg" width="16" height="16" alt="Explanation for the scores"/></a></td>
|
||||
<td>Score</td>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr class="row_even">
|
||||
<td class="gras"># 1</td>
|
||||
<td><img class="vmiddle logo_auteur logo_6forum" width="25" height="25" src="IMG/logo/auton0.png?1637503221" alt="skav" /></td>
|
||||
<td><a class=" txt_1comite" title="profil of skav" href="/skav?lang=en">skav</a></td>
|
||||
<td class="show-for-medium-up"><img src='squelettes/img/pays/fr.svg?1594376628' width='16' height='16' /></td>
|
||||
<td class="show-for-medium-up"><img src='squelettes/img/rang/legend.svg?1641984641' alt='legend' width='16' height='16' title='legend' /></td>
|
||||
<td><a href="skav?inc=score&lang=en" title="536559">26435</a></td>
|
||||
</tr>
|
||||
<tr class="row_odd">
|
||||
<td class="gras"># 1</td>
|
||||
<td><img class="vmiddle logo_auteur logo_1comite" width="25" height="25" src="IMG/logo/auton341626.png?1606567329" alt="Kkameleon" /></td>
|
||||
<td><a class=" txt_1comite" title="profil of Kkameleon" href="/Kkameleon?lang=en">Kkameleon</a></td>
|
||||
<td class="show-for-medium-up"><img src='squelettes/img/pays/fr.svg?1594376628' width='16' height='16' /></td>
|
||||
<td class="show-for-medium-up"><img src='squelettes/img/rang/legend.svg?1641984641' alt='legend' width='16' height='16' title='legend' /></td>
|
||||
<td><a href="Kkameleon?inc=score&lang=en" title="341626">26435</a></td>
|
||||
</tr>
|
||||
<tr class="row_even">
|
||||
<td class="gras"># 1</td>
|
||||
<td><img class="vmiddle logo_auteur logo_0minirezo" width="25" height="25" src="IMG/logo/auton49364.png?1493586230" alt="ENOENT" /></td>
|
||||
<td><a class=" txt_0minirezo" title="profil of ENOENT" href="/ENOENT?lang=en">ENOENT</a></td>
|
||||
<td class="show-for-medium-up"><img src='squelettes/img/pays/fr.svg?1594376628' width='16' height='16' /></td>
|
||||
<td class="show-for-medium-up"><img src='squelettes/img/rang/legend.svg?1641984641' alt='legend' width='16' height='16' title='legend' /></td>
|
||||
<td><a href="ENOENT?inc=score&lang=en" title="49364">26435</a></td>
|
||||
</tr>
|
||||
<tr class="row_odd">
|
||||
<td class="gras"># 4</td>
|
||||
<td><img class="vmiddle logo_auteur logo_6forum" width="25" height="25" src="IMG/logo/auton366289.png?1665089268" alt="CharlB" /></td>
|
||||
<td><a class=" txt_6forum" title="profil of CharlB" href="/CharlB?lang=en">CharlB</a></td>
|
||||
<td class="show-for-medium-up"><img src='squelettes/img/pays/fr.svg?1594376628' width='16' height='16' /></td>
|
||||
<td class="show-for-medium-up"><img src='squelettes/img/rang/legend.svg?1641984641' alt='legend' width='16' height='16' title='legend' /></td>
|
||||
<td><a href="CharlB?inc=score&lang=en" title="366289">26105</a></td>
|
||||
</tr>
|
||||
<tr class="row_even">
|
||||
<td class="gras"># 5</td>
|
||||
<td><img class="vmiddle logo_auteur logo_1comite" width="25" height="25" src="IMG/logo/auton592353.jpg?1696598810" alt="ToG" /></td>
|
||||
<td><a class=" txt_1comite" title="profil of ToG" href="/ToG?lang=en">ToG</a></td>
|
||||
<td class="show-for-medium-up"><img src='squelettes/img/pays/fr.svg?1594376628' width='16' height='16' /></td>
|
||||
<td class="show-for-medium-up"><img src='squelettes/img/rang/legend.svg?1641984641' alt='legend' width='16' height='16' title='legend' /></td>
|
||||
<td><a href="ToG?inc=score&lang=en" title="592353">25880</a></td>
|
||||
</tr>
|
||||
<tr class="row_odd">
|
||||
<td class="gras"># 6</td>
|
||||
<td><img class="vmiddle logo_auteur logo_6forum" width="25" height="25" src="IMG/logo/auton221019.jpg?1565189393" alt="macz" /></td>
|
||||
<td><a class=" txt_6forum" title="profil of macz" href="/macz?lang=en">macz</a></td>
|
||||
<td class="show-for-medium-up"><img src='squelettes/img/pays/en.svg?1637569714' width='16' height='16' /></td>
|
||||
<td class="show-for-medium-up"><img src='squelettes/img/rang/legend.svg?1641984641' alt='legend' width='16' height='16' title='legend' /></td>
|
||||
<td><a href="macz?inc=score&lang=en" title="221019">25875</a></td>
|
||||
</tr>
|
||||
<tr class="row_even">
|
||||
<td class="gras"># 7</td>
|
||||
<td><img class="vmiddle logo_auteur logo_6forum" width="25" height="25" src="IMG/logo/auton14516.jpg?1474128409" alt="blackndoor" /></td>
|
||||
<td><a class=" txt_6forum" title="profil of blackndoor" href="/blackndoor?lang=en">blackndoor</a></td>
|
||||
<td class="show-for-medium-up"><img src='squelettes/img/pays/fr.svg?1594376628' width='16' height='16' /></td>
|
||||
<td class="show-for-medium-up"><img src='squelettes/img/rang/legend.svg?1641984641' alt='legend' width='16' height='16' title='legend' /></td>
|
||||
<td><a href="blackndoor?inc=score&lang=en" title="14516">25565</a></td>
|
||||
</tr>
|
||||
<tr class="row_odd">
|
||||
<td class="gras"># 8</td>
|
||||
<td><img class="vmiddle logo_auteur logo_6forum" width="25" height="25" src="IMG/logo/auton892829.png?1779836646" alt="geky" /></td>
|
||||
<td><a class=" txt_6forum" title="profil of geky" href="/geky-892829?lang=en">geky</a></td>
|
||||
<td class="show-for-medium-up"><img src='squelettes/img/pays/fr.svg?1594376628' width='16' height='16' /></td>
|
||||
<td class="show-for-medium-up"><img src='squelettes/img/rang/legend.svg?1641984641' alt='legend' width='16' height='16' title='legend' /></td>
|
||||
<td><a href="geky-892829?inc=score&lang=en" title="892829">24830</a></td>
|
||||
</tr>
|
||||
<tr class="row_even">
|
||||
<td class="gras"># 9</td>
|
||||
<td><img class="vmiddle logo_auteur logo_6forum" width="25" height="19" src="IMG/logo/auton317636.gif?1663314648" alt="nikost" /></td>
|
||||
<td><a class=" txt_6forum" title="profil of nikost" href="/nikost?lang=en">nikost</a></td>
|
||||
<td class="show-for-medium-up"><img src='squelettes/img/pays/fr.svg?1594376628' width='16' height='16' /></td>
|
||||
<td class="show-for-medium-up"><img src='squelettes/img/rang/elite.svg?1640075769' alt='elite' width='16' height='16' title='elite' /></td>
|
||||
<td><a href="nikost?inc=score&lang=en" title="317636">24510</a></td>
|
||||
</tr>
|
||||
<tr class="row_odd">
|
||||
<td class="gras"># 10</td>
|
||||
<td><img class="vmiddle logo_auteur logo_1comite" width="25" height="16" src="IMG/logo/auton24861.jpg?1464908918" alt="k4ndar3c" /></td>
|
||||
<td><a class=" txt_1comite" title="profil of k4ndar3c" href="/k4ndar3c?lang=en">k4ndar3c</a></td>
|
||||
<td class="show-for-medium-up"><img src='squelettes/img/pays/fr.svg?1594376628' width='16' height='16' /></td>
|
||||
<td class="show-for-medium-up"><img src='squelettes/img/rang/elite.svg?1640075769' alt='elite' width='16' height='16' title='elite' /></td>
|
||||
<td><a href="k4ndar3c?inc=score&lang=en" title="24861">23775</a></td>
|
||||
</tr>
|
||||
<tr class="row_even">
|
||||
<td class="gras"># 11</td>
|
||||
<td><img class="vmiddle logo_auteur logo_1comite" width="20" height="25" src="IMG/logo/auton526362.jpg?1714421693" alt="M58" /></td>
|
||||
<td><a class=" txt_1comite" title="profil of M58" href="/M58_?lang=en">M58</a></td>
|
||||
<td class="show-for-medium-up"><img src='squelettes/img/pays/fr.svg?1594376628' width='16' height='16' /></td>
|
||||
<td class="show-for-medium-up"><img src='squelettes/img/rang/elite.svg?1640075769' alt='elite' width='16' height='16' title='elite' /></td>
|
||||
<td><a href="M58_?inc=score&lang=en" title="526362">23690</a></td>
|
||||
</tr>
|
||||
<tr class="row_odd">
|
||||
<td class="gras"># 12</td>
|
||||
<td><img class="vmiddle logo_auteur logo_1comite" width="25" height="25" src="IMG/logo/auton698033.png?1752680949" alt="NearXa" /></td>
|
||||
<td><a class=" txt_1comite" title="profil of NearXa" href="/NearXa-1337?lang=en">NearXa</a></td>
|
||||
<td class="show-for-medium-up"><img src='squelettes/img/pays/fr.svg?1594376628' width='16' height='16' /></td>
|
||||
<td class="show-for-medium-up"><img src='squelettes/img/rang/elite.svg?1640075769' alt='elite' width='16' height='16' title='elite' /></td>
|
||||
<td><a href="NearXa-1337?inc=score&lang=en" title="698033">23330</a></td>
|
||||
</tr>
|
||||
<tr class="row_even">
|
||||
<td class="gras"># 13</td>
|
||||
<td><img class="vmiddle logo_auteur logo_6forum" width="23" height="25" src="IMG/logo/auton27430.png?1440105023" alt="franb" /></td>
|
||||
<td><a class=" txt_6forum" title="profil of franb" href="/franb?lang=en">franb</a></td>
|
||||
<td class="show-for-medium-up"><img src='squelettes/img/pays/fr.svg?1594376628' width='16' height='16' /></td>
|
||||
<td class="show-for-medium-up"><img src='squelettes/img/rang/elite.svg?1640075769' alt='elite' width='16' height='16' title='elite' /></td>
|
||||
<td><a href="franb?inc=score&lang=en" title="27430">23175</a></td>
|
||||
</tr>
|
||||
<tr class="row_odd">
|
||||
<td class="gras"># 14</td>
|
||||
<td><img class="vmiddle logo_auteur logo_6forum" width="22" height="25" src="IMG/logo/auton79281.jpg?1712996043" alt="Jrmbt" /></td>
|
||||
<td><a class=" txt_6forum" title="profil of Jrmbt" href="/Jrmbt?lang=en">Jrmbt</a></td>
|
||||
<td class="show-for-medium-up"><img src='squelettes/img/pays/fr.svg?1594376628' width='16' height='16' /></td>
|
||||
<td class="show-for-medium-up"><img src='squelettes/img/rang/elite.svg?1640075769' alt='elite' width='16' height='16' title='elite' /></td>
|
||||
<td><a href="Jrmbt?inc=score&lang=en" title="79281">22950</a></td>
|
||||
</tr>
|
||||
<tr class="row_even">
|
||||
<td class="gras"># 15</td>
|
||||
<td><img class="vmiddle logo_auteur logo_6forum" width="25" height="25" src="IMG/logo/auton218356.png?1596202289" alt="voydstack" /></td>
|
||||
<td><a class=" txt_6forum" title="profil of voydstack" href="/voydstack?lang=en">voydstack</a></td>
|
||||
<td class="show-for-medium-up"><img src='squelettes/img/pays/fr.svg?1594376628' width='16' height='16' /></td>
|
||||
<td class="show-for-medium-up"><img src='squelettes/img/rang/elite.svg?1640075769' alt='elite' width='16' height='16' title='elite' /></td>
|
||||
<td><a href="voydstack?inc=score&lang=en" title="218356">22235</a></td>
|
||||
</tr>
|
||||
<tr class="row_odd">
|
||||
<td class="gras"># 16</td>
|
||||
<td><img class="vmiddle logo_auteur logo_6forum" width="18" height="25" src="IMG/logo/auton848814.jpg?1734033298" alt="Adem0" /></td>
|
||||
<td><a class=" txt_6forum" title="profil of Adem0" href="/Adem0?lang=en">Adem0</a></td>
|
||||
<td class="show-for-medium-up"><img src='squelettes/img/pays/fr.svg?1594376628' width='16' height='16' /></td>
|
||||
<td class="show-for-medium-up"><img src='squelettes/img/rang/elite.svg?1640075769' alt='elite' width='16' height='16' title='elite' /></td>
|
||||
<td><a href="Adem0?inc=score&lang=en" title="848814">22090</a></td>
|
||||
</tr>
|
||||
<tr class="row_even">
|
||||
<td class="gras"># 17</td>
|
||||
<td><img class="vmiddle logo_auteur logo_1comite" width="25" height="17" src="IMG/logo/auton317376.jpg?1619479668" alt="nobodyisnobody" /></td>
|
||||
<td><a class=" txt_1comite" title="profil of nobodyisnobody" href="/nobodyisnobody?lang=en">nobodyisnobody</a></td>
|
||||
<td class="show-for-medium-up"><img src='squelettes/img/pays/fr.svg?1594376628' width='16' height='16' /></td>
|
||||
<td class="show-for-medium-up"><img src='squelettes/img/rang/elite.svg?1640075769' alt='elite' width='16' height='16' title='elite' /></td>
|
||||
<td><a href="nobodyisnobody?inc=score&lang=en" title="317376">21865</a></td>
|
||||
</tr>
|
||||
<tr class="row_odd">
|
||||
<td class="gras"># 18</td>
|
||||
<td><img class="vmiddle logo_auteur logo_6forum" width="25" height="25" src="IMG/logo/auton0.png?1637503221" alt="csg" /></td>
|
||||
<td><a class=" txt_6forum" title="profil of csg" href="/csg?lang=en">csg</a></td>
|
||||
<td class="show-for-medium-up"><img src='squelettes/img/pays/fr.svg?1594376628' width='16' height='16' /></td>
|
||||
<td class="show-for-medium-up"><img src='squelettes/img/rang/elite.svg?1640075769' alt='elite' width='16' height='16' title='elite' /></td>
|
||||
<td><a href="csg?inc=score&lang=en" title="703517">21500</a></td>
|
||||
</tr>
|
||||
<tr class="row_even">
|
||||
<td class="gras"># 19</td>
|
||||
<td><img class="vmiddle logo_auteur logo_1comite" width="25" height="25" src="IMG/logo/auton385190.jpg?1666646118" alt="_cthulu" /></td>
|
||||
<td><a class=" txt_1comite" title="profil of _cthulu" href="/_cthulu?lang=en">_cthulu</a></td>
|
||||
<td class="show-for-medium-up"><img src='squelettes/img/pays/fr.svg?1594376628' width='16' height='16' /></td>
|
||||
<td class="show-for-medium-up"><img src='squelettes/img/rang/elite.svg?1640075769' alt='elite' width='16' height='16' title='elite' /></td>
|
||||
<td><a href="_cthulu?inc=score&lang=en" title="385190">21425</a></td>
|
||||
</tr>
|
||||
<tr class="row_odd">
|
||||
<td class="gras"># 20</td>
|
||||
<td><img class="vmiddle logo_auteur logo_6forum" width="25" height="25" src="IMG/logo/auton27775.png?1438473128" alt="laxa" /></td>
|
||||
<td><a class=" txt_6forum" title="profil of laxa" href="/laxa?lang=en">laxa</a></td>
|
||||
<td class="show-for-medium-up"><img src='squelettes/img/pays/fr.svg?1594376628' width='16' height='16' /></td>
|
||||
<td class="show-for-medium-up"><img src='squelettes/img/rang/elite.svg?1640075769' alt='elite' width='16' height='16' title='elite' /></td>
|
||||
<td><a href="laxa?inc=score&lang=en" title="27775">21115</a></td>
|
||||
</tr>
|
||||
<tr class="row_even">
|
||||
<td class="gras"># 21</td>
|
||||
<td><img class="vmiddle logo_auteur logo_1comite" width="25" height="25" src="IMG/logo/auton475204.jpg?1768506059" alt="Ap4sh" /></td>
|
||||
<td><a class=" txt_1comite" title="profil of Ap4sh" href="/Ap4sh?lang=en">Ap4sh</a></td>
|
||||
<td class="show-for-medium-up"><img src='squelettes/img/pays/fr.svg?1594376628' width='16' height='16' /></td>
|
||||
<td class="show-for-medium-up"><img src='squelettes/img/rang/elite.svg?1640075769' alt='elite' width='16' height='16' title='elite' /></td>
|
||||
<td><a href="Ap4sh?inc=score&lang=en" title="475204">21015</a></td>
|
||||
</tr>
|
||||
<tr class="row_odd">
|
||||
<td class="gras"># 22</td>
|
||||
<td><img class="vmiddle logo_auteur logo_1comite" width="25" height="24" src="IMG/logo/auton442429.png?1657666447" alt="Vozec" /></td>
|
||||
<td><a class=" txt_1comite" title="profil of Vozec" href="/Vozec?lang=en">Vozec</a></td>
|
||||
<td class="show-for-medium-up"><img src='squelettes/img/pays/fr.svg?1594376628' width='16' height='16' /></td>
|
||||
<td class="show-for-medium-up"><img src='squelettes/img/rang/elite.svg?1640075769' alt='elite' width='16' height='16' title='elite' /></td>
|
||||
<td><a href="Vozec?inc=score&lang=en" title="442429">20905</a></td>
|
||||
</tr>
|
||||
<tr class="row_even">
|
||||
<td class="gras"># 23</td>
|
||||
<td><img class="vmiddle logo_auteur logo_6forum" width="25" height="25" src="IMG/logo/auton158722.jpg?1639654441" alt="gwel" /></td>
|
||||
<td><a class=" txt_6forum" title="profil of gwel" href="/gwel?lang=en">gwel</a></td>
|
||||
<td class="show-for-medium-up"><img src='squelettes/img/pays/fr.svg?1594376628' width='16' height='16' /></td>
|
||||
<td class="show-for-medium-up"><img src='squelettes/img/rang/elite.svg?1640075769' alt='elite' width='16' height='16' title='elite' /></td>
|
||||
<td><a href="gwel?inc=score&lang=en" title="158722">20700</a></td>
|
||||
</tr>
|
||||
<tr class="row_odd">
|
||||
<td class="gras"># 24</td>
|
||||
<td><img class="vmiddle logo_auteur logo_6forum" width="25" height="25" src="IMG/logo/auton236284.jpg?1589212400" alt="Bdenneu" /></td>
|
||||
<td><a class=" txt_6forum" title="profil of Bdenneu" href="/Bdenneu?lang=en">Bdenneu</a></td>
|
||||
<td class="show-for-medium-up"><img src='squelettes/img/pays/fr.svg?1594376628' width='16' height='16' /></td>
|
||||
<td class="show-for-medium-up"><img src='squelettes/img/rang/elite.svg?1640075769' alt='elite' width='16' height='16' title='elite' /></td>
|
||||
<td><a href="Bdenneu?inc=score&lang=en" title="236284">20645</a></td>
|
||||
</tr>
|
||||
<tr class="row_even">
|
||||
<td class="gras"># 25</td>
|
||||
<td><img class="vmiddle logo_auteur logo_6forum" width="14" height="25" src="IMG/logo/auton539628.jpg?1769780598" alt="D0pp3lgang3r" /></td>
|
||||
<td><a class=" txt_6forum" title="profil of D0pp3lgang3r" href="/D0pp3lgang3r?lang=en">D0pp3lgang3r</a></td>
|
||||
<td class="show-for-medium-up"><img src='squelettes/img/pays/fr.svg?1594376628' width='16' height='16' /></td>
|
||||
<td class="show-for-medium-up"><img src='squelettes/img/rang/elite.svg?1640075769' alt='elite' width='16' height='16' title='elite' /></td>
|
||||
<td><a href="D0pp3lgang3r?inc=score&lang=en" title="539628">20010</a></td>
|
||||
</tr>
|
||||
<tr class="row_odd">
|
||||
<td class="gras"># 25</td>
|
||||
<td><img class="vmiddle logo_auteur logo_6forum" width="25" height="23" src="IMG/logo/auton813990.jpg?1752183601" alt="AlexsB" /></td>
|
||||
<td><a class=" txt_6forum" title="profil of AlexsB" href="/AlexsB?lang=en">AlexsB</a></td>
|
||||
<td class="show-for-medium-up"><img src='squelettes/img/pays/fr.svg?1594376628' width='16' height='16' /></td>
|
||||
<td class="show-for-medium-up"><img src='squelettes/img/rang/elite.svg?1640075769' alt='elite' width='16' height='16' title='elite' /></td>
|
||||
<td><a href="AlexsB?inc=score&lang=en" title="813990">19985</a></td>
|
||||
</tr>
|
||||
<tr class="row_even">
|
||||
<td class="gras"># 26</td>
|
||||
<td><img class="vmiddle logo_auteur logo_6forum" width="25" height="25" src="IMG/logo/auton106074.png?1773046289" alt="Mr7F" /></td>
|
||||
<td><a class=" txt_6forum" title="profil of Mr7F" href="/Mr7F?lang=en">Mr7F</a></td>
|
||||
<td class="show-for-medium-up"><img src='squelettes/img/pays/en.svg?1637569714' width='16' height='16' /></td>
|
||||
<td class="show-for-medium-up"><img src='squelettes/img/rang/elite.svg?1640075769' alt='elite' width='16' height='16' title='elite' /></td>
|
||||
<td><a href="Mr7F?inc=score&lang=en" title="106074">19880</a></td>
|
||||
</tr>
|
||||
<tr class="row_odd">
|
||||
<td class="gras"># 26</td>
|
||||
<td><img class="vmiddle logo_auteur logo_6forum" width="21" height="25" src="IMG/logo/auton93289.png?1602765200" alt="mjuuum" /></td>
|
||||
<td><a class=" txt_6forum" title="profil of mjuuum" href="/mjuuum?lang=en">mjuuum</a></td>
|
||||
<td class="show-for-medium-up"><img src='squelettes/img/pays/en.svg?1637569714' width='16' height='16' /></td>
|
||||
<td class="show-for-medium-up"><img src='squelettes/img/rang/elite.svg?1640075769' alt='elite' width='16' height='16' title='elite' /></td>
|
||||
<td><a href="mjuuum?inc=score&lang=en" title="93289">19875</a></td>
|
||||
</tr>
|
||||
<tr class="row_even">
|
||||
<td class="gras"># 27</td>
|
||||
<td><img class="vmiddle logo_auteur logo_1comite" width="25" height="25" src="IMG/logo/auton18343.png?1538216614" alt="Laluka" /></td>
|
||||
<td><a class=" txt_1comite" title="profil of Laluka" href="/Laluka?lang=en">Laluka</a></td>
|
||||
<td class="show-for-medium-up"><img src='squelettes/img/pays/fr.svg?1594376628' width='16' height='16' /></td>
|
||||
<td class="show-for-medium-up"><img src='squelettes/img/rang/elite.svg?1640075769' alt='elite' width='16' height='16' title='elite' /></td>
|
||||
<td><a href="Laluka?inc=score&lang=en" title="18343">19765</a></td>
|
||||
</tr>
|
||||
<tr class="row_odd">
|
||||
<td class="gras"># 30</td>
|
||||
<td><img class="vmiddle logo_auteur logo_6forum" width="20" height="25" src="IMG/logo/auton613494.jpg?1635643200" alt="Oblivios" /></td>
|
||||
<td><a class=" txt_5pre" title="profil of Oblivios" href="/Oblivios?lang=en">Oblivios</a></td>
|
||||
<td class="show-for-medium-up"><img src='squelettes/img/pays/fr.svg?1594376628' width='16' height='16' /></td>
|
||||
<td class="show-for-medium-up"><img src='squelettes/img/rang/elite.svg?1640075769' alt='elite' width='16' height='16' title='elite' /></td>
|
||||
<td><a href="Oblivios?inc=score&lang=en" title="613494">19615</a></td>
|
||||
</tr>
|
||||
<tr class="row_even">
|
||||
<td class="gras"># 28</td>
|
||||
<td><img class="vmiddle logo_auteur logo_6forum" width="25" height="25" src="IMG/logo/auton0.png?1637503221" alt="kikko" /></td>
|
||||
<td><a class=" txt_6forum" title="profil of kikko" href="/kikko?lang=en">kikko</a></td>
|
||||
<td class="show-for-medium-up"><img src='squelettes/img/pays/fr.svg?1594376628' width='16' height='16' /></td>
|
||||
<td class="show-for-medium-up"><img src='squelettes/img/rang/elite.svg?1640075769' alt='elite' width='16' height='16' title='elite' /></td>
|
||||
<td><a href="kikko?inc=score&lang=en" title="36671">19570</a></td>
|
||||
</tr>
|
||||
<tr class="row_odd">
|
||||
<td class="gras"># 29</td>
|
||||
<td><img class="vmiddle logo_auteur logo_5pre" width="25" height="25" src="IMG/logo/auton139707.png?1698081501" alt="Podalirius" /></td>
|
||||
<td><a class=" txt_5pre" title="profil of Podalirius" href="/Podalirius?lang=en">Podalirius</a></td>
|
||||
<td class="show-for-medium-up"><img src='squelettes/img/pays/fr.svg?1594376628' width='16' height='16' /></td>
|
||||
<td class="show-for-medium-up"><img src='squelettes/img/rang/elite.svg?1640075769' alt='elite' width='16' height='16' title='elite' /></td>
|
||||
<td><a href="Podalirius?inc=score&lang=en" title="139707">19565</a></td>
|
||||
</tr>
|
||||
<tr class="row_even">
|
||||
<td class="gras"># 33</td>
|
||||
<td><img class="vmiddle logo_auteur logo_6forum" width="25" height="25" src="IMG/logo/auton49089.png?1716449817" alt="NilDead" /></td>
|
||||
<td><a class=" txt_6forum" title="profil of NilDead" href="/NilDead?lang=en">NilDead</a></td>
|
||||
<td class="show-for-medium-up"><img src='squelettes/img/pays/fr.svg?1594376628' width='16' height='16' /></td>
|
||||
<td class="show-for-medium-up"><img src='squelettes/img/rang/elite.svg?1640075769' alt='elite' width='16' height='16' title='elite' /></td>
|
||||
<td><a href="NilDead?inc=score&lang=en" title="49089">19290</a></td>
|
||||
</tr>
|
||||
<tr class="row_odd">
|
||||
<td class="gras"># 34</td>
|
||||
<td><img class="vmiddle logo_auteur logo_6forum" width="25" height="25" src="IMG/logo/auton147650.png?1775677914" alt="arnaud-sh" /></td>
|
||||
<td><a class=" txt_6forum" title="profil of LightDiscord" href="/LightDiscord?lang=en">LightDiscord</a></td>
|
||||
<td class="show-for-medium-up"><img src='squelettes/img/pays/fr.svg?1594376628' width='16' height='16' /></td>
|
||||
<td class="show-for-medium-up"><img src='squelettes/img/rang/elite.svg?1640075769' alt='elite' width='16' height='16' title='elite' /></td>
|
||||
<td><a href="arnaud-sh?inc=score&lang=en" title="147650">19250</a></td>
|
||||
</tr>
|
||||
<tr class="row_even">
|
||||
<td class="gras"># 35</td>
|
||||
<td><img class="vmiddle logo_auteur logo_1comite" width="25" height="24" src="IMG/logo/auton141378.jpg?1779570957" alt="Nyu" /></td>
|
||||
<td><a class=" txt_1comite" title="profil of Nyu" href="/Nyu?lang=en">Nyu</a></td>
|
||||
<td class="show-for-medium-up"><img src='squelettes/img/pays/fr.svg?1594376628' width='16' height='16' /></td>
|
||||
<td class="show-for-medium-up"><img src='squelettes/img/rang/elite.svg?1640075769' alt='elite' width='16' height='16' title='elite' /></td>
|
||||
<td><a href="Nyu?inc=score&lang=en" title="141378">18985</a></td>
|
||||
</tr>
|
||||
<tr class="row_odd">
|
||||
<td class="gras"># 36</td>
|
||||
<td><img class="vmiddle logo_auteur logo_6forum" width="21" height="25" src="IMG/logo/auton23519.png?1454951124" alt="Alkanor" /></td>
|
||||
<td><a class=" txt_6forum" title="profil of Alkanor" href="/Alkanor?lang=en">Alkanor</a></td>
|
||||
<td class="show-for-medium-up"><img src='squelettes/img/pays/fr.svg?1594376628' width='16' height='16' /></td>
|
||||
<td class="show-for-medium-up"><img src='squelettes/img/rang/elite.svg?1640075769' alt='elite' width='16' height='16' title='elite' /></td>
|
||||
<td><a href="Alkanor?inc=score&lang=en" title="23519">18760</a></td>
|
||||
</tr>
|
||||
<tr class="row_even">
|
||||
<td class="gras"># 37</td>
|
||||
<td><img class="vmiddle logo_auteur logo_6forum" width="25" height="25" src="IMG/logo/auton395842.jpg?1620507281" alt="Express" /></td>
|
||||
<td><a class=" txt_5pre" title="profil of Express" href="/Express?lang=en">Express</a></td>
|
||||
<td class="show-for-medium-up"><img src='squelettes/img/pays/fr.svg?1594376628' width='16' height='16' /></td>
|
||||
<td class="show-for-medium-up"><img src='squelettes/img/rang/elite.svg?1640075769' alt='elite' width='16' height='16' title='elite' /></td>
|
||||
<td><a href="Express?inc=score&lang=en" title="395842">18740</a></td>
|
||||
</tr>
|
||||
<tr class="row_odd">
|
||||
<td class="gras"># 38</td>
|
||||
<td><img class="vmiddle logo_auteur logo_6forum" width="25" height="18" src="IMG/logo/auton365797.jpg?1632567624" alt="Dvorhack" /></td>
|
||||
<td><a class=" txt_6forum" title="profil of Dvorhack" href="/Viel?lang=en">Dvorhack</a></td>
|
||||
<td class="show-for-medium-up"><img src='squelettes/img/pays/fr.svg?1594376628' width='16' height='16' /></td>
|
||||
<td class="show-for-medium-up"><img src='squelettes/img/rang/elite.svg?1640075769' alt='elite' width='16' height='16' title='elite' /></td>
|
||||
<td><a href="Viel?inc=score&lang=en" title="365797">18725</a></td>
|
||||
</tr>
|
||||
<tr class="row_even">
|
||||
<td class="gras"># 39</td>
|
||||
<td><img class="vmiddle logo_auteur logo_6forum" width="23" height="25" src="IMG/logo/auton245879.jpg?1663956446" alt="Iroh" /></td>
|
||||
<td><a class=" txt_6forum" title="profil of Iroh" href="/Iroh?lang=en">Iroh</a></td>
|
||||
<td class="show-for-medium-up"><img src='squelettes/img/pays/fr.svg?1594376628' width='16' height='16' /></td>
|
||||
<td class="show-for-medium-up"><img src='squelettes/img/rang/elite.svg?1640075769' alt='elite' width='16' height='16' title='elite' /></td>
|
||||
<td><a href="Iroh?inc=score&lang=en" title="245879">18470</a></td>
|
||||
</tr>
|
||||
<tr class="row_odd">
|
||||
<td class="gras"># 40</td>
|
||||
<td><img class="vmiddle logo_auteur logo_6forum" width="25" height="25" src="IMG/logo/auton0.png?1637503221" alt="Rishkov" /></td>
|
||||
<td><a class=" txt_6forum" title="profil of Rishkov" href="/Rishkov?lang=en">Rishkov</a></td>
|
||||
<td class="show-for-medium-up"><img src='squelettes/img/pays/fr.svg?1594376628' width='16' height='16' /></td>
|
||||
<td class="show-for-medium-up"><img src='squelettes/img/rang/elite.svg?1640075769' alt='elite' width='16' height='16' title='elite' /></td>
|
||||
<td><a href="Rishkov?inc=score&lang=en" title="660750">18330</a></td>
|
||||
</tr>
|
||||
<tr class="row_even">
|
||||
<td class="gras"># 41</td>
|
||||
<td><img class="vmiddle logo_auteur logo_6forum" width="25" height="25" src="IMG/logo/auton729201.png?1752959393" alt="Retro16" /></td>
|
||||
<td><a class=" txt_6forum" title="profil of Retro16" href="/Retro16?lang=en">Retro16</a></td>
|
||||
<td class="show-for-medium-up"><img src='squelettes/img/pays/fr.svg?1594376628' width='16' height='16' /></td>
|
||||
<td class="show-for-medium-up"><img src='squelettes/img/rang/elite.svg?1640075769' alt='elite' width='16' height='16' title='elite' /></td>
|
||||
<td><a href="Retro16?inc=score&lang=en" title="729201">18080</a></td>
|
||||
</tr>
|
||||
<tr class="row_odd">
|
||||
<td class="gras"># 42</td>
|
||||
<td><img class="vmiddle logo_auteur logo_1comite" width="25" height="25" src="IMG/logo/auton312118.png?1615662453" alt="Pyfu" /></td>
|
||||
<td><a class=" txt_1comite" title="profil of Pyfu" href="/Pyfu?lang=en">Pyfu</a></td>
|
||||
<td class="show-for-medium-up"><img src='squelettes/img/pays/fr.svg?1594376628' width='16' height='16' /></td>
|
||||
<td class="show-for-medium-up"><img src='squelettes/img/rang/elite.svg?1640075769' alt='elite' width='16' height='16' title='elite' /></td>
|
||||
<td><a href="Pyfu?inc=score&lang=en" title="312118">18070</a></td>
|
||||
</tr>
|
||||
<tr class="row_even">
|
||||
<td class="gras"># 43</td>
|
||||
<td><img class="vmiddle logo_auteur logo_6forum" width="21" height="25" src="IMG/logo/auton152303.png?1580758659" alt="Slowerzs" /></td>
|
||||
<td><a class=" txt_6forum" title="profil of Slowerzs" href="/Slowerzs?lang=en">Slowerzs</a></td>
|
||||
<td class="show-for-medium-up"><img src='squelettes/img/pays/fr.svg?1594376628' width='16' height='16' /></td>
|
||||
<td class="show-for-medium-up"><img src='squelettes/img/rang/elite.svg?1640075769' alt='elite' width='16' height='16' title='elite' /></td>
|
||||
<td><a href="Slowerzs?inc=score&lang=en" title="152303">17980</a></td>
|
||||
</tr>
|
||||
<tr class="row_odd">
|
||||
<td class="gras"># 44</td>
|
||||
<td><img class="vmiddle logo_auteur logo_6forum" width="25" height="25" src="IMG/logo/auton962121.png?1776115782" alt="AESpider" /></td>
|
||||
<td><a class=" txt_6forum" title="profil of AESpider" href="/AESpider?lang=en">AESpider</a></td>
|
||||
<td class="show-for-medium-up"><img src='squelettes/img/pays/fr.svg?1594376628' width='16' height='16' /></td>
|
||||
<td class="show-for-medium-up"><img src='squelettes/img/rang/elite.svg?1640075769' alt='elite' width='16' height='16' title='elite' /></td>
|
||||
<td><a href="AESpider?inc=score&lang=en" title="962121">17955</a></td>
|
||||
</tr>
|
||||
<tr class="row_even">
|
||||
<td class="gras"># 44</td>
|
||||
<td><img class="vmiddle logo_auteur logo_6forum" width="25" height="25" src="IMG/logo/auton533125.jpg?1738508401" alt="235711131723" /></td>
|
||||
<td><a class=" txt_6forum" title="profil of 235711131723" href="/235711131723?lang=en">235711131723</a></td>
|
||||
<td class="show-for-medium-up"><img src='squelettes/img/pays/fr.svg?1594376628' width='16' height='16' /></td>
|
||||
<td class="show-for-medium-up"><img src='squelettes/img/rang/elite.svg?1640075769' alt='elite' width='16' height='16' title='elite' /></td>
|
||||
<td><a href="235711131723?inc=score&lang=en" title="533125">17945</a></td>
|
||||
</tr>
|
||||
<tr class="row_odd">
|
||||
<td class="gras"># 45</td>
|
||||
<td><img class="vmiddle logo_auteur logo_6forum" width="22" height="25" src="IMG/logo/auton47809.gif?1662053394" alt="ohohoh" /></td>
|
||||
<td><a class=" txt_6forum" title="profil of ohohoh" href="/ohohoh-47809?lang=en">ohohoh</a></td>
|
||||
<td class="show-for-medium-up"><img src='squelettes/img/pays/fr.svg?1594376628' width='16' height='16' /></td>
|
||||
<td class="show-for-medium-up"><img src='squelettes/img/rang/elite.svg?1640075769' alt='elite' width='16' height='16' title='elite' /></td>
|
||||
<td><a href="ohohoh-47809?inc=score&lang=en" title="47809">17740</a></td>
|
||||
</tr>
|
||||
<tr class="row_even">
|
||||
<td class="gras"># 46</td>
|
||||
<td><img class="vmiddle logo_auteur logo_6forum" width="25" height="25" src="IMG/logo/auton284606.jpg?1599208174" alt="face0xff" /></td>
|
||||
<td><a class=" txt_1comite" title="profil of face0xff" href="/face0xff?lang=en">face0xff</a></td>
|
||||
<td class="show-for-medium-up"><img src='squelettes/img/pays/fr.svg?1594376628' width='16' height='16' /></td>
|
||||
<td class="show-for-medium-up"><img src='squelettes/img/rang/elite.svg?1640075769' alt='elite' width='16' height='16' title='elite' /></td>
|
||||
<td><a href="face0xff?inc=score&lang=en" title="284606">17660</a></td>
|
||||
</tr>
|
||||
<tr class="row_odd">
|
||||
<td class="gras"># 46</td>
|
||||
<td><img class="vmiddle logo_auteur logo_6forum" width="25" height="25" src="IMG/logo/auton8738.jpg?1692005697" alt="Tomtombinary" /></td>
|
||||
<td><a class=" txt_6forum" title="profil of Tomtombinary" href="/Tomtombinary?lang=en">Tomtombinary</a></td>
|
||||
<td class="show-for-medium-up"><img src='squelettes/img/pays/fr.svg?1594376628' width='16' height='16' /></td>
|
||||
<td class="show-for-medium-up"><img src='squelettes/img/rang/elite.svg?1640075769' alt='elite' width='16' height='16' title='elite' /></td>
|
||||
<td><a href="Tomtombinary?inc=score&lang=en" title="8738">17635</a></td>
|
||||
</tr>
|
||||
<tr class="row_even">
|
||||
<td class="gras"># 47</td>
|
||||
<td><img class="vmiddle logo_auteur logo_1comite" width="25" height="25" src="IMG/logo/auton259266.jpg?1748876096" alt="Globules" /></td>
|
||||
<td><a class=" txt_1comite" title="profil of Globules" href="/Globules?lang=en">Globules</a></td>
|
||||
<td class="show-for-medium-up"><img src='squelettes/img/pays/fr.svg?1594376628' width='16' height='16' /></td>
|
||||
<td class="show-for-medium-up"><img src='squelettes/img/rang/elite.svg?1640075769' alt='elite' width='16' height='16' title='elite' /></td>
|
||||
<td><a href="Globules?inc=score&lang=en" title="259266">17610</a></td>
|
||||
</tr>
|
||||
<tr class="row_odd">
|
||||
<td class="gras"># 49</td>
|
||||
<td><img class="vmiddle logo_auteur logo_6forum" width="25" height="25" src="IMG/logo/auton125681.gif?1642179520" alt="Re:Z3R0" /></td>
|
||||
<td><a class=" txt_6forum" title="profil of Re:Z3R0" href="/Re-Z3R0?lang=en">Re:Z3R0</a></td>
|
||||
<td class="show-for-medium-up"><img src='squelettes/img/pays/fr.svg?1594376628' width='16' height='16' /></td>
|
||||
<td class="show-for-medium-up"><img src='squelettes/img/rang/elite.svg?1640075769' alt='elite' width='16' height='16' title='elite' /></td>
|
||||
<td><a href="Re-Z3R0?inc=score&lang=en" title="125681">17550</a></td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
<p class="pagination">
|
||||
<div class="pagination-centered">
|
||||
<ul class="pagination">
|
||||
<li class="current"><a href='./?page=structure&inc=modeles%2Fclassement&lang=en&ajah=1&debut_classement=0#pagination_debut_classement' class='lien_pagination gris' rel='nofollow'>1</a></li>
|
||||
<li><a href='./?page=structure&inc=modeles%2Fclassement&lang=en&ajah=1&debut_classement=50#pagination_debut_classement' class='lien_pagination gris' rel='nofollow'>2</a></li>
|
||||
<li><a href='./?page=structure&inc=modeles%2Fclassement&lang=en&ajah=1&debut_classement=100#pagination_debut_classement' class='lien_pagination gris' rel='nofollow'>3</a></li>
|
||||
<li><a href='./?page=structure&inc=modeles%2Fclassement&lang=en&ajah=1&debut_classement=150#pagination_debut_classement' class='lien_pagination gris' rel='nofollow'>4</a></li>
|
||||
<li><a href='./?page=structure&inc=modeles%2Fclassement&lang=en&ajah=1&debut_classement=200#pagination_debut_classement' class='lien_pagination gris' rel='nofollow'>5</a></li>
|
||||
<li><a href='./?page=structure&inc=modeles%2Fclassement&lang=en&ajah=1&debut_classement=250#pagination_debut_classement' class='lien_pagination gris' rel='nofollow'>6</a></li>
|
||||
<li><a href='./?page=structure&inc=modeles%2Fclassement&lang=en&ajah=1&debut_classement=300#pagination_debut_classement' class='lien_pagination gris' rel='nofollow'>7</a></li>
|
||||
<li><a href='./?page=structure&inc=modeles%2Fclassement&lang=en&ajah=1&debut_classement=350#pagination_debut_classement' class='lien_pagination gris' rel='nofollow'>8</a></li>
|
||||
<li><a href='./?page=structure&inc=modeles%2Fclassement&lang=en&ajah=1&debut_classement=400#pagination_debut_classement' class='lien_pagination gris' rel='nofollow'>9</a></li>
|
||||
<li><a href='./?page=structure&inc=modeles%2Fclassement&lang=en&ajah=1&debut_classement=50#pagination_debut_classement' class='lien_pagination gris' rel='nofollow'>></a></li>
|
||||
<li><a href='./?page=structure&inc=modeles%2Fclassement&lang=en&ajah=1&debut_classement=372900#pagination_debut_classement' class='lien_pagination gris' rel='nofollow'>...</a></li>
|
||||
</ul>
|
||||
</div>
|
||||
</p>
|
||||
</div><!--ajaxbloc-->
|
||||
@@ -0,0 +1,73 @@
|
||||
// Package server exposes the scoreboard Store over gRPC and HTTP/JSON.
|
||||
package server
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"google.golang.org/grpc/codes"
|
||||
"google.golang.org/grpc/status"
|
||||
|
||||
pb "git.mkz.me/mycroft/root-me-api/gen/scoreboard/v1"
|
||||
"git.mkz.me/mycroft/root-me-api/internal/scoreboard"
|
||||
)
|
||||
|
||||
// GRPCServer implements pb.ScoreboardServiceServer over a Store.
|
||||
type GRPCServer struct {
|
||||
pb.UnimplementedScoreboardServiceServer
|
||||
Store *scoreboard.Store
|
||||
}
|
||||
|
||||
// NewGRPC returns a gRPC server backed by the given store.
|
||||
func NewGRPC(store *scoreboard.Store) *GRPCServer { return &GRPCServer{Store: store} }
|
||||
|
||||
func (s *GRPCServer) ListScoreboard(_ context.Context, req *pb.ListScoreboardRequest) (*pb.ListScoreboardResponse, error) {
|
||||
if !s.Store.Ready() {
|
||||
return nil, status.Error(codes.Unavailable, "scoreboard not loaded yet")
|
||||
}
|
||||
entries, fetchedAt := s.Store.List(int(req.GetOffset()), int(req.GetLimit()))
|
||||
resp := &pb.ListScoreboardResponse{
|
||||
Total: int32(s.Store.Len()),
|
||||
FetchedAt: fetchedAt.Unix(),
|
||||
}
|
||||
resp.Entries = make([]*pb.Entry, len(entries))
|
||||
for i, e := range entries {
|
||||
resp.Entries[i] = toProto(e)
|
||||
}
|
||||
return resp, nil
|
||||
}
|
||||
|
||||
func (s *GRPCServer) GetByRank(_ context.Context, req *pb.GetByRankRequest) (*pb.Entry, error) {
|
||||
if !s.Store.Ready() {
|
||||
return nil, status.Error(codes.Unavailable, "scoreboard not loaded yet")
|
||||
}
|
||||
e, ok := s.Store.ByRank(int(req.GetRank()))
|
||||
if !ok {
|
||||
return nil, status.Errorf(codes.NotFound, "no entry at rank %d", req.GetRank())
|
||||
}
|
||||
return toProto(e), nil
|
||||
}
|
||||
|
||||
func (s *GRPCServer) GetByUsername(_ context.Context, req *pb.GetByUsernameRequest) (*pb.Entry, error) {
|
||||
if !s.Store.Ready() {
|
||||
return nil, status.Error(codes.Unavailable, "scoreboard not loaded yet")
|
||||
}
|
||||
if req.GetUsername() == "" {
|
||||
return nil, status.Error(codes.InvalidArgument, "username is required")
|
||||
}
|
||||
e, ok := s.Store.ByUsername(req.GetUsername())
|
||||
if !ok {
|
||||
return nil, status.Errorf(codes.NotFound, "no entry for user %q", req.GetUsername())
|
||||
}
|
||||
return toProto(e), nil
|
||||
}
|
||||
|
||||
func toProto(e scoreboard.Entry) *pb.Entry {
|
||||
return &pb.Entry{
|
||||
Rank: int32(e.Rank),
|
||||
Username: e.Username,
|
||||
ProfilePath: e.ProfilePath,
|
||||
Country: e.Country,
|
||||
Grade: e.Grade,
|
||||
Score: int32(e.Score),
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,139 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
"git.mkz.me/mycroft/root-me-api/internal/scoreboard"
|
||||
)
|
||||
|
||||
// StatsProvider exposes refresh health (implemented by *scoreboard.Refresher).
|
||||
type StatsProvider interface {
|
||||
Stats() (success, failure int64, lastErr error, lastErrAt time.Time)
|
||||
}
|
||||
|
||||
// HTTPHandler builds the JSON REST mux over the store. stats may be nil.
|
||||
func HTTPHandler(store *scoreboard.Store, stats StatsProvider, logger *slog.Logger) http.Handler {
|
||||
if logger == nil {
|
||||
logger = slog.Default()
|
||||
}
|
||||
h := &httpServer{store: store, stats: stats, logger: logger}
|
||||
|
||||
mux := http.NewServeMux()
|
||||
mux.HandleFunc("GET /healthz", h.healthz)
|
||||
mux.HandleFunc("GET /readyz", h.readyz)
|
||||
mux.HandleFunc("GET /v1/scoreboard", h.list)
|
||||
mux.HandleFunc("GET /v1/scoreboard/rank/{rank}", h.byRank)
|
||||
mux.HandleFunc("GET /v1/scoreboard/user/{username}", h.byUser)
|
||||
return mux
|
||||
}
|
||||
|
||||
type httpServer struct {
|
||||
store *scoreboard.Store
|
||||
stats StatsProvider
|
||||
logger *slog.Logger
|
||||
}
|
||||
|
||||
type listResponse struct {
|
||||
Entries []scoreboard.Entry `json:"entries"`
|
||||
Total int `json:"total"`
|
||||
FetchedAt time.Time `json:"fetched_at"`
|
||||
}
|
||||
|
||||
func (h *httpServer) healthz(w http.ResponseWriter, _ *http.Request) {
|
||||
w.WriteHeader(http.StatusOK)
|
||||
_, _ = w.Write([]byte("ok\n"))
|
||||
}
|
||||
|
||||
func (h *httpServer) readyz(w http.ResponseWriter, _ *http.Request) {
|
||||
body := map[string]any{"ready": h.store.Ready(), "entries": h.store.Len()}
|
||||
if h.stats != nil {
|
||||
ok, fail, lastErr, lastErrAt := h.stats.Stats()
|
||||
body["refresh_success"] = ok
|
||||
body["refresh_failure"] = fail
|
||||
if lastErr != nil {
|
||||
body["last_error"] = lastErr.Error()
|
||||
body["last_error_at"] = lastErrAt
|
||||
}
|
||||
}
|
||||
code := http.StatusOK
|
||||
if !h.store.Ready() {
|
||||
code = http.StatusServiceUnavailable
|
||||
}
|
||||
writeJSON(w, code, body)
|
||||
}
|
||||
|
||||
func (h *httpServer) list(w http.ResponseWriter, r *http.Request) {
|
||||
if !h.store.Ready() {
|
||||
writeError(w, http.StatusServiceUnavailable, "scoreboard not loaded yet")
|
||||
return
|
||||
}
|
||||
offset, err := intParam(r, "offset", 0)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusBadRequest, "invalid offset")
|
||||
return
|
||||
}
|
||||
limit, err := intParam(r, "limit", 0)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusBadRequest, "invalid limit")
|
||||
return
|
||||
}
|
||||
entries, fetchedAt := h.store.List(offset, limit)
|
||||
writeJSON(w, http.StatusOK, listResponse{
|
||||
Entries: entries,
|
||||
Total: h.store.Len(),
|
||||
FetchedAt: fetchedAt,
|
||||
})
|
||||
}
|
||||
|
||||
func (h *httpServer) byRank(w http.ResponseWriter, r *http.Request) {
|
||||
if !h.store.Ready() {
|
||||
writeError(w, http.StatusServiceUnavailable, "scoreboard not loaded yet")
|
||||
return
|
||||
}
|
||||
rank, err := strconv.Atoi(r.PathValue("rank"))
|
||||
if err != nil || rank < 1 {
|
||||
writeError(w, http.StatusBadRequest, "rank must be a positive integer")
|
||||
return
|
||||
}
|
||||
e, ok := h.store.ByRank(rank)
|
||||
if !ok {
|
||||
writeError(w, http.StatusNotFound, "no entry at that rank")
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, e)
|
||||
}
|
||||
|
||||
func (h *httpServer) byUser(w http.ResponseWriter, r *http.Request) {
|
||||
if !h.store.Ready() {
|
||||
writeError(w, http.StatusServiceUnavailable, "scoreboard not loaded yet")
|
||||
return
|
||||
}
|
||||
e, ok := h.store.ByUsername(r.PathValue("username"))
|
||||
if !ok {
|
||||
writeError(w, http.StatusNotFound, "user not found in scoreboard")
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, e)
|
||||
}
|
||||
|
||||
func intParam(r *http.Request, name string, def int) (int, error) {
|
||||
v := r.URL.Query().Get(name)
|
||||
if v == "" {
|
||||
return def, nil
|
||||
}
|
||||
return strconv.Atoi(v)
|
||||
}
|
||||
|
||||
func writeJSON(w http.ResponseWriter, code int, v any) {
|
||||
w.Header().Set("Content-Type", "application/json; charset=utf-8")
|
||||
w.WriteHeader(code)
|
||||
_ = json.NewEncoder(w).Encode(v)
|
||||
}
|
||||
|
||||
func writeError(w http.ResponseWriter, code int, msg string) {
|
||||
writeJSON(w, code, map[string]string{"error": msg})
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
syntax = "proto3";
|
||||
|
||||
package scoreboard.v1;
|
||||
|
||||
option go_package = "git.mkz.me/mycroft/root-me-api/gen/scoreboard/v1;scoreboardv1";
|
||||
|
||||
// ScoreboardService serves the cached root-me.org scoreboard.
|
||||
service ScoreboardService {
|
||||
// ListScoreboard returns a page of entries ordered by rank.
|
||||
rpc ListScoreboard(ListScoreboardRequest) returns (ListScoreboardResponse);
|
||||
// GetByRank returns the entry at the given canonical rank.
|
||||
rpc GetByRank(GetByRankRequest) returns (Entry);
|
||||
// GetByUsername returns the entry for a username (case-insensitive).
|
||||
rpc GetByUsername(GetByUsernameRequest) returns (Entry);
|
||||
}
|
||||
|
||||
// Entry is a single scoreboard row.
|
||||
message Entry {
|
||||
int32 rank = 1;
|
||||
string username = 2;
|
||||
string profile_path = 3;
|
||||
string country = 4;
|
||||
string grade = 5;
|
||||
int32 score = 6;
|
||||
}
|
||||
|
||||
message ListScoreboardRequest {
|
||||
// Zero-based offset into the ranked list.
|
||||
int32 offset = 1;
|
||||
// Maximum entries to return; 0 means "all from offset".
|
||||
int32 limit = 2;
|
||||
}
|
||||
|
||||
message ListScoreboardResponse {
|
||||
repeated Entry entries = 1;
|
||||
// Total entries in the current snapshot.
|
||||
int32 total = 2;
|
||||
// Snapshot time (unix seconds) the data was fetched.
|
||||
int64 fetched_at = 3;
|
||||
}
|
||||
|
||||
message GetByRankRequest {
|
||||
int32 rank = 1;
|
||||
}
|
||||
|
||||
message GetByUsernameRequest {
|
||||
string username = 1;
|
||||
}
|
||||
Reference in New Issue
Block a user