├── .rgignore ├── .env.example ├── web ├── static │ ├── assets │ │ ├── blackhole │ │ │ ├── scene.bin │ │ │ ├── space.glb │ │ │ ├── blackhole.glb │ │ │ ├── textures │ │ │ │ ├── Blackhole_01_baseColor.png │ │ │ │ ├── Blackhole_01_emissive.jpeg │ │ │ │ ├── Blackhole_02_emissive.jpeg │ │ │ │ └── Blackhole_03_baseColor.png │ │ │ └── license.txt │ │ ├── Line2.js │ │ ├── LineGeometry.js │ │ ├── LineSegmentsGeometry.js │ │ ├── LineSegments2.js │ │ └── LineMaterial.js │ ├── clusters.js │ └── blackhole.js ├── templates │ ├── footer.templ │ ├── header.templ │ ├── stats.templ │ ├── blackhole.templ │ ├── navbar.templ │ ├── clusters.templ │ ├── links.templ │ ├── index.templ │ ├── peerfinder.templ │ ├── calculator.templ │ └── leaderboard.templ ├── static.go ├── stats.go ├── calculator.go ├── run.go ├── blackhole.go ├── index.go ├── peerfinder.go ├── clusters.go └── leaderboard.go ├── internal ├── models │ ├── campus.go │ ├── coalition.go │ ├── title.go │ ├── apikey.go │ ├── location.go │ ├── project.go │ └── user.go ├── projects │ ├── xp.go │ └── projects.go ├── api │ ├── stats.go │ ├── debug.go │ ├── oauth.go │ ├── ratelimit.go │ ├── keys.go │ └── api.go ├── keys │ ├── keydel │ │ └── keydel.go │ └── keygen │ │ └── keygen.go ├── campus │ └── campus.go ├── database │ ├── filters.go │ └── db.go ├── users │ ├── testaccounts.go │ ├── title.go │ ├── coalition.go │ ├── users.go │ └── logtime.go └── clusters │ └── clusters.go ├── .envrc ├── .gitignore ├── devenv.yaml ├── devenv.nix ├── Makefile ├── README.md ├── go.mod ├── cmd ├── main.go └── jobs.go ├── devenv.lock ├── assets └── xp.json └── go.sum /.rgignore: -------------------------------------------------------------------------------- 1 | web/static/assets/ 2 | -------------------------------------------------------------------------------- /.env.example: -------------------------------------------------------------------------------- 1 | INTRA_SESSION_TOKEN=... # _intra_42_session_production 2 | USER_ID_TOKEN=... # user.id 3 | -------------------------------------------------------------------------------- /web/static/assets/blackhole/scene.bin: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/demostanis/42evaluators/HEAD/web/static/assets/blackhole/scene.bin -------------------------------------------------------------------------------- /web/static/assets/blackhole/space.glb: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/demostanis/42evaluators/HEAD/web/static/assets/blackhole/space.glb -------------------------------------------------------------------------------- /internal/models/campus.go: -------------------------------------------------------------------------------- 1 | package models 2 | 3 | type Campus struct { 4 | ID int `json:"id"` 5 | Name string `json:"name"` 6 | } 7 | -------------------------------------------------------------------------------- /web/static/assets/blackhole/blackhole.glb: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/demostanis/42evaluators/HEAD/web/static/assets/blackhole/blackhole.glb -------------------------------------------------------------------------------- /web/static/assets/blackhole/textures/Blackhole_01_baseColor.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/demostanis/42evaluators/HEAD/web/static/assets/blackhole/textures/Blackhole_01_baseColor.png -------------------------------------------------------------------------------- /web/static/assets/blackhole/textures/Blackhole_01_emissive.jpeg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/demostanis/42evaluators/HEAD/web/static/assets/blackhole/textures/Blackhole_01_emissive.jpeg -------------------------------------------------------------------------------- /web/static/assets/blackhole/textures/Blackhole_02_emissive.jpeg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/demostanis/42evaluators/HEAD/web/static/assets/blackhole/textures/Blackhole_02_emissive.jpeg -------------------------------------------------------------------------------- /web/static/assets/blackhole/textures/Blackhole_03_baseColor.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/demostanis/42evaluators/HEAD/web/static/assets/blackhole/textures/Blackhole_03_baseColor.png -------------------------------------------------------------------------------- /.envrc: -------------------------------------------------------------------------------- 1 | source_url "https://raw.githubusercontent.com/cachix/devenv/95f329d49a8a5289d31e0982652f7058a189bfca/direnvrc" "sha256-d+8cBpDfDBj41inrADaJt+bDWhOktwslgoP5YiGJ1v0=" 2 | 3 | use devenv -------------------------------------------------------------------------------- /internal/models/coalition.go: -------------------------------------------------------------------------------- 1 | package models 2 | 3 | type Coalition struct { 4 | ID int `json:"id"` 5 | Name string `json:"name"` 6 | CoverURL string `json:"cover_url"` 7 | } 8 | -------------------------------------------------------------------------------- /internal/models/title.go: -------------------------------------------------------------------------------- 1 | package models 2 | 3 | type Title struct { 4 | ID int `json:"id"` 5 | Name string `json:"name"` 6 | } 7 | 8 | var DefaultTitle = Title{ 9 | ID: -1, 10 | Name: "%login", 11 | } 12 | -------------------------------------------------------------------------------- /internal/models/apikey.go: -------------------------------------------------------------------------------- 1 | package models 2 | 3 | type APIKey struct { 4 | ID int 5 | Name string 6 | AppID int 7 | UID string 8 | Secret string 9 | RedirectURI string 10 | } 11 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .env 2 | *_templ.go 3 | db/42evaluators-dev.sqlite3 4 | db/42evaluators-dev.sqlite3-journal 5 | .*.swp 6 | 7 | # Devenv 8 | .devenv* 9 | devenv.local.nix 10 | # direnv 11 | .direnv 12 | # pre-commit 13 | .pre-commit-config.yaml 14 | -------------------------------------------------------------------------------- /internal/models/location.go: -------------------------------------------------------------------------------- 1 | package models 2 | 3 | type Location struct { 4 | ID int `json:"id"` 5 | UserID int `json:"user_id"` 6 | Login string `json:"login"` 7 | Host string `json:"host"` 8 | CampusID int `json:"campus_id"` 9 | EndAt string `json:"end_at"` 10 | Image string 11 | } 12 | -------------------------------------------------------------------------------- /devenv.yaml: -------------------------------------------------------------------------------- 1 | inputs: 2 | nixpkgs: 3 | url: github:cachix/devenv-nixpkgs/rolling 4 | 5 | # If you're using non-OSS software, you can set allowUnfree to true. 6 | # allowUnfree: true 7 | 8 | # If you're willing to use a package that's vulnerable 9 | # permittedInsecurePackages: 10 | # - "openssl-1.1.1w" 11 | 12 | # If you have more than one devenv you can merge them 13 | #imports: 14 | # - ./backend 15 | -------------------------------------------------------------------------------- /web/templates/footer.templ: -------------------------------------------------------------------------------- 1 | package templates 2 | 3 | import "github.com/demostanis/42evaluators/internal/models" 4 | 5 | script fallbackImages(src string) { 6 | // TODO: this doesn't work on my phone... 7 | [].__proto__.slice.call(document.querySelectorAll("img")).forEach(img => 8 | (img.onerror = () => img.src = src)); 9 | } 10 | 11 | templ footer() { 12 | @fallbackImages(models.DefaultImageLink) 13 | } 14 | -------------------------------------------------------------------------------- /web/static.go: -------------------------------------------------------------------------------- 1 | package web 2 | 3 | import ( 4 | "net/http" 5 | "strings" 6 | ) 7 | 8 | func intercept(serv http.Handler) http.Handler { 9 | return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 10 | if strings.HasSuffix(r.URL.Path, "/") { 11 | http.NotFound(w, r) 12 | } else { 13 | serv.ServeHTTP(w, r) 14 | } 15 | }) 16 | } 17 | 18 | func handleStatic() http.Handler { 19 | serv := http.FileServer(http.Dir("web/static")) 20 | return http.StripPrefix("/static/", intercept(serv)) 21 | } 22 | -------------------------------------------------------------------------------- /web/static/assets/Line2.js: -------------------------------------------------------------------------------- 1 | import { LineSegments2 } from 'three/addons/LineSegments2.js'; 2 | import { LineGeometry } from 'three/addons/LineGeometry.js'; 3 | import { LineMaterial } from 'three/addons/LineMaterial.js'; 4 | 5 | class Line2 extends LineSegments2 { 6 | 7 | constructor( geometry = new LineGeometry(), material = new LineMaterial( { color: Math.random() * 0xffffff } ) ) { 8 | 9 | super( geometry, material ); 10 | 11 | this.isLine2 = true; 12 | 13 | this.type = 'Line2'; 14 | 15 | } 16 | 17 | } 18 | 19 | export { Line2 }; 20 | -------------------------------------------------------------------------------- /devenv.nix: -------------------------------------------------------------------------------- 1 | { pkgs, lib, config, inputs, ... }: 2 | 3 | { 4 | languages.go.enable = true; 5 | 6 | services.postgres = { 7 | enable = true; 8 | listen_addresses = "127.0.0.1"; 9 | }; 10 | 11 | enterShell = '' 12 | export PATH="${GOPATH:-$HOME/go}/bin:$PATH" 13 | ''; 14 | 15 | env.PRODUCTION = "n"; # this is not a real environment variable, 16 | # you need to modify it here to take effect 17 | processes.evaluators = lib.mkIf (config.env.PRODUCTION == "y") { 18 | exec = "make prod"; 19 | }; 20 | 21 | pre-commit.hooks.golangci-lint.enable = true; 22 | 23 | # .env is not used in this file 24 | dotenv.disableHint = true; 25 | } 26 | -------------------------------------------------------------------------------- /web/static/assets/blackhole/license.txt: -------------------------------------------------------------------------------- 1 | Model Information: 2 | * title: Blackhole 3 | * source: https://sketchfab.com/3d-models/blackhole-32f978d0e7354af293fa498f2998b14c 4 | * author: shikoooooooo (https://sketchfab.com/shikoooooooo) 5 | 6 | Model License: 7 | * license type: CC-BY-4.0 (http://creativecommons.org/licenses/by/4.0/) 8 | * requirements: Author must be credited. Commercial use is allowed. 9 | 10 | If you use this 3D model in your project be sure to copy paste this credit wherever you share it: 11 | This work is based on "Blackhole" (https://sketchfab.com/3d-models/blackhole-32f978d0e7354af293fa498f2998b14c) by shikoooooooo (https://sketchfab.com/shikoooooooo) licensed under CC-BY-4.0 (http://creativecommons.org/licenses/by/4.0/) -------------------------------------------------------------------------------- /web/templates/header.templ: -------------------------------------------------------------------------------- 1 | package templates 2 | 3 | script changeTheme() { 4 | const search = new URLSearchParams(location.search); 5 | if (search.has("theme")) 6 | document.body.parentNode.dataset.theme = search.get("theme"); 7 | } 8 | 9 | templ header() { 10 | 11 | 12 | 13 | 42evaluators 14 | 15 | 16 | 17 | 18 | 19 | 20 | @changeTheme() 21 | @navbar() 22 | } 23 | -------------------------------------------------------------------------------- /internal/projects/xp.go: -------------------------------------------------------------------------------- 1 | package projects 2 | 3 | import ( 4 | "encoding/json" 5 | "io" 6 | "os" 7 | ) 8 | 9 | type XPByLevel struct { 10 | Level int `json:"lvl"` 11 | XP int `json:"xp"` 12 | XPToNextLevel int `json:"xpToNextLevel"` 13 | } 14 | 15 | var XPData []XPByLevel 16 | 17 | func OpenXPData() error { 18 | file, err := os.Open("assets/xp.json") 19 | if err != nil { 20 | return err 21 | } 22 | bytes, err := io.ReadAll(file) 23 | if err != nil { 24 | return err 25 | } 26 | err = json.Unmarshal(bytes, &XPData) 27 | if err != nil { 28 | return err 29 | } 30 | 31 | for i := range XPData { 32 | if i == len(XPData)-1 { 33 | break 34 | } 35 | levelXP := XPData[i].XP 36 | nextLevelXP := XPData[i+1].XP 37 | XPData[i].XPToNextLevel = nextLevelXP - levelXP 38 | } 39 | return nil 40 | } 41 | -------------------------------------------------------------------------------- /internal/api/stats.go: -------------------------------------------------------------------------------- 1 | package api 2 | 3 | import ( 4 | "fmt" 5 | "sync" 6 | ) 7 | 8 | type Stats struct { 9 | sync.Mutex 10 | RequestsSoFar int `json:"requestsSoFar"` 11 | TotalRequests int `json:"totalRequests"` 12 | } 13 | 14 | func (stats *Stats) requestDone() { 15 | stats.Lock() 16 | defer stats.Unlock() 17 | stats.RequestsSoFar++ 18 | } 19 | 20 | func (stats *Stats) growTotalRequests(n int) { 21 | stats.Lock() 22 | defer stats.Unlock() 23 | stats.TotalRequests += n 24 | } 25 | 26 | func (stats *Stats) String() string { 27 | return fmt.Sprintf("%d/%d requests", 28 | stats.RequestsSoFar, stats.TotalRequests) 29 | } 30 | 31 | func (stats *Stats) Percent() int { 32 | if stats.TotalRequests == 0 { 33 | return 0 34 | } 35 | return int( 36 | float32(stats.RequestsSoFar) / float32(stats.TotalRequests) * 100., 37 | ) 38 | } 39 | 40 | var APIStats = Stats{} 41 | -------------------------------------------------------------------------------- /web/stats.go: -------------------------------------------------------------------------------- 1 | package web 2 | 3 | import ( 4 | "encoding/json" 5 | "net/http" 6 | "time" 7 | 8 | "github.com/demostanis/42evaluators/internal/api" 9 | "github.com/gorilla/websocket" 10 | "gorm.io/gorm" 11 | ) 12 | 13 | func statsWs(db *gorm.DB) http.Handler { 14 | return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 15 | c, err := upgrader.Upgrade(w, r, nil) 16 | if err != nil { 17 | return 18 | } 19 | defer c.Close() 20 | 21 | stop := make(chan bool) 22 | ticker := time.NewTicker(1 * time.Second) 23 | 24 | for { 25 | select { 26 | case <-stop: 27 | return 28 | case <-ticker.C: 29 | bytes, err := json.Marshal(&api.APIStats) 30 | if err != nil { 31 | return 32 | } 33 | err = c.WriteMessage(websocket.TextMessage, bytes) 34 | if err != nil { 35 | stop <- true 36 | } 37 | } 38 | } 39 | }) 40 | } 41 | -------------------------------------------------------------------------------- /internal/keys/keydel/keydel.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "fmt" 5 | "os" 6 | 7 | "github.com/demostanis/42evaluators/internal/api" 8 | "github.com/demostanis/42evaluators/internal/database" 9 | "github.com/joho/godotenv" 10 | ) 11 | 12 | func main() { 13 | if err := godotenv.Load(); err != nil { 14 | fmt.Fprintln(os.Stderr, "error loading .env:", err) 15 | return 16 | } 17 | 18 | db, err := database.OpenDB() 19 | if err != nil { 20 | fmt.Fprintln(os.Stderr, "error opening database:", err) 21 | return 22 | } 23 | phyDB, _ := db.DB() 24 | defer phyDB.Close() 25 | 26 | api.DefaultKeysManager, err = api.NewKeysManager(db) 27 | if err != nil { 28 | fmt.Fprintln(os.Stderr, "error creating a key manager:", err) 29 | return 30 | } 31 | err = api.DefaultKeysManager.DeleteAllKeys() 32 | if err != nil { 33 | fmt.Fprintln(os.Stderr, "error deleting API keys:", err) 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | TEMPL ?= templ 2 | GO ?= go 3 | 4 | default: dev 5 | 6 | TEMPLATES = $(patsubst %.templ,%_templ.go,$(wildcard web/templates/*.templ)) 7 | 8 | web/templates/%_templ.go: web/templates/%.templ 9 | $(TEMPL) generate -f $^ 10 | 11 | templates: $(TEMPLATES) 12 | 13 | dev: deps templates 14 | env $(FLAGS) $(GO) run $(GOFLAGS) cmd/*.go 15 | 16 | prod: GOFLAGS="-ldflags=-X github.com/demostanis/42evaluators/internal/api.defaultRedirectURI=https://42evaluators.com" 17 | prod: deps templates dev 18 | 19 | nojobs: FLAGS=disabledjobs=* 20 | nojobs: dev 21 | 22 | race: GOFLAGS+=-race 23 | race: dev 24 | 25 | debug: FLAGS=httpdebug=* 26 | debug: dev 27 | 28 | 42evaluators: templates 29 | $(GO) build cmd/main.go -o $@ 30 | 31 | build: deps 42evaluators 32 | 33 | clean: 34 | $(RM) $(TEMPLATES) 35 | 36 | deps: 37 | @if ! which templ >/dev/null 2>&1 ; then \ 38 | $(GO) install github.com/a-h/templ/cmd/templ@latest; \ 39 | fi 40 | 41 | .PHONY: default templates dev build clean deps 42 | -------------------------------------------------------------------------------- /internal/keys/keygen/keygen.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "fmt" 5 | "os" 6 | "strconv" 7 | 8 | "github.com/demostanis/42evaluators/internal/api" 9 | "github.com/demostanis/42evaluators/internal/database" 10 | "github.com/joho/godotenv" 11 | ) 12 | 13 | func main() { 14 | if err := godotenv.Load(); err != nil { 15 | fmt.Fprintln(os.Stderr, "error loading .env:", err) 16 | return 17 | } 18 | 19 | db, err := database.OpenDB() 20 | if err != nil { 21 | fmt.Fprintln(os.Stderr, "error opening database:", err) 22 | return 23 | } 24 | phyDB, _ := db.DB() 25 | defer phyDB.Close() 26 | 27 | if len(os.Args) != 2 { 28 | fmt.Fprintln(os.Stderr, "usage: ./keygen ") 29 | return 30 | } 31 | i, err := strconv.Atoi(os.Args[1]) 32 | if err != nil || i <= 0 { 33 | fmt.Fprintln(os.Stderr, "invalid key count") 34 | return 35 | } 36 | 37 | if err = api.GetKeys(i, db); err != nil { 38 | fmt.Fprintln(os.Stderr, "failed to generate keys:", err) 39 | return 40 | } 41 | } 42 | -------------------------------------------------------------------------------- /internal/campus/campus.go: -------------------------------------------------------------------------------- 1 | package campus 2 | 3 | import ( 4 | "fmt" 5 | 6 | "github.com/demostanis/42evaluators/internal/api" 7 | "github.com/demostanis/42evaluators/internal/models" 8 | "gorm.io/gorm" 9 | ) 10 | 11 | var ( 12 | waitForCampuses = make(chan bool) 13 | waitForCampusesClosed = false 14 | ) 15 | 16 | func WaitForCampuses() { 17 | if !waitForCampusesClosed { 18 | <-waitForCampuses 19 | } 20 | } 21 | 22 | func GetCampuses(db *gorm.DB, errstream chan error) { 23 | campuses, err := api.DoPaginated[[]models.Campus]( 24 | api.NewRequest("/v2/campus"). 25 | Authenticated()) 26 | if err != nil { 27 | errstream <- err 28 | return 29 | } 30 | 31 | for { 32 | campus, err := (<-campuses)() 33 | if err != nil { 34 | errstream <- fmt.Errorf("error while fetching campuses: %w", err) 35 | continue 36 | } 37 | if campus == nil { 38 | break 39 | } 40 | err = db.Save(&campus).Error 41 | if err != nil { 42 | errstream <- err 43 | } 44 | } 45 | if !waitForCampusesClosed { 46 | close(waitForCampuses) 47 | waitForCampusesClosed = true 48 | } 49 | } 50 | -------------------------------------------------------------------------------- /internal/api/debug.go: -------------------------------------------------------------------------------- 1 | package api 2 | 3 | import ( 4 | "fmt" 5 | "net/http" 6 | "net/http/httputil" 7 | "os" 8 | "strings" 9 | ) 10 | 11 | const DebugVar = "httpdebug" 12 | 13 | func selectedEndpoint(endpoint string) bool { 14 | wantedEndpointsRaw := os.Getenv(DebugVar) 15 | if wantedEndpointsRaw == "" { 16 | return false 17 | } 18 | if wantedEndpointsRaw == "*" { 19 | return true 20 | } 21 | wantedEndpoints := strings.Split(wantedEndpointsRaw, ",") 22 | for _, wantedEndpoint := range wantedEndpoints { 23 | if strings.HasPrefix(endpoint, wantedEndpoint) { 24 | return true 25 | } 26 | } 27 | return false 28 | } 29 | 30 | func DebugRequest(req *http.Request) { 31 | if selectedEndpoint(req.URL.Path) { 32 | output, err := httputil.DumpRequestOut(req, true) 33 | if err == nil { 34 | fmt.Printf("\n\n%s\n\n", output) 35 | } else { 36 | fmt.Fprintf(os.Stderr, "request errored out: %s\n", err) 37 | } 38 | } 39 | } 40 | 41 | func DebugResponse(res *http.Response) { 42 | if selectedEndpoint(res.Request.URL.Path) { 43 | output, err := httputil.DumpResponse(res, true) 44 | if err == nil { 45 | fmt.Printf("\n\n%s\n\n", output) 46 | } else { 47 | fmt.Fprintf(os.Stderr, "request errored out: %s\n", err) 48 | } 49 | } 50 | } 51 | -------------------------------------------------------------------------------- /internal/models/project.go: -------------------------------------------------------------------------------- 1 | package models 2 | 3 | type TeamUser struct { 4 | TeamID int `gorm:"primaryKey"` 5 | UserID int `gorm:"primaryKey" json:"id"` 6 | Leader bool `json:"leader"` 7 | User User 8 | } 9 | 10 | type Team struct { 11 | // this musn't be a gorm.Model, because GORM is so 12 | // fucking drunk and will put two ids in INSERT statements, 13 | // making the DB complain that there are two fucking ids. 14 | ID int `json:"id"` 15 | Name string `json:"name"` 16 | Users []TeamUser `json:"users"` 17 | ProjectID int 18 | } 19 | 20 | type Subject struct { 21 | ID int `json:"id"` 22 | Name string `json:"name"` 23 | Slug string `json:"slug"` 24 | // Calculated from the distance from the center 25 | // using Holy Graph coordinates 26 | Position int 27 | XP int 28 | } 29 | 30 | type Project struct { 31 | ID int `json:"id"` 32 | CursusIDs []int `gorm:"-" json:"cursus_ids"` 33 | FinalMark int `json:"final_mark"` 34 | Status string `json:"status"` 35 | Teams []Team `gorm:"foreignKey:ProjectID" json:"teams"` 36 | CurrentTeamID int `gorm:"-" json:"current_team_id"` 37 | ActiveTeam int 38 | 39 | SubjectID int 40 | Subject Subject `json:"project"` 41 | } 42 | -------------------------------------------------------------------------------- /internal/database/filters.go: -------------------------------------------------------------------------------- 1 | package database 2 | 3 | import ( 4 | "fmt" 5 | "time" 6 | 7 | "github.com/demostanis/42evaluators/internal/models" 8 | "gorm.io/gorm" 9 | ) 10 | 11 | const ( 12 | PromoFormat = "01/2006" 13 | ) 14 | 15 | func WithCampus(campusID string) func(db *gorm.DB) *gorm.DB { 16 | return func(db *gorm.DB) *gorm.DB { 17 | if campusID != "" { 18 | return db.Where("campus_id = ?", campusID) 19 | } 20 | return db 21 | } 22 | } 23 | 24 | const OnlyRealUsersCondition = "is_staff = false AND is_test = false AND login != ''" 25 | 26 | func OnlyRealUsers() func(db *gorm.DB) *gorm.DB { 27 | return func(db *gorm.DB) *gorm.DB { 28 | return db.Where(OnlyRealUsersCondition) 29 | } 30 | } 31 | 32 | func WithPromo(promo string) func(db *gorm.DB) *gorm.DB { 33 | promoBeginAt, err := time.Parse(PromoFormat, promo) 34 | 35 | return func(db *gorm.DB) *gorm.DB { 36 | if err == nil { 37 | return db. 38 | Model(&models.User{}). 39 | Where("begin_at::text LIKE ?", fmt.Sprintf("%d-%02d-%%", 40 | promoBeginAt.Year(), promoBeginAt.Month())). 41 | Scopes(OnlyRealUsers()) 42 | } 43 | return db 44 | } 45 | } 46 | 47 | const UnwantedSubjectsCondition = `name NOT LIKE 'Day %' AND 48 | name NOT LIKE '%DEPRECATED%' AND 49 | name NOT LIKE 'Rush %'` 50 | -------------------------------------------------------------------------------- /internal/users/testaccounts.go: -------------------------------------------------------------------------------- 1 | package users 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "sync" 7 | 8 | "github.com/demostanis/42evaluators/internal/api" 9 | "github.com/demostanis/42evaluators/internal/models" 10 | "gorm.io/gorm" 11 | ) 12 | 13 | type Group struct { 14 | Group struct { 15 | Name string `json:"name"` 16 | } `json:"group"` 17 | UserID int `json:"user_id"` 18 | } 19 | 20 | func GetTests( 21 | ctx context.Context, 22 | db *gorm.DB, 23 | errstream chan error, 24 | wg *sync.WaitGroup, 25 | ) { 26 | wg.Add(1) 27 | 28 | groups, err := api.DoPaginated[[]Group]( 29 | api.NewRequest("/v2/groups_users"). 30 | Authenticated()) 31 | if err != nil { 32 | errstream <- err 33 | return 34 | } 35 | 36 | for { 37 | group, err := (<-groups)() 38 | if err != nil { 39 | errstream <- fmt.Errorf("error while fetching groups: %w", err) 40 | continue 41 | } 42 | if group == nil { 43 | break 44 | } 45 | 46 | if group.Group.Name == "Test account" { 47 | user := models.User{ID: group.UserID} 48 | err = user.CreateIfNeeded(db) 49 | if err != nil { 50 | errstream <- err 51 | continue 52 | } 53 | err = user.YesItsATestAccount(db) 54 | if err != nil { 55 | errstream <- err 56 | } 57 | } 58 | } 59 | 60 | wg.Done() 61 | } 62 | -------------------------------------------------------------------------------- /web/calculator.go: -------------------------------------------------------------------------------- 1 | package web 2 | 3 | import ( 4 | "fmt" 5 | "net/http" 6 | 7 | "github.com/demostanis/42evaluators/internal/database" 8 | "github.com/demostanis/42evaluators/internal/models" 9 | "github.com/demostanis/42evaluators/web/templates" 10 | "gorm.io/gorm" 11 | ) 12 | 13 | func handleCalculator(db *gorm.DB) http.Handler { 14 | return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 15 | var subjects []models.Subject 16 | err := db. 17 | Model(&models.Subject{}). 18 | Where(database.UnwantedSubjectsCondition). 19 | Where("xp > 0"). 20 | Order("position"). 21 | Find(&subjects).Error 22 | if err != nil { 23 | internalServerError(w, fmt.Errorf("failed to get subjects: %w", err)) 24 | return 25 | } 26 | 27 | user := getLoggedInUser(r) 28 | 29 | var level float64 30 | err = db. 31 | Model(&models.User{}). 32 | Select("level"). 33 | Where("id = ?", user.them.ID). 34 | Find(&level).Error 35 | if err != nil { 36 | internalServerError(w, fmt.Errorf("failed to get user level: %w", err)) 37 | return 38 | } 39 | 40 | // TODO: once we find a more optimized way of 41 | // fetching the logged-in user's active projects, 42 | // we should display them above others in the 43 | // project select 44 | 45 | _ = templates.Calculator(subjects, level). 46 | Render(r.Context(), w) 47 | }) 48 | } 49 | -------------------------------------------------------------------------------- /web/templates/stats.templ: -------------------------------------------------------------------------------- 1 | package templates 2 | 3 | import ( 4 | "github.com/demostanis/42evaluators/internal/api" 5 | "strconv" 6 | ) 7 | 8 | script realtimeStats() { 9 | const secure = window.location.protocol == "https:"; 10 | const ws = new WebSocket((secure ? "wss://" : "ws://") 11 | + window.location.host 12 | + window.location.pathname.substr(0, window.location.pathname.length-1) // remove trailing / 13 | + ".live"); 14 | 15 | ws.onmessage = message => { 16 | const data = JSON.parse(message.data); 17 | 18 | const value = `${data.requestsSoFar}/${data.totalRequests} requests`; 19 | document.querySelector(".stat-value").textContent = value; 20 | const progress = data.requestsSoFar/data.totalRequests*100; 21 | document.querySelector("progress").value = parseInt(progress); 22 | } 23 | } 24 | 25 | templ Stats(stats *api.Stats) { 26 | @header() 27 | 28 |
29 |
30 |
31 | Fetching API data... 32 |
33 |
34 | { stats.String() } 35 |
36 |
37 | 41 |
42 |
43 |
44 | 45 | @realtimeStats() 46 | @footer() 47 | } 48 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # 42evaluators 2 | 3 | Welcome to the **42evaluators** GitHub repository! 4 | 5 | ## Running 6 | 7 | We use [devenv](https://devenv.sh) for development. It provides an easy way to 8 | install and setup PostgreSQL (it's probably also possible to setup PostgreSQL 9 | separately. You might have to modify `internal/database/db.go` adequately). 10 | 11 | Once you've downloaded it, run `devenv up -d`. This runs PostgreSQL in the background 12 | (you can also remove the `-d` if you want to inspect the logs). Afterwards, you 13 | can enter the development shell with `devenv shell`. 14 | 15 | You need to fill `.env`. It contains credentials to connect to your account, 16 | which you can find in the "Storage" tab of the Devtools while you're on the 42 intra, 17 | to create API keys (to prevent rate limiting, because 42evaluators requires 18 | doing a LOT of API requests). See `.env.example`. 19 | 20 | Finally, you can use the Makefile to launch 42evaluators: `make` 21 | 22 | This will generate API keys, and start fetching a bunch of stuff (such as 23 | projects, which takes a lot of time...). You can open up `localhost:8080`. 24 | 25 | ## Backstory 26 | 27 | A few months ago, some students from 42 Le Havre noticed 42evaluators.com went down. 28 | We decided to email the previous owner, @rfautier, to try to keep maintaining 29 | the code ourselves. 30 | 31 | After he agreed, we checked the code, but many parts of it would have needed to 32 | be replaced if we wanted to keep the code clean. 33 | 34 | Since I liked Go, I decided to rewrite it completly in Go. But the other students 35 | didn't like that language, so I ended up rewriting most of it myself. 36 | -------------------------------------------------------------------------------- /web/templates/blackhole.templ: -------------------------------------------------------------------------------- 1 | package templates 2 | 3 | import ( 4 | "github.com/demostanis/42evaluators/internal/models" 5 | "strconv" 6 | ) 7 | 8 | script campusChangeHandler() { 9 | const campusSelect = document.querySelector("#campus-select"); 10 | 11 | function handle() { 12 | const params = new URLSearchParams(window.location.search); 13 | params.delete("campus"); 14 | params.append("campus", campusSelect.selectedOptions[0].value); 15 | window.location.search = params; 16 | } 17 | 18 | campusSelect.addEventListener("change", handle); 19 | } 20 | 21 | templ Blackhole(campuses []models.Campus, currentCampusID int) { 22 | @header() 23 | 24 | 32 | 33 |
34 | 45 | 46 |
47 |
48 | 49 | @campusChangeHandler() 50 | @footer() 51 | } 52 | -------------------------------------------------------------------------------- /web/templates/navbar.templ: -------------------------------------------------------------------------------- 1 | package templates 2 | 3 | type ctxKey string 4 | 5 | const UrlCtxKey ctxKey = "url" 6 | 7 | func getActive(ctx context.Context, url string) string { 8 | if ctx.Value(UrlCtxKey).(string) == url { 9 | return "active" 10 | } 11 | return "" 12 | } 13 | 14 | templ Link(url string, name string) { 15 |
  • 16 | 18 | { name } 19 | 20 |
  • 21 | } 22 | 23 | templ links() { 24 | @Link("/leaderboard/", "Leaderboard") 25 | @Link("/peerfinder/", "Peer finder") 26 | @Link("/blackhole/", "Blackhole map") 27 | @Link("/clusters/", "Clusters map") 28 | @Link("/calculator/", "XP calculator") 29 | @Link("/useful-links/", "Useful links") 30 | } 31 | 32 | templ navbar() { 33 | 62 | } 63 | -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module github.com/demostanis/42evaluators 2 | 3 | go 1.21.3 4 | 5 | require ( 6 | github.com/PuerkitoBio/goquery v1.9.1 7 | github.com/a-h/templ v0.2.663 8 | github.com/bogdanfinn/fhttp v0.5.28 9 | github.com/bogdanfinn/tls-client v1.7.4 10 | github.com/go-co-op/gocron/v2 v2.2.9 11 | github.com/gorilla/websocket v1.5.1 12 | github.com/joho/godotenv v1.5.1 13 | golang.org/x/sync v0.7.0 14 | golang.org/x/time v0.5.0 15 | gorm.io/driver/mysql v1.5.6 16 | gorm.io/driver/postgres v1.5.7 17 | gorm.io/gorm v1.25.9 18 | ) 19 | 20 | require ( 21 | filippo.io/edwards25519 v1.1.0 // indirect 22 | github.com/andybalholm/brotli v1.1.0 // indirect 23 | github.com/andybalholm/cascadia v1.3.2 // indirect 24 | github.com/bogdanfinn/utls v1.6.1 // indirect 25 | github.com/cloudflare/circl v1.3.7 // indirect 26 | github.com/go-sql-driver/mysql v1.8.1 // indirect 27 | github.com/google/uuid v1.6.0 // indirect 28 | github.com/jackc/pgpassfile v1.0.0 // indirect 29 | github.com/jackc/pgservicefile v0.0.0-20221227161230-091c0ba34f0a // indirect 30 | github.com/jackc/pgx/v5 v5.4.3 // indirect 31 | github.com/jinzhu/inflection v1.0.0 // indirect 32 | github.com/jinzhu/now v1.1.5 // indirect 33 | github.com/jonboulle/clockwork v0.4.0 // indirect 34 | github.com/klauspost/compress v1.17.8 // indirect 35 | github.com/quic-go/quic-go v0.42.0 // indirect 36 | github.com/robfig/cron/v3 v3.0.1 // indirect 37 | github.com/tam7t/hpkp v0.0.0-20160821193359-2b70b4024ed5 // indirect 38 | golang.org/x/crypto v0.22.0 // indirect 39 | golang.org/x/exp v0.0.0-20240409090435-93d18d7e34b8 // indirect 40 | golang.org/x/net v0.24.0 // indirect 41 | golang.org/x/sys v0.19.0 // indirect 42 | golang.org/x/text v0.14.0 // indirect 43 | ) 44 | -------------------------------------------------------------------------------- /web/templates/clusters.templ: -------------------------------------------------------------------------------- 1 | package templates 2 | 3 | import ( 4 | "strconv" 5 | "github.com/demostanis/42evaluators/internal/clusters" 6 | ) 7 | 8 | script cropToContent(campusID int) { 9 | if ((campusID < 161 || campusID > 163) && campusID != 198) { 10 | const s = document.querySelector("svg"); 11 | const r = s.getBBox({ stroke: true }); 12 | const navbarHeight = document.querySelector(".navbar").getBoundingClientRect().height; 13 | s.setAttribute("viewBox", `${r.x} ${r.y} ${r.width} ${r.height}`); 14 | } 15 | } 16 | 17 | script selectHandler() { 18 | function handle(e) { 19 | const search = new URLSearchParams(window.location.search); 20 | const newCluster = e.target.selectedOptions[0].value; 21 | if (search.get("cluster") != newCluster) { 22 | search.delete("cluster"); 23 | search.append("cluster", newCluster); 24 | window.location.search = search; 25 | } 26 | } 27 | 28 | document.querySelector("#cluster") 29 | .addEventListener("change", handle); 30 | } 31 | 32 | templ ClustersMap(allClusters []clusters.Cluster, selectedCluster clusters.Cluster) { 33 | @header() 34 | 35 | 36 |
    37 | @templ.Raw(selectedCluster.Svg) 38 | 49 | @cropToContent(selectedCluster.ID) 50 | @selectHandler() 51 |
    52 | @footer() 53 | } 54 | -------------------------------------------------------------------------------- /web/static/assets/LineGeometry.js: -------------------------------------------------------------------------------- 1 | import { LineSegmentsGeometry } from 'three/addons/LineSegmentsGeometry.js'; 2 | 3 | class LineGeometry extends LineSegmentsGeometry { 4 | 5 | constructor() { 6 | 7 | super(); 8 | 9 | this.isLineGeometry = true; 10 | 11 | this.type = 'LineGeometry'; 12 | 13 | } 14 | 15 | setPositions( array ) { 16 | 17 | // converts [ x1, y1, z1, x2, y2, z2, ... ] to pairs format 18 | 19 | const length = array.length - 3; 20 | const points = new Float32Array( 2 * length ); 21 | 22 | for ( let i = 0; i < length; i += 3 ) { 23 | 24 | points[ 2 * i ] = array[ i ]; 25 | points[ 2 * i + 1 ] = array[ i + 1 ]; 26 | points[ 2 * i + 2 ] = array[ i + 2 ]; 27 | 28 | points[ 2 * i + 3 ] = array[ i + 3 ]; 29 | points[ 2 * i + 4 ] = array[ i + 4 ]; 30 | points[ 2 * i + 5 ] = array[ i + 5 ]; 31 | 32 | } 33 | 34 | super.setPositions( points ); 35 | 36 | return this; 37 | 38 | } 39 | 40 | setColors( array ) { 41 | 42 | // converts [ r1, g1, b1, r2, g2, b2, ... ] to pairs format 43 | 44 | const length = array.length - 3; 45 | const colors = new Float32Array( 2 * length ); 46 | 47 | for ( let i = 0; i < length; i += 3 ) { 48 | 49 | colors[ 2 * i ] = array[ i ]; 50 | colors[ 2 * i + 1 ] = array[ i + 1 ]; 51 | colors[ 2 * i + 2 ] = array[ i + 2 ]; 52 | 53 | colors[ 2 * i + 3 ] = array[ i + 3 ]; 54 | colors[ 2 * i + 4 ] = array[ i + 4 ]; 55 | colors[ 2 * i + 5 ] = array[ i + 5 ]; 56 | 57 | } 58 | 59 | super.setColors( colors ); 60 | 61 | return this; 62 | 63 | } 64 | 65 | fromLine( line ) { 66 | 67 | const geometry = line.geometry; 68 | 69 | this.setPositions( geometry.attributes.position.array ); // assumes non-indexed 70 | 71 | // set colors, maybe 72 | 73 | return this; 74 | 75 | } 76 | 77 | } 78 | 79 | export { LineGeometry }; 80 | -------------------------------------------------------------------------------- /internal/database/db.go: -------------------------------------------------------------------------------- 1 | package database 2 | 3 | import ( 4 | "time" 5 | 6 | "github.com/demostanis/42evaluators/internal/models" 7 | "gorm.io/driver/postgres" 8 | "gorm.io/gorm" 9 | "gorm.io/gorm/logger" 10 | ) 11 | 12 | type DatabaseType int 13 | 14 | const ( 15 | Production DatabaseType = iota 16 | Development 17 | ) 18 | 19 | func newDB(dialector gorm.Dialector) (*gorm.DB, error) { 20 | db, err := gorm.Open(dialector, &gorm.Config{ 21 | DisableForeignKeyConstraintWhenMigrating: true, 22 | // TODO: remove 23 | Logger: logger.Default.LogMode(logger.Silent), 24 | }) 25 | if err != nil { 26 | return nil, err 27 | } 28 | 29 | if err = db.AutoMigrate(models.APIKey{}); err != nil { 30 | return nil, err 31 | } 32 | if err = db.AutoMigrate(models.User{}); err != nil { 33 | return nil, err 34 | } 35 | if err = db.AutoMigrate(models.Coalition{}); err != nil { 36 | return nil, err 37 | } 38 | if err = db.AutoMigrate(models.Title{}); err != nil { 39 | return nil, err 40 | } 41 | if err = db.AutoMigrate(models.Location{}); err != nil { 42 | return nil, err 43 | } 44 | if err = db.AutoMigrate(models.Campus{}); err != nil { 45 | return nil, err 46 | } 47 | if err = db.AutoMigrate(models.Subject{}); err != nil { 48 | return nil, err 49 | } 50 | if err = db.AutoMigrate(models.TeamUser{}); err != nil { 51 | return nil, err 52 | } 53 | if err = db.AutoMigrate(models.Team{}); err != nil { 54 | return nil, err 55 | } 56 | if err = db.AutoMigrate(models.Project{}); err != nil { 57 | return nil, err 58 | } 59 | if err = db.Exec("CREATE EXTENSION IF NOT EXISTS pg_trgm").Error; err != nil { 60 | return nil, err 61 | } 62 | 63 | phyDB, _ := db.DB() 64 | phyDB.SetMaxOpenConns(20) 65 | phyDB.SetConnMaxLifetime(time.Second * 20) 66 | 67 | return db, nil 68 | } 69 | 70 | func OpenDB() (*gorm.DB, error) { 71 | return newDB(postgres.Open("host=localhost")) 72 | } 73 | -------------------------------------------------------------------------------- /web/run.go: -------------------------------------------------------------------------------- 1 | package web 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "log" 7 | "net/http" 8 | 9 | "github.com/a-h/templ" 10 | "github.com/demostanis/42evaluators/internal/api" 11 | "github.com/demostanis/42evaluators/web/templates" 12 | 13 | "gorm.io/gorm" 14 | ) 15 | 16 | const UsersPerPage = 50 17 | 18 | func loggedInUsersOnly(handler http.Handler) http.Handler { 19 | return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 20 | if getLoggedInUser(r) == nil { 21 | http.Redirect(w, r, fmt.Sprintf("/?needslogin=1&next=%s", 22 | r.URL.Path, 23 | ), http.StatusSeeOther) 24 | return 25 | } 26 | handler.ServeHTTP(w, r) 27 | }) 28 | } 29 | 30 | func withURL(handler http.Handler) http.Handler { 31 | return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 32 | ctx := context.WithValue(r.Context(), templates.UrlCtxKey, r.URL.Path) 33 | handler.ServeHTTP(w, r.WithContext(ctx)) 34 | }) 35 | } 36 | 37 | func Run(db *gorm.DB) { 38 | http.Handle("/", withURL(handleIndex(db))) 39 | http.Handle("/leaderboard/", withURL(loggedInUsersOnly(handleLeaderboard(db)))) 40 | http.Handle("/peerfinder/", withURL(loggedInUsersOnly(handlePeerFinder(db)))) 41 | http.Handle("/calculator/", withURL(loggedInUsersOnly(handleCalculator(db)))) 42 | http.Handle("/blackhole/", withURL(loggedInUsersOnly(handleBlackhole(db)))) 43 | http.Handle("/blackhole.json", withURL(loggedInUsersOnly(blackholeMap(db)))) 44 | http.Handle("/clusters/", withURL(loggedInUsersOnly(handleClusters()))) 45 | http.Handle("/clusters.live", withURL(loggedInUsersOnly(clustersWs(db)))) 46 | http.Handle("/stats/", withURL(loggedInUsersOnly(templ.Handler(templates.Stats(&api.APIStats))))) 47 | http.Handle("/stats.live", withURL(loggedInUsersOnly(statsWs(db)))) 48 | http.Handle("/useful-links/", withURL(loggedInUsersOnly(templ.Handler(templates.Links())))) 49 | 50 | http.Handle("/static/", handleStatic()) 51 | 52 | log.Fatal(http.ListenAndServe(":8080", nil)) 53 | } 54 | -------------------------------------------------------------------------------- /internal/api/oauth.go: -------------------------------------------------------------------------------- 1 | package api 2 | 3 | import ( 4 | "errors" 5 | "fmt" 6 | 7 | "github.com/demostanis/42evaluators/internal/models" 8 | ) 9 | 10 | type Target struct { 11 | URLs []string 12 | Percent float32 13 | ID int 14 | } 15 | 16 | var clients map[int][]*RLHTTPClient 17 | 18 | type OauthTokenResponse struct { 19 | AccessToken string `json:"access_token"` 20 | } 21 | 22 | func OauthToken(apiKey models.APIKey, code string, next string) (string, error) { 23 | params := make(map[string]string) 24 | params["grant_type"] = "client_credentials" 25 | params["client_id"] = apiKey.UID 26 | params["client_secret"] = apiKey.Secret 27 | if code != "" { 28 | params["code"] = code 29 | params["redirect_uri"] = fmt.Sprintf( 30 | "%s?next=%s", 31 | apiKey.RedirectURI, 32 | next, 33 | ) 34 | params["grant_type"] = "authorization_code" 35 | } 36 | 37 | resp, err := Do[OauthTokenResponse]( 38 | NewRequest("/oauth/token"). 39 | WithMethod("POST"). 40 | WithParams(params)) 41 | if err != nil { 42 | return "", err 43 | } 44 | if resp.AccessToken == "" { 45 | return "", errors.New("no access token in response") 46 | } 47 | 48 | return resp.AccessToken, nil 49 | } 50 | 51 | func InitClients(apiKeys []models.APIKey) error { 52 | clients = make(map[int][]*RLHTTPClient) 53 | 54 | var total float32 55 | for _, target := range targets { 56 | total += target.Percent 57 | } 58 | if total > 1 { 59 | return errors.New("total percentage of targets is bigger than 1") 60 | } 61 | 62 | for _, apiKey := range apiKeys { 63 | accessToken, err := OauthToken(apiKey, "", "") 64 | if err != nil { 65 | continue 66 | } 67 | var targetInNeed int 68 | for _, target := range targets { 69 | if len(clients[target.ID]) < max(1, int(float32(len(apiKeys))*target.Percent)) { 70 | targetInNeed = target.ID 71 | break 72 | } 73 | } 74 | clients[targetInNeed] = append(clients[targetInNeed], 75 | RateLimitedClient(accessToken, apiKey)) 76 | } 77 | return nil 78 | } 79 | -------------------------------------------------------------------------------- /internal/users/title.go: -------------------------------------------------------------------------------- 1 | package users 2 | 3 | import ( 4 | "context" 5 | "errors" 6 | "fmt" 7 | "sync" 8 | 9 | "github.com/demostanis/42evaluators/internal/api" 10 | "github.com/demostanis/42evaluators/internal/models" 11 | "gorm.io/gorm" 12 | ) 13 | 14 | type TitleID struct { 15 | ID int `json:"title_id"` 16 | Selected bool `json:"selected"` 17 | UserID int `json:"user_id"` 18 | } 19 | 20 | func getTitle(titleID int, db *gorm.DB) (*models.Title, error) { 21 | var cachedTitle models.Title 22 | err := db. 23 | Session(&gorm.Session{}). 24 | Model(&models.Title{}). 25 | Where("id = ?", titleID). 26 | First(&cachedTitle).Error 27 | 28 | if errors.Is(err, gorm.ErrRecordNotFound) { 29 | actualTitle, err := api.Do[models.Title]( 30 | api.NewRequest(fmt.Sprintf("/v2/titles/%d", titleID)). 31 | Authenticated()) 32 | if err != nil { 33 | return nil, err 34 | } 35 | 36 | db.Save(actualTitle) 37 | return actualTitle, nil 38 | } 39 | return &cachedTitle, err 40 | } 41 | 42 | func GetTitles( 43 | ctx context.Context, 44 | db *gorm.DB, 45 | errstream chan error, 46 | wg *sync.WaitGroup, 47 | ) { 48 | wg.Add(1) 49 | 50 | titles, err := api.DoPaginated[[]TitleID]( 51 | api.NewRequest("/v2/titles_users"). 52 | Authenticated()) 53 | if err != nil { 54 | errstream <- err 55 | return 56 | } 57 | 58 | for { 59 | title, err := (<-titles)() 60 | if err != nil { 61 | errstream <- fmt.Errorf("error while fetching titles: %w", err) 62 | continue 63 | } 64 | if title == nil { 65 | break 66 | } 67 | if !title.Selected { 68 | continue 69 | } 70 | 71 | user := models.User{ID: title.UserID} 72 | err = user.CreateIfNeeded(db) 73 | if err != nil { 74 | errstream <- err 75 | continue 76 | } 77 | go func(titleID int) { 78 | actualTitle, err := getTitle(titleID, db) 79 | if err != nil { 80 | errstream <- err 81 | return 82 | } 83 | err = user.SetTitle(*actualTitle, db) 84 | if err != nil { 85 | errstream <- err 86 | return 87 | } 88 | }(title.ID) 89 | } 90 | 91 | wg.Done() 92 | } 93 | -------------------------------------------------------------------------------- /cmd/main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "os" 7 | 8 | "github.com/demostanis/42evaluators/internal/api" 9 | "github.com/demostanis/42evaluators/internal/database" 10 | "github.com/demostanis/42evaluators/internal/projects" 11 | "github.com/joho/godotenv" 12 | 13 | "github.com/demostanis/42evaluators/web" 14 | ) 15 | 16 | func reportErrors(errstream chan error) { 17 | // TODO: perform error reporting on e.g. Sentry 18 | for { 19 | err := <-errstream 20 | if err != nil { 21 | fmt.Fprintln(os.Stderr, err) 22 | } 23 | } 24 | } 25 | 26 | func main() { 27 | err := godotenv.Load() 28 | if err != nil { 29 | fmt.Fprintln(os.Stderr, "error loading .env:", err) 30 | return 31 | } 32 | 33 | err = web.OpenClustersData() 34 | if err != nil { 35 | fmt.Fprintln(os.Stderr, "error opening clusters data:", err) 36 | return 37 | } 38 | 39 | err = projects.OpenProjectData() 40 | if err != nil { 41 | fmt.Fprintln(os.Stderr, "error opening projects data:", err) 42 | return 43 | } 44 | 45 | // TODO: go:embed maybe? 46 | err = projects.OpenXPData() 47 | if err != nil { 48 | fmt.Fprintln(os.Stderr, "error opening xp data:", err) 49 | return 50 | } 51 | 52 | db, err := database.OpenDB() 53 | if err != nil { 54 | fmt.Fprintln(os.Stderr, "error opening database:", err) 55 | return 56 | } 57 | phyDB, _ := db.DB() 58 | defer phyDB.Close() 59 | 60 | go web.Run(db) 61 | 62 | api.DefaultKeysManager, err = api.NewKeysManager(db) 63 | if err != nil { 64 | fmt.Fprintln(os.Stderr, "error creating a key manager:", err) 65 | return 66 | } 67 | keys, err := api.DefaultKeysManager.GetKeys() 68 | if err != nil { 69 | fmt.Fprintln(os.Stderr, "error getting API keys:", err) 70 | return 71 | } 72 | 73 | err = api.InitClients(keys) 74 | if err != nil { 75 | fmt.Fprintln(os.Stderr, "error initializing clients:", err) 76 | return 77 | } 78 | 79 | ctx := context.Background() 80 | errstream := make(chan error) 81 | 82 | err = setupCron(ctx, db, errstream) 83 | if err != nil { 84 | fmt.Fprintln(os.Stderr, "error setting up cron jobs:", err) 85 | return 86 | } 87 | 88 | reportErrors(errstream) 89 | } 90 | -------------------------------------------------------------------------------- /web/blackhole.go: -------------------------------------------------------------------------------- 1 | package web 2 | 3 | import ( 4 | "encoding/json" 5 | "net/http" 6 | "slices" 7 | "strconv" 8 | "time" 9 | 10 | "github.com/demostanis/42evaluators/internal/database" 11 | "github.com/demostanis/42evaluators/internal/models" 12 | "github.com/demostanis/42evaluators/web/templates" 13 | "gorm.io/gorm" 14 | ) 15 | 16 | type Blackhole struct { 17 | Login string `json:"login"` 18 | Date time.Time `json:"date"` 19 | Image string `json:"image"` 20 | } 21 | 22 | func handleBlackhole(db *gorm.DB) http.Handler { 23 | return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 24 | currentCampusID := getLoggedInUser(r).them.CampusID 25 | currentCampusIDRaw := r.URL.Query().Get("campus") 26 | if currentCampusIDRaw != "" { 27 | currentCampusID, _ = strconv.Atoi(currentCampusIDRaw) 28 | } 29 | 30 | var campuses []models.Campus 31 | err := db. 32 | Model(&models.Campus{}). 33 | Find(&campuses).Error 34 | if err != nil { 35 | internalServerError(w, err) 36 | return 37 | } 38 | 39 | _ = templates.Blackhole( 40 | campuses, 41 | currentCampusID, 42 | ).Render(r.Context(), w) 43 | }) 44 | } 45 | 46 | func blackholeMap(db *gorm.DB) http.Handler { 47 | return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 48 | result := make([]Blackhole, 0) 49 | 50 | campusID := r.URL.Query().Get("campus") 51 | if campusID == "" { 52 | campusID = strconv.Itoa(getLoggedInUser(r).them.CampusID) 53 | } 54 | var users []models.User 55 | err := db. 56 | Scopes(database.OnlyRealUsers()). 57 | Scopes(database.WithCampus(campusID)). 58 | Find(&users).Error 59 | if err != nil { 60 | internalServerError(w, err) 61 | return 62 | } 63 | 64 | for _, user := range users { 65 | if !user.BlackholedAt.IsZero() { 66 | if user.ImageLinkSmall == "" { 67 | user.ImageLinkSmall = models.DefaultImageLink 68 | } 69 | result = append(result, Blackhole{ 70 | user.Login, 71 | user.BlackholedAt, 72 | user.ImageLinkSmall, 73 | }) 74 | } 75 | } 76 | 77 | slices.SortFunc(result, func(a, b Blackhole) int { 78 | return a.Date.Compare(b.Date) 79 | }) 80 | 81 | _ = json.NewEncoder(w).Encode(result) 82 | }) 83 | } 84 | -------------------------------------------------------------------------------- /web/templates/links.templ: -------------------------------------------------------------------------------- 1 | package templates 2 | 3 | templ Section(name string) { 4 |

    5 | { name } 6 |

    7 | } 8 | 9 | type UsefulLinkData struct { 10 | Name string 11 | Description string 12 | Link string 13 | } 14 | 15 | templ UsefulLink(data UsefulLinkData) { 16 |
  • 17 | 18 | { data.Name } — { data.Description } 19 | 20 |
  • 21 | } 22 | 23 | templ Links() { 24 | @header() 25 | 26 |
    27 |

    Useful links

    28 | @Section("Official links") 29 | @UsefulLink(UsefulLinkData{ 30 | Name: "Github pack", 31 | Description: "Get the Github Student Pack for free", 32 | Link: "https://github-portal.42.fr", 33 | }) 34 | @UsefulLink(UsefulLinkData{ 35 | Name: "Discord server", 36 | Description: "Official 42 Paris Discord server", 37 | Link: "https://discord.com/invite/42", 38 | }) 39 | 40 | @Section("Tools") 41 | @UsefulLink(UsefulLinkData{ 42 | Name: "Friends42", 43 | Description: "Cluster maps with the ability of adding friends", 44 | Link: "https://friends42.fr", 45 | }) 46 | @UsefulLink(UsefulLinkData{ 47 | Name: "Codam's Peerfinder", 48 | Description: "Like 42evaluators' peer finder, but prettier", 49 | Link: "https://find-peers.codam.nl", 50 | }) 51 | @UsefulLink(UsefulLinkData{ 52 | Name: "s42", 53 | Description: "Formerly students42, 42evaluators-like website mostly under construction", 54 | Link: "https://s42.app", 55 | }) 56 | @UsefulLink(UsefulLinkData{ 57 | Name: "RNCP", 58 | Description: "Check your progress on RNCP certificates", 59 | Link: "https://rncp.hacku.org", 60 | }) 61 | 62 | @Section("Do not use") 63 | @UsefulLink(UsefulLinkData{ 64 | Name: "42Evals", 65 | Description: "Get evaluation scales for common core projects", 66 | Link: "https://42evals.com", 67 | }) 68 |
    69 |

    70 | To add a link to this list, open a pull request to the GitHub repository 71 |

    72 | } 73 | -------------------------------------------------------------------------------- /internal/users/coalition.go: -------------------------------------------------------------------------------- 1 | package users 2 | 3 | import ( 4 | "context" 5 | "errors" 6 | "fmt" 7 | "maps" 8 | "sync" 9 | 10 | "github.com/demostanis/42evaluators/internal/api" 11 | "github.com/demostanis/42evaluators/internal/models" 12 | "gorm.io/gorm" 13 | ) 14 | 15 | var ( 16 | ActiveCoalitions = map[string]string{ 17 | // Filters out old coalitions 18 | "range[this_year_score]": "1,999999999", 19 | } 20 | ) 21 | 22 | type CoalitionID struct { 23 | ID int `json:"coalition_id"` 24 | UserID int `json:"user_id"` 25 | } 26 | 27 | func getCoalition(coalitionID int, db *gorm.DB) (*models.Coalition, error) { 28 | var cachedCoalition models.Coalition 29 | err := db. 30 | Session(&gorm.Session{}). 31 | Model(&models.Coalition{}). 32 | Where("id = ?", coalitionID). 33 | First(&cachedCoalition).Error 34 | 35 | if errors.Is(err, gorm.ErrRecordNotFound) { 36 | actualCoalition, err := api.Do[models.Coalition]( 37 | api.NewRequest(fmt.Sprintf("/v2/coalitions/%d", coalitionID)). 38 | Authenticated()) 39 | if err != nil { 40 | return nil, err 41 | } 42 | 43 | db.Save(actualCoalition) 44 | return actualCoalition, nil 45 | } 46 | return &cachedCoalition, err 47 | } 48 | 49 | func GetCoalitions( 50 | ctx context.Context, 51 | db *gorm.DB, 52 | errstream chan error, 53 | wg *sync.WaitGroup, 54 | ) { 55 | wg.Add(1) 56 | 57 | coalitions, err := api.DoPaginated[[]CoalitionID]( 58 | api.NewRequest("/v2/coalitions_users"). 59 | Authenticated(). 60 | WithParams(maps.Clone(ActiveCoalitions))) 61 | if err != nil { 62 | errstream <- err 63 | return 64 | } 65 | 66 | for { 67 | coalition, err := (<-coalitions)() 68 | if err != nil { 69 | errstream <- fmt.Errorf("error while fetching coalitions: %w", err) 70 | continue 71 | } 72 | if coalition == nil { 73 | break 74 | } 75 | 76 | user := models.User{ID: coalition.UserID} 77 | err = user.CreateIfNeeded(db) 78 | if err != nil { 79 | errstream <- err 80 | continue 81 | } 82 | go func(coalitionID int) { 83 | actualCoalition, err := getCoalition(coalitionID, db) 84 | if err != nil { 85 | errstream <- err 86 | return 87 | } 88 | err = user.SetCoalition(*actualCoalition, db) 89 | if err != nil { 90 | errstream <- err 91 | } 92 | }(coalition.ID) 93 | } 94 | 95 | wg.Done() 96 | } 97 | -------------------------------------------------------------------------------- /web/index.go: -------------------------------------------------------------------------------- 1 | package web 2 | 3 | import ( 4 | "fmt" 5 | "net/http" 6 | "sync" 7 | 8 | "github.com/demostanis/42evaluators/internal/api" 9 | "github.com/demostanis/42evaluators/web/templates" 10 | "gorm.io/gorm" 11 | ) 12 | 13 | var mu sync.Mutex 14 | 15 | type LoggedInUser struct { 16 | accessToken string 17 | them *templates.Me 18 | } 19 | 20 | var loggedInUsers []LoggedInUser 21 | 22 | func getLoggedInUser(r *http.Request) *LoggedInUser { 23 | token, err := r.Cookie("token") 24 | if err != nil { 25 | return nil 26 | } 27 | 28 | for _, user := range loggedInUsers { 29 | if user.accessToken == token.Value { 30 | return &user 31 | } 32 | } 33 | return nil 34 | } 35 | 36 | func handleIndex(db *gorm.DB) http.Handler { 37 | return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 38 | apiKey := api.OauthAPIKey() 39 | if apiKey == nil { 40 | w.WriteHeader(http.StatusPreconditionRequired) 41 | _, _ = w.Write([]byte("The server is currently restarting, please wait a few seconds. If this issue persists, please report to @cgodard on Slack.")) 42 | return 43 | } 44 | 45 | code := r.URL.Query().Get("code") 46 | next := r.URL.Query().Get("next") 47 | if code != "" { 48 | accessToken, err := api.OauthToken(*apiKey, code, next) 49 | if err != nil { 50 | w.WriteHeader(http.StatusBadRequest) 51 | return 52 | } 53 | 54 | them, err := api.Do[templates.Me](api.NewRequest("/v2/me"). 55 | AuthenticatedAs(accessToken)) 56 | 57 | if err == nil { 58 | w.Header().Add("Set-Cookie", "token="+accessToken+"; HttpOnly") 59 | mu.Lock() 60 | loggedInUsers = append(loggedInUsers, LoggedInUser{ 61 | accessToken, 62 | them, 63 | }) 64 | mu.Unlock() 65 | } 66 | if next == "" { 67 | _ = templates. 68 | LoggedInIndex(them, err). 69 | Render(r.Context(), w) 70 | } else { 71 | http.Redirect(w, r, next, http.StatusSeeOther) 72 | } 73 | } else { 74 | user := getLoggedInUser(r) 75 | if user != nil { 76 | _ = templates. 77 | LoggedInIndex(user.them, nil). 78 | Render(r.Context(), w) 79 | } else { 80 | needsLogin := r.URL.Query().Get("needslogin") != "" 81 | _ = templates. 82 | LoggedOutIndex(apiKey.UID, fmt.Sprintf("%s?next=%s", 83 | apiKey.RedirectURI, next, 84 | ), needsLogin). 85 | Render(r.Context(), w) 86 | } 87 | } 88 | }) 89 | } 90 | -------------------------------------------------------------------------------- /internal/users/users.go: -------------------------------------------------------------------------------- 1 | package users 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "maps" 7 | "strconv" 8 | "strings" 9 | "sync" 10 | "time" 11 | 12 | "golang.org/x/sync/semaphore" 13 | 14 | "github.com/demostanis/42evaluators/internal/api" 15 | "github.com/demostanis/42evaluators/internal/campus" 16 | "github.com/demostanis/42evaluators/internal/models" 17 | "gorm.io/gorm" 18 | ) 19 | 20 | var ( 21 | DefaultParams = map[string]string{ 22 | "filter[cursus_id]": "21", 23 | } 24 | ConcurrentCampusesFetch = int64(5) 25 | ) 26 | 27 | var ( 28 | waitForUsers = make(chan bool) 29 | waitForUsersClosed = false 30 | ) 31 | 32 | func WaitForUsers() { 33 | if !waitForUsersClosed { 34 | <-waitForUsers 35 | } 36 | } 37 | 38 | func fetchOneCampus(ctx context.Context, campusID int, db *gorm.DB, errstream chan error) { 39 | params := maps.Clone(DefaultParams) 40 | params["filter[campus_id]"] = strconv.Itoa(campusID) 41 | 42 | users, err := api.DoPaginated[[]models.User]( 43 | api.NewRequest("/v2/cursus_users"). 44 | Authenticated(). 45 | WithParams(params)) 46 | if err != nil { 47 | errstream <- err 48 | return 49 | } 50 | 51 | var wg sync.WaitGroup 52 | for { 53 | user, err := (<-users)() 54 | if err != nil { 55 | errstream <- err 56 | continue 57 | } 58 | if user == nil { 59 | break 60 | } 61 | if strings.HasPrefix(user.Login, "3b3-") { 62 | continue 63 | } 64 | 65 | wg.Add(1) 66 | go func() { 67 | defer wg.Done() 68 | err = user.CreateIfNeeded(db) 69 | if err != nil { 70 | errstream <- err 71 | return 72 | } 73 | err = user.UpdateFields(db) 74 | if err != nil { 75 | errstream <- err 76 | return 77 | } 78 | err = user.SetCampus(campusID, db) 79 | if err != nil { 80 | errstream <- err 81 | } 82 | }() 83 | } 84 | wg.Wait() 85 | } 86 | 87 | func GetUsers(ctx context.Context, db *gorm.DB, errstream chan error) { 88 | campus.WaitForCampuses() 89 | var campuses []models.Campus 90 | db.Find(&campuses) 91 | 92 | var wg sync.WaitGroup 93 | go GetTests(ctx, db, errstream, &wg) 94 | go GetCoalitions(ctx, db, errstream, &wg) 95 | go GetTitles(ctx, db, errstream, &wg) 96 | go GetLogtimes(ctx, db, errstream, &wg) 97 | 98 | start := time.Now() 99 | weights := semaphore.NewWeighted(ConcurrentCampusesFetch) 100 | 101 | for _, campus := range campuses { 102 | err := weights.Acquire(ctx, 1) 103 | if err != nil { 104 | errstream <- err 105 | continue 106 | } 107 | wg.Add(1) 108 | 109 | go func(campusID int) { 110 | fetchOneCampus(ctx, campusID, db, errstream) 111 | weights.Release(1) 112 | wg.Done() 113 | }(campus.ID) 114 | } 115 | 116 | wg.Wait() 117 | fmt.Printf("took %.2f minutes to fetch all users\n", 118 | time.Since(start).Minutes()) 119 | 120 | if !waitForUsersClosed { 121 | close(waitForUsers) 122 | waitForUsersClosed = true 123 | } 124 | } 125 | -------------------------------------------------------------------------------- /web/templates/index.templ: -------------------------------------------------------------------------------- 1 | package templates 2 | 3 | import ( 4 | "fmt" 5 | "net/url" 6 | "encoding/json" 7 | ) 8 | 9 | type MeRaw struct { 10 | ID int `json:"id"` 11 | DisplayName string `json:"usual_full_name"` 12 | Campuses []struct { 13 | ID int `json:"campus_id"` 14 | IsPrimary bool `json:"is_primary"` 15 | } `json:"campus_users"` 16 | } 17 | 18 | type Me struct { 19 | ID int 20 | DisplayName string 21 | CampusID int 22 | } 23 | 24 | func (me *Me) UnmarshalJSON(data []byte) error { 25 | var meRaw MeRaw 26 | 27 | err := json.Unmarshal(data, &meRaw) 28 | if err != nil { 29 | return err 30 | } 31 | 32 | me.ID = meRaw.ID 33 | me.DisplayName = meRaw.DisplayName 34 | 35 | for _, campus := range meRaw.Campuses { 36 | if campus.IsPrimary { 37 | me.CampusID = campus.ID 38 | break 39 | } 40 | } 41 | return nil 42 | } 43 | 44 | func getOauthURL(clientID string, redirectURI string) templ.SafeURL { 45 | return templ.SafeURL(fmt.Sprintf( 46 | "https://api.intra.42.fr/oauth/authorize?client_id=%s&redirect_uri=%s&scope=public&response_type=code", 47 | clientID, url.QueryEscape(redirectURI), 48 | )) 49 | } 50 | 51 | script removeUselessParams() { 52 | const location = new URL(window.location.href); 53 | location.searchParams.delete("code"); 54 | location.searchParams.delete("needslogin"); 55 | history.replaceState(null, "", location.href); 56 | } 57 | 58 | templ LoggedOutIndex(clientID string, redirectURI string, needsLogin bool) { 59 | @header() 60 | if needsLogin { 61 | 62 |
    63 | You need to be logged-in to access this page. 64 |
    65 | } 66 |
    67 | 68 | 69 | Login with 70 | 71 | 72 |
    73 | @removeUselessParams() 74 | } 75 | 76 | templ LoggedInIndex(them *Me, err error) { 77 | @header() 78 |
    79 | if err != nil { 80 | An error occurred: { fmt.Sprintf("%v", err) } 81 | } else { 82 | Welcome back, { them.DisplayName }! 83 | //
    84 | //
    85 | //

    Changelog

    86 | //
      87 | //
    • 42evaluators has been fully rewritten in Go by students from Le Havre. It is also now open source on GitHub!
    • 88 | //
    89 | //
    90 | //
    91 | } 92 |
    93 | @removeUselessParams() 94 | @footer() 95 | } 96 | -------------------------------------------------------------------------------- /internal/projects/projects.go: -------------------------------------------------------------------------------- 1 | package projects 2 | 3 | import ( 4 | "context" 5 | "encoding/json" 6 | "fmt" 7 | "io" 8 | "math" 9 | "os" 10 | "time" 11 | 12 | "github.com/demostanis/42evaluators/internal/api" 13 | "github.com/demostanis/42evaluators/internal/models" 14 | "gorm.io/gorm" 15 | ) 16 | 17 | var cursus21Begin, _ = time.Parse(time.RFC3339, "2019-07-29T08:45:17.896Z") 18 | 19 | const maxConcurrentFetches = 100 20 | 21 | type ProjectData struct { 22 | X float64 `json:"x"` 23 | Y float64 `json:"y"` 24 | ProjectID int `json:"project_id"` 25 | Difficulty int `json:"difficulty"` 26 | } 27 | 28 | var allProjectData []ProjectData 29 | 30 | func OpenProjectData() error { 31 | file, err := os.Open("assets/project_data.json") 32 | if err != nil { 33 | return err 34 | } 35 | bytes, err := io.ReadAll(file) 36 | if err != nil { 37 | return err 38 | } 39 | err = json.Unmarshal(bytes, &allProjectData) 40 | if err != nil { 41 | return err 42 | } 43 | return nil 44 | } 45 | 46 | func setPositionInGraph( 47 | db *gorm.DB, 48 | subject *models.Subject, 49 | ) { 50 | libft := struct { 51 | X float64 52 | Y float64 53 | }{2999., 2999.} 54 | 55 | subject.Position = 99999 56 | for _, projectData := range allProjectData { 57 | if projectData.ProjectID == subject.ID { 58 | subject.XP = projectData.Difficulty 59 | subject.Position = 60 | int(math.Hypot( 61 | projectData.X-libft.X, 62 | projectData.Y-libft.Y)) 63 | break 64 | } 65 | } 66 | } 67 | 68 | func prepareProjectForDB(db *gorm.DB, project *models.Project) { 69 | project.SubjectID = project.Subject.ID 70 | 71 | for i := range project.Teams { 72 | team := &project.Teams[i] 73 | team.ProjectID = i 74 | 75 | if team.ID == project.CurrentTeamID { 76 | project.ActiveTeam = i 77 | } 78 | 79 | for j := range team.Users { 80 | user := &team.Users[j] 81 | user.TeamID = team.ID 82 | } 83 | } 84 | 85 | setPositionInGraph(db, &project.Subject) 86 | } 87 | 88 | func GetProjects(ctx context.Context, db *gorm.DB, errstream chan error) { 89 | projects, err := api.DoPaginated[[]models.Project]( 90 | api.NewRequest("/v2/projects_users"). 91 | WithMaxConcurrentFetches(maxConcurrentFetches). 92 | SinceLastFetch(db, cursus21Begin). 93 | Authenticated()) 94 | if err != nil { 95 | errstream <- err 96 | } 97 | 98 | start := time.Now() 99 | 100 | for { 101 | project, err := (<-projects)() 102 | if err != nil { 103 | errstream <- err 104 | continue 105 | } 106 | if project == nil { 107 | break 108 | } 109 | 110 | if len(project.CursusIDs) > 0 && project.CursusIDs[0] == 21 && 111 | len(project.Teams) > 0 && len(project.Teams[0].Users) > 0 { 112 | prepareProjectForDB(db, project) 113 | err = db. 114 | Session(&gorm.Session{FullSaveAssociations: true}). 115 | Save(&project).Error 116 | if err != nil { 117 | errstream <- err 118 | } 119 | } 120 | } 121 | 122 | fmt.Printf("took %.2f minutes to fetch all projects\n", 123 | time.Since(start).Minutes()) 124 | } 125 | -------------------------------------------------------------------------------- /cmd/jobs.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "context" 5 | "os" 6 | "strings" 7 | "time" 8 | 9 | "github.com/demostanis/42evaluators/internal/campus" 10 | "github.com/demostanis/42evaluators/internal/clusters" 11 | "github.com/demostanis/42evaluators/internal/projects" 12 | "github.com/demostanis/42evaluators/internal/users" 13 | "github.com/go-co-op/gocron/v2" 14 | "gorm.io/gorm" 15 | ) 16 | 17 | func getDisabledJobs() (bool, bool, bool, bool) { 18 | if os.Getenv("disabledjobs") == "*" { 19 | return true, true, true, true 20 | } 21 | 22 | disabledJobs := strings.Split(os.Getenv("disabledjobs"), ",") 23 | disableCampusesJob := false 24 | disableUsersJob := false 25 | disableLocationsJob := false 26 | disableProjectsJob := false 27 | 28 | for _, job := range disabledJobs { 29 | if job == "campuses" { 30 | disableCampusesJob = true 31 | } 32 | if job == "users" { 33 | disableUsersJob = true 34 | } 35 | if job == "locations" { 36 | disableLocationsJob = true 37 | } 38 | if job == "projects" { 39 | disableProjectsJob = true 40 | } 41 | } 42 | 43 | return disableCampusesJob, 44 | disableUsersJob, 45 | disableLocationsJob, 46 | disableProjectsJob 47 | } 48 | 49 | func setupCron(ctx context.Context, db *gorm.DB, errstream chan error) error { 50 | var job1, job2, job3, job4 gocron.Job 51 | disableCampusesJob, 52 | disableUsersJob, 53 | disableLocationsJob, 54 | disableProjectsJob := getDisabledJobs() 55 | 56 | s, err := gocron.NewScheduler() 57 | if err != nil { 58 | return err 59 | } 60 | if !disableCampusesJob { 61 | job1, err = s.NewJob( 62 | gocron.DailyJob(1, gocron.NewAtTimes( 63 | gocron.NewAtTime(0, 0, 0))), 64 | gocron.NewTask( 65 | campus.GetCampuses, 66 | db, errstream, 67 | ), 68 | ) 69 | if err != nil { 70 | return err 71 | } 72 | } 73 | if !disableUsersJob { 74 | job2, err = s.NewJob( 75 | gocron.DurationJob(time.Hour*2), 76 | gocron.NewTask( 77 | users.GetUsers, 78 | ctx, db, errstream, 79 | ), 80 | ) 81 | if err != nil { 82 | return err 83 | } 84 | } 85 | if !disableLocationsJob { 86 | lastFetch := time.Time{} 87 | job3, err = s.NewJob( 88 | gocron.DurationJob(time.Minute*1), 89 | gocron.NewTask( 90 | func(ctx context.Context, db *gorm.DB, errstream chan error) { 91 | if !lastFetch.IsZero() && !clusters.FirstFetchDone { 92 | return 93 | } 94 | 95 | previousLastFetch := lastFetch 96 | lastFetch = time.Now().UTC() 97 | clusters.GetLocations(previousLastFetch, ctx, db, errstream) 98 | }, 99 | ctx, db, errstream, 100 | ), 101 | ) 102 | if err != nil { 103 | return err 104 | } 105 | } 106 | if !disableProjectsJob { 107 | job4, err = s.NewJob( 108 | gocron.DurationJob(time.Hour*4), 109 | gocron.NewTask( 110 | projects.GetProjects, 111 | ctx, db, errstream, 112 | ), 113 | ) 114 | if err != nil { 115 | return err 116 | } 117 | } 118 | s.Start() 119 | if !disableCampusesJob { 120 | _ = job1.RunNow() 121 | } 122 | if !disableUsersJob { 123 | _ = job2.RunNow() 124 | } 125 | if !disableLocationsJob { 126 | _ = job3.RunNow() 127 | } 128 | if !disableProjectsJob { 129 | _ = job4.RunNow() 130 | } 131 | return nil 132 | } 133 | -------------------------------------------------------------------------------- /internal/api/ratelimit.go: -------------------------------------------------------------------------------- 1 | package api 2 | 3 | import ( 4 | "context" 5 | "net/http" 6 | "sync" 7 | "time" 8 | 9 | "github.com/demostanis/42evaluators/internal/models" 10 | "golang.org/x/time/rate" 11 | ) 12 | 13 | const ( 14 | RequestsPerSecond = 2 15 | RequestsPerHour = 1200 16 | SleepBetweenTries = 100 * time.Millisecond 17 | ) 18 | 19 | var id = 0 20 | 21 | func newTarget(urls []string, percent float32) Target { 22 | id++ 23 | return Target{ 24 | urls, 25 | percent, 26 | id, 27 | } 28 | } 29 | 30 | var ( 31 | oauthTarget = newTarget( 32 | []string{ 33 | "/oauth/client", 34 | }, 35 | 1./30., 36 | ) 37 | targets = []Target{ 38 | oauthTarget, 39 | newTarget( 40 | []string{ 41 | "/v2/campus", 42 | "/v2/cursus_users", 43 | "/v2/groups_users", 44 | "/v2/coalitions_users", 45 | "/v2/coalitions", 46 | "/v2/titles_users", 47 | "/v2/titles", 48 | }, 49 | 3./5., 50 | ), 51 | newTarget( 52 | []string{ 53 | "/v2/locations", 54 | }, 55 | 1./5., 56 | ), 57 | newTarget( 58 | []string{ 59 | "/v2/projects_users", 60 | }, 61 | 1./6., 62 | ), 63 | } 64 | ) 65 | 66 | var mu sync.Mutex 67 | 68 | type RLHTTPClient struct { 69 | sync.Mutex 70 | client *http.Client 71 | secondlyRateLimiter *rate.Limiter 72 | hourlyRateLimiter *rate.Limiter 73 | isRateLimited bool 74 | accessToken string 75 | apiKey models.APIKey 76 | } 77 | 78 | func (c *RLHTTPClient) setIsRateLimited(isRateLimited bool) { 79 | c.Lock() 80 | c.isRateLimited = isRateLimited 81 | c.Unlock() 82 | } 83 | 84 | func (c *RLHTTPClient) getIsRateLimited() bool { 85 | c.Lock() 86 | defer c.Unlock() 87 | return c.isRateLimited 88 | } 89 | 90 | func RateLimitedClient(accessToken string, apiKey models.APIKey) *RLHTTPClient { 91 | return &RLHTTPClient{ 92 | client: http.DefaultClient, 93 | secondlyRateLimiter: rate.NewLimiter( 94 | rate.Every(1*time.Second), RequestsPerSecond), 95 | hourlyRateLimiter: rate.NewLimiter( 96 | rate.Every(1*time.Hour), RequestsPerHour), 97 | isRateLimited: false, 98 | accessToken: accessToken, 99 | apiKey: apiKey, 100 | } 101 | } 102 | 103 | func (c *RLHTTPClient) Do(req *http.Request) (*http.Response, error) { 104 | err := c.secondlyRateLimiter.Wait(context.Background()) 105 | if err != nil { 106 | return nil, err 107 | } 108 | err = c.hourlyRateLimiter.Wait(context.Background()) 109 | if err != nil { 110 | return nil, err 111 | } 112 | 113 | defer c.setIsRateLimited(false) 114 | return c.client.Do(req) 115 | } 116 | 117 | func findNonRateLimitedClientFor(target Target) *RLHTTPClient { 118 | mu.Lock() 119 | for _, potentialClient := range clients[target.ID] { 120 | if !potentialClient.getIsRateLimited() { 121 | potentialClient.setIsRateLimited(true) 122 | mu.Unlock() 123 | return potentialClient 124 | } 125 | } 126 | mu.Unlock() 127 | 128 | time.Sleep(SleepBetweenTries) 129 | return findNonRateLimitedClientFor(target) 130 | } 131 | 132 | func OauthAPIKey() *models.APIKey { 133 | oauthClients := clients[oauthTarget.ID] 134 | if len(oauthClients) < 1 { 135 | time.Sleep(SleepBetweenTries) 136 | return OauthAPIKey() 137 | } 138 | return &oauthClients[0].apiKey 139 | } 140 | -------------------------------------------------------------------------------- /web/static/clusters.js: -------------------------------------------------------------------------------- 1 | let currentPopup; 2 | const handleNewImages = () => { 3 | document.querySelectorAll("image").forEach(image => { 4 | image.addEventListener("mouseover", () => { 5 | if (!image.href.baseVal) return; 6 | if (currentPopup 7 | && !currentPopup.hovered) currentPopup.remove(); 8 | 9 | const login = clusterMap[image.id]; 10 | const bbox = image.getBoundingClientRect(); 11 | const popup = document.createElement("card"); 12 | popup.classList.add("card", "card-side", 13 | "h-32", "bg-base-300", "shadow-2xl"); 14 | 15 | const popupImage = document.createElement("figure"); 16 | popupImage.classList.add("w-full", "h-full"); 17 | const popupImageImage = document.createElement("img"); 18 | popupImageImage.classList.add("w-full", "h-full"); 19 | popupImageImage.src = image.href.baseVal; 20 | popupImage.appendChild(popupImageImage); 21 | 22 | const popupTitle = document.createElement("h2"); 23 | const popupTitleTitle = document.createElement("a"); 24 | popupTitle.classList.add("popup-title", "text-center"); 25 | popupTitleTitle.href = "https://profile.intra.42.fr/users/" + login; 26 | popupTitleTitle.textContent = login; 27 | popupTitle.appendChild(popupTitleTitle); 28 | 29 | const popupBodyBody = document.createElement("p"); 30 | popupBodyBody.classList.add("text-center"); 31 | popupBodyBody.textContent = image.id; 32 | const popupBody = document.createElement("div"); 33 | popupBody.classList.add("popup-body", "m-auto", "p-5"); 34 | popupBody.appendChild(popupTitle); 35 | popupBody.appendChild(popupBodyBody); 36 | 37 | popup.appendChild(popupImage); 38 | popup.appendChild(popupBody); 39 | 40 | document.body.appendChild(popup); 41 | currentPopup = popup; 42 | 43 | let id = setInterval(() => { 44 | if (popup.getBoundingClientRect().height != 0) { 45 | clearInterval(id); 46 | popup.style.position = "absolute"; 47 | popup.style.left = bbox.x + bbox.width + "px"; 48 | popup.style.top = bbox.y 49 | - (popup.getBoundingClientRect().height 50 | - bbox.height) / 2 + "px"; 51 | } 52 | }); 53 | }); 54 | document.body.addEventListener("mouseover", event => { 55 | if (currentPopup 56 | && ["svg", "card"].indexOf(event.target.nodeName) >= 0) 57 | currentPopup.remove(); 58 | }); 59 | }); 60 | } 61 | 62 | const secure = window.location.protocol == "https:"; 63 | const ws = new WebSocket((secure ? "wss://" : "ws://") 64 | + window.location.host 65 | + window.location.pathname.substr(0, window.location.pathname.length-1) // remove trailing / 66 | + ".live"); 67 | 68 | ws.onopen = () => { 69 | ws.send(JSON.stringify({ 70 | cluster: parseInt(new URLSearchParams(window.location.search).get("cluster")), 71 | })); 72 | handleNewImages(); 73 | } 74 | 75 | ws.onerror = () => { 76 | // I guess we should do something. We don't handle 77 | // network errors in the blackhole map either (yet). 78 | // The error handling of this app sucks. 79 | } 80 | 81 | const clusterMap = {}; 82 | ws.onmessage = message => { 83 | const data = JSON.parse(message.data); 84 | // We cannot use getElementById since we need the second element 85 | // and we cannot use querySelector since IDs might start with a number 86 | // (I hate those SVGs) 87 | const elem = document.all[data.host]?.[1]; 88 | if (elem) { 89 | clusterMap[data.host] = data.login; 90 | 91 | if (data.left) { 92 | elem.setAttribute("href", ""); 93 | elem.style.display = "none"; 94 | elem.style.display = "block"; 95 | } 96 | else 97 | elem.setAttribute("href", data.image); 98 | } 99 | } 100 | -------------------------------------------------------------------------------- /internal/users/logtime.go: -------------------------------------------------------------------------------- 1 | package users 2 | 3 | import ( 4 | "context" 5 | "encoding/json" 6 | "fmt" 7 | "slices" 8 | "sync" 9 | "time" 10 | 11 | "github.com/demostanis/42evaluators/internal/api" 12 | "github.com/demostanis/42evaluators/internal/models" 13 | "gorm.io/gorm" 14 | ) 15 | 16 | type LogtimeRaw struct { 17 | EndAt string `json:"end_at"` 18 | BeginAt string `json:"begin_at"` 19 | User struct { 20 | ID int `json:"id"` 21 | } `json:"user"` 22 | } 23 | 24 | type Logtime struct { 25 | EndAt time.Time 26 | BeginAt time.Time 27 | UserID int 28 | } 29 | 30 | func (logtime *Logtime) UnmarshalJSON(data []byte) error { 31 | var logtimeRaw LogtimeRaw 32 | 33 | err := json.Unmarshal(data, &logtimeRaw) 34 | if err != nil { 35 | return err 36 | } 37 | 38 | endAt, err := time.Parse(time.RFC3339, logtimeRaw.EndAt) 39 | if err != nil { 40 | endAt = time.Now() 41 | } 42 | beginAt, _ := time.Parse(time.RFC3339, logtimeRaw.BeginAt) 43 | 44 | logtime.EndAt = endAt 45 | logtime.BeginAt = beginAt 46 | logtime.UserID = logtimeRaw.User.ID 47 | return nil 48 | } 49 | 50 | func calcWeeklyLogtime(logtime []Logtime) time.Duration { 51 | // Some people in some campuses sometimes are 52 | // in multiples locations at once, so don't count 53 | // their logtimes twice... 54 | slices.SortFunc(logtime, func(a, b Logtime) int { 55 | return int(a.BeginAt.Unix()+a.EndAt.Unix()) - 56 | int(b.BeginAt.Unix()+b.EndAt.Unix()) 57 | }) 58 | var previousLocation *Logtime 59 | for i := range logtime { 60 | location := &(logtime)[i] 61 | if previousLocation == nil { 62 | previousLocation = location 63 | continue 64 | } 65 | if location.BeginAt.Unix() < previousLocation.EndAt.Unix() { 66 | location.BeginAt = previousLocation.EndAt 67 | } 68 | previousLocation = location 69 | } 70 | 71 | var total time.Duration 72 | for _, location := range logtime { 73 | total += location.EndAt.Sub(location.BeginAt) 74 | } 75 | total = total.Truncate(time.Minute) 76 | return total 77 | } 78 | 79 | func GetLogtimes( 80 | ctx context.Context, 81 | db *gorm.DB, 82 | errstream chan error, 83 | wg *sync.WaitGroup, 84 | ) { 85 | wg.Add(1) 86 | 87 | day := time.Hour * 24 88 | currentDay := int(time.Now().UTC().Weekday() - 1) 89 | daysSinceMonday := time.Duration(currentDay) * day 90 | monday := time.Now().UTC().Add(-daysSinceMonday).Truncate(day) 91 | sunday := monday.Add(day * 7) 92 | 93 | params := make(map[string]string) 94 | params["range[begin_at]"] = fmt.Sprintf("%s,%s", 95 | monday.Format(time.RFC3339), 96 | sunday.Format(time.RFC3339)) 97 | 98 | logtimes, err := api.DoPaginated[[]Logtime]( 99 | api.NewRequest("/v2/locations"). 100 | Authenticated(). 101 | WithParams(params)) 102 | if err != nil { 103 | errstream <- err 104 | return 105 | } 106 | 107 | totalWeeklyLogtimes := make(map[int][]Logtime) 108 | 109 | for { 110 | logtime, err := (<-logtimes)() 111 | if err != nil { 112 | errstream <- fmt.Errorf("error while fetching locations: %w", err) 113 | continue 114 | } 115 | if logtime == nil { 116 | break 117 | } 118 | 119 | totalWeeklyLogtimes[logtime.UserID] = append( 120 | totalWeeklyLogtimes[logtime.UserID], *logtime) 121 | } 122 | 123 | for userID, logtime := range totalWeeklyLogtimes { 124 | weeklyLogtime := calcWeeklyLogtime(logtime) 125 | 126 | user := models.User{ID: userID} 127 | err = user.CreateIfNeeded(db) 128 | if err != nil { 129 | errstream <- err 130 | continue 131 | } 132 | err = user.SetWeeklyLogtime(weeklyLogtime, db) 133 | if err != nil { 134 | errstream <- err 135 | } 136 | } 137 | 138 | wg.Done() 139 | } 140 | -------------------------------------------------------------------------------- /web/peerfinder.go: -------------------------------------------------------------------------------- 1 | package web 2 | 3 | import ( 4 | "fmt" 5 | "net/http" 6 | "strconv" 7 | "strings" 8 | 9 | "github.com/demostanis/42evaluators/internal/database" 10 | "github.com/demostanis/42evaluators/internal/models" 11 | "github.com/demostanis/42evaluators/web/templates" 12 | "gorm.io/gorm" 13 | ) 14 | 15 | const ( 16 | usersPerQuery = 10000 17 | ) 18 | 19 | func isValidProject(project models.Project) bool { 20 | // we need this bunch of conditions since GORM will give us 21 | // zeroed projects which don't meet the preload condition... 22 | return len(project.Teams) > 0 && 23 | len(project.Teams) > project.ActiveTeam && 24 | len(project.Teams[project.ActiveTeam].Users) > 0 && 25 | project.Teams[project.ActiveTeam].Users[0].User.ID != 0 26 | } 27 | 28 | func handlePeerFinder(db *gorm.DB) http.Handler { 29 | return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 30 | var subjects []models.Subject 31 | 32 | err := db. 33 | Model(&models.Subject{}). 34 | Order("position, name"). 35 | Where(database.UnwantedSubjectsCondition). 36 | Find(&subjects).Error 37 | if err != nil { 38 | internalServerError(w, err) 39 | return 40 | } 41 | 42 | campuses, err := getAllCampuses(db) 43 | if err != nil { 44 | internalServerError(w, err) 45 | return 46 | } 47 | 48 | var campusID int 49 | campusIDRaw := r.URL.Query().Get("campus") 50 | if campusIDRaw == "any" { 51 | campusID = -1 52 | } else { 53 | campusID, err = strconv.Atoi(campusIDRaw) 54 | if err != nil { 55 | campusID = getLoggedInUser(r).them.CampusID 56 | } 57 | } 58 | 59 | status := r.URL.Query().Get("status") 60 | if status == "" { 61 | status = "active" 62 | } 63 | isValidStatus := false 64 | for _, possibleStatus := range []string{ 65 | "active", "finished", "waiting_for_correction", 66 | "creating_group", "in_progress", 67 | } { 68 | if status == possibleStatus { 69 | isValidStatus = true 70 | } 71 | } 72 | if !isValidStatus { 73 | status = "active" 74 | } 75 | 76 | var wantedSubjects []string 77 | wantedSubjectsRaw := r.URL.Query().Get("subjects") 78 | if wantedSubjectsRaw != "" { 79 | wantedSubjects = strings.Split(wantedSubjectsRaw, ",") 80 | } else { 81 | for _, subject := range subjects { 82 | wantedSubjects = append(wantedSubjects, subject.Name) 83 | } 84 | } 85 | 86 | checkedSubjects := make(map[string]bool) 87 | for _, subject := range wantedSubjects { 88 | checkedSubjects[subject] = true 89 | } 90 | 91 | withStatus := func(search string) func(db *gorm.DB) *gorm.DB { 92 | return func(db *gorm.DB) *gorm.DB { 93 | if status == "active" { 94 | return db. 95 | Where("status != 'finished'") 96 | } 97 | return db. 98 | Where("status = ?", status) 99 | } 100 | } 101 | 102 | projectsMap := make(map[int][]models.Project) 103 | 104 | preloadCondition := "" 105 | if campusID != -1 { 106 | preloadCondition += fmt.Sprintf("campus_id = %d AND ", campusID) 107 | } 108 | preloadCondition += database.OnlyRealUsersCondition 109 | 110 | var projects []models.Project 111 | db. 112 | Preload("Teams.Users.User", 113 | preloadCondition). 114 | Preload("Subject", "name IN ? AND "+database.UnwantedSubjectsCondition, 115 | wantedSubjects). 116 | Scopes(withStatus(status)). 117 | Model(&models.Project{}). 118 | // We need to fetch by batches of users, else GORM 119 | // generates way too large queries... 120 | FindInBatches(&projects, usersPerQuery, 121 | func(db *gorm.DB, batch int) error { 122 | for _, project := range projects { 123 | if isValidProject(project) { 124 | projectsMap[project.Subject.ID] = append( 125 | projectsMap[project.Subject.ID], project) 126 | } 127 | } 128 | return nil 129 | }) 130 | 131 | _ = templates.PeerFinder( 132 | subjects, projectsMap, checkedSubjects, 133 | status, campuses, campusID, 134 | ).Render(r.Context(), w) 135 | }) 136 | } 137 | -------------------------------------------------------------------------------- /internal/clusters/clusters.go: -------------------------------------------------------------------------------- 1 | package clusters 2 | 3 | import ( 4 | "context" 5 | "errors" 6 | "fmt" 7 | "sync" 8 | "time" 9 | 10 | "github.com/demostanis/42evaluators/internal/api" 11 | "github.com/demostanis/42evaluators/internal/models" 12 | "gorm.io/gorm" 13 | ) 14 | 15 | const ( 16 | ConcurrentLocationsFetch = 40 17 | ) 18 | 19 | var ( 20 | LocationChannel = make(chan models.Location) 21 | FirstFetchDone = false 22 | ) 23 | 24 | type Location struct { 25 | ID int `json:"id"` 26 | Host string `json:"host"` 27 | CampusID int `json:"campus_id"` 28 | User struct { 29 | ID int `json:"id"` 30 | Login string `json:"login"` 31 | Image struct { 32 | Versions struct { 33 | Small string `json:"small"` 34 | } `json:"versions"` 35 | } `json:"image"` 36 | } `json:"user"` 37 | EndAt string `json:"end_at"` 38 | } 39 | 40 | type Cluster struct { 41 | ID int `json:"id"` 42 | Name string `json:"name"` 43 | Image string `json:"cdn_link"` 44 | Campus struct { 45 | ID int `json:"id"` 46 | Name string `json:"name"` 47 | } `json:"campus"` 48 | Svg string 49 | DisplayName string 50 | } 51 | 52 | func getParams(lastFetch time.Time, field string) map[string]string { 53 | params := make(map[string]string) 54 | if !lastFetch.IsZero() { 55 | r := lastFetch.Format(time.RFC3339) + "," + 56 | time.Now().UTC().Format(time.RFC3339) 57 | params[fmt.Sprintf("range[%s]", field)] = r 58 | } else { 59 | params["filter[active]"] = "true" 60 | } 61 | return params 62 | } 63 | 64 | var mu sync.Mutex 65 | 66 | func UpdateLocationInDB(location models.Location, db *gorm.DB) error { 67 | mu.Lock() 68 | defer mu.Unlock() 69 | 70 | var newLocation models.Location 71 | err := db. 72 | Session(&gorm.Session{}). 73 | Where("id = ?", location.ID). 74 | First(&newLocation).Error 75 | 76 | if errors.Is(err, gorm.ErrRecordNotFound) { 77 | // Sometimes the location gets created at the same 78 | // time by another goroutine, so ignore the error 79 | db.Create(&location) 80 | } 81 | if location.EndAt != "" { 82 | return db.Delete(&location).Error 83 | } 84 | return db. 85 | Model(&newLocation). 86 | // I wonder why I need to specify this... 87 | Where("id = ?", location.ID). 88 | Updates(map[string]any{ 89 | "ID": location.ID, 90 | "UserID": location.UserID, 91 | "Login": location.Login, 92 | "Host": location.Host, 93 | "CampusID": location.CampusID, 94 | "EndAt": location.EndAt, 95 | "Image": location.Image, 96 | }).Error 97 | } 98 | 99 | func getLocationsForField( 100 | lastFetch time.Time, 101 | field string, 102 | ctx context.Context, 103 | db *gorm.DB, 104 | errstream chan error, 105 | ) { 106 | locations, err := api.DoPaginated[[]Location]( 107 | api.NewRequest("/v2/locations"). 108 | Authenticated(). 109 | WithParams(getParams(lastFetch, field))) 110 | if err != nil { 111 | errstream <- err 112 | return 113 | } 114 | 115 | for { 116 | location, err := (<-locations)() 117 | if err != nil { 118 | errstream <- err 119 | continue 120 | } 121 | if location == nil { 122 | break 123 | } 124 | dbLocation := models.Location{ 125 | ID: location.ID, 126 | UserID: location.User.ID, 127 | Login: location.User.Login, 128 | Host: location.Host, 129 | CampusID: location.CampusID, 130 | Image: location.User.Image.Versions.Small, 131 | EndAt: location.EndAt, 132 | } 133 | err = UpdateLocationInDB(dbLocation, db) 134 | if err != nil { 135 | errstream <- err 136 | continue 137 | } 138 | if !lastFetch.IsZero() { 139 | LocationChannel <- dbLocation 140 | } 141 | } 142 | } 143 | 144 | func GetLocations( 145 | lastFetch time.Time, 146 | ctx context.Context, 147 | db *gorm.DB, 148 | errstream chan error, 149 | ) { 150 | if lastFetch.IsZero() { 151 | // Makes everything easier 152 | db.Exec("DELETE FROM locations") 153 | } 154 | // Don't do them in parallel, we need end_at to have 155 | // more importance than begin_at 156 | getLocationsForField(lastFetch, "begin_at", ctx, db, errstream) 157 | if !lastFetch.IsZero() { 158 | getLocationsForField(lastFetch, "end_at", ctx, db, errstream) 159 | } 160 | FirstFetchDone = true 161 | } 162 | -------------------------------------------------------------------------------- /devenv.lock: -------------------------------------------------------------------------------- 1 | { 2 | "nodes": { 3 | "devenv": { 4 | "locked": { 5 | "dir": "src/modules", 6 | "lastModified": 1713442308, 7 | "owner": "cachix", 8 | "repo": "devenv", 9 | "rev": "3e601bedf5455f4d8eba783afbcac93c333e04de", 10 | "treeHash": "74d5a15471bf00a4a45790f5b3a5d81f77b0d418", 11 | "type": "github" 12 | }, 13 | "original": { 14 | "dir": "src/modules", 15 | "owner": "cachix", 16 | "repo": "devenv", 17 | "type": "github" 18 | } 19 | }, 20 | "flake-compat": { 21 | "flake": false, 22 | "locked": { 23 | "lastModified": 1696426674, 24 | "owner": "edolstra", 25 | "repo": "flake-compat", 26 | "rev": "0f9255e01c2351cc7d116c072cb317785dd33b33", 27 | "treeHash": "2addb7b71a20a25ea74feeaf5c2f6a6b30898ecb", 28 | "type": "github" 29 | }, 30 | "original": { 31 | "owner": "edolstra", 32 | "repo": "flake-compat", 33 | "type": "github" 34 | } 35 | }, 36 | "flake-utils": { 37 | "inputs": { 38 | "systems": "systems" 39 | }, 40 | "locked": { 41 | "lastModified": 1710146030, 42 | "owner": "numtide", 43 | "repo": "flake-utils", 44 | "rev": "b1d9ab70662946ef0850d488da1c9019f3a9752a", 45 | "treeHash": "bd263f021e345cb4a39d80c126ab650bebc3c10c", 46 | "type": "github" 47 | }, 48 | "original": { 49 | "owner": "numtide", 50 | "repo": "flake-utils", 51 | "type": "github" 52 | } 53 | }, 54 | "gitignore": { 55 | "inputs": { 56 | "nixpkgs": [ 57 | "pre-commit-hooks", 58 | "nixpkgs" 59 | ] 60 | }, 61 | "locked": { 62 | "lastModified": 1709087332, 63 | "owner": "hercules-ci", 64 | "repo": "gitignore.nix", 65 | "rev": "637db329424fd7e46cf4185293b9cc8c88c95394", 66 | "treeHash": "ca14199cabdfe1a06a7b1654c76ed49100a689f9", 67 | "type": "github" 68 | }, 69 | "original": { 70 | "owner": "hercules-ci", 71 | "repo": "gitignore.nix", 72 | "type": "github" 73 | } 74 | }, 75 | "nixpkgs": { 76 | "locked": { 77 | "lastModified": 1713361204, 78 | "owner": "cachix", 79 | "repo": "devenv-nixpkgs", 80 | "rev": "285676e87ad9f0ca23d8714a6ab61e7e027020c6", 81 | "treeHash": "50354b35a3e0277d4a83a0a88fa0b0866b5f392f", 82 | "type": "github" 83 | }, 84 | "original": { 85 | "owner": "cachix", 86 | "ref": "rolling", 87 | "repo": "devenv-nixpkgs", 88 | "type": "github" 89 | } 90 | }, 91 | "nixpkgs-stable": { 92 | "locked": { 93 | "lastModified": 1713344939, 94 | "owner": "NixOS", 95 | "repo": "nixpkgs", 96 | "rev": "e402c3eb6d88384ca6c52ef1c53e61bdc9b84ddd", 97 | "treeHash": "4e2828da841f6c45445424643a7c2057ca9e4e45", 98 | "type": "github" 99 | }, 100 | "original": { 101 | "owner": "NixOS", 102 | "ref": "nixos-23.11", 103 | "repo": "nixpkgs", 104 | "type": "github" 105 | } 106 | }, 107 | "pre-commit-hooks": { 108 | "inputs": { 109 | "flake-compat": "flake-compat", 110 | "flake-utils": "flake-utils", 111 | "gitignore": "gitignore", 112 | "nixpkgs": [ 113 | "nixpkgs" 114 | ], 115 | "nixpkgs-stable": "nixpkgs-stable" 116 | }, 117 | "locked": { 118 | "lastModified": 1712897695, 119 | "owner": "cachix", 120 | "repo": "pre-commit-hooks.nix", 121 | "rev": "40e6053ecb65fcbf12863338a6dcefb3f55f1bf8", 122 | "treeHash": "9ba338feee8e6b2193c305f46b65b0fef49816b7", 123 | "type": "github" 124 | }, 125 | "original": { 126 | "owner": "cachix", 127 | "repo": "pre-commit-hooks.nix", 128 | "type": "github" 129 | } 130 | }, 131 | "root": { 132 | "inputs": { 133 | "devenv": "devenv", 134 | "nixpkgs": "nixpkgs", 135 | "pre-commit-hooks": "pre-commit-hooks" 136 | } 137 | }, 138 | "systems": { 139 | "locked": { 140 | "lastModified": 1681028828, 141 | "owner": "nix-systems", 142 | "repo": "default", 143 | "rev": "da67096a3b9bf56a91d16901293e51ba5b49a27e", 144 | "treeHash": "cce81f2a0f0743b2eb61bc2eb6c7adbe2f2c6beb", 145 | "type": "github" 146 | }, 147 | "original": { 148 | "owner": "nix-systems", 149 | "repo": "default", 150 | "type": "github" 151 | } 152 | } 153 | }, 154 | "root": "root", 155 | "version": 7 156 | } 157 | -------------------------------------------------------------------------------- /internal/api/keys.go: -------------------------------------------------------------------------------- 1 | package api 2 | 3 | import ( 4 | "context" 5 | "errors" 6 | "fmt" 7 | "os" 8 | "strconv" 9 | "sync" 10 | 11 | "golang.org/x/sync/semaphore" 12 | 13 | tls_client "github.com/bogdanfinn/tls-client" 14 | "github.com/bogdanfinn/tls-client/profiles" 15 | "github.com/demostanis/42evaluators/internal/models" 16 | "gorm.io/gorm" 17 | ) 18 | 19 | const ( 20 | host = "profile.intra.42.fr" 21 | concurrentFetches = int64(42) 22 | defaultUserAgent = "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36" 23 | defaultAppName = "42evaluators" 24 | keysToGenerate = 400 25 | ) 26 | 27 | var defaultRedirectURI = "http://localhost:8080" 28 | 29 | type KeysManager struct { 30 | ctx context.Context 31 | authToken string 32 | intraSessionToken string 33 | userIDToken string 34 | redirectURI string 35 | client tls_client.HttpClient 36 | db *gorm.DB 37 | } 38 | 39 | type APIResult struct { 40 | Name string 41 | AppID string 42 | UID string 43 | Secret string 44 | } 45 | 46 | var DefaultKeysManager *KeysManager = nil 47 | 48 | func NewKeysManager(db *gorm.DB) (*KeysManager, error) { 49 | intraSessionToken, ok := os.LookupEnv("INTRA_SESSION_TOKEN") 50 | if !ok { 51 | return nil, errors.New("no INTRA_SESSION_TOKEN found in .env file") 52 | } 53 | 54 | userIDToken, ok := os.LookupEnv("USER_ID_TOKEN") 55 | if !ok { 56 | return nil, errors.New("no USER_ID_TOKEN found in .env file") 57 | } 58 | 59 | client, err := tls_client.NewHttpClient( 60 | tls_client.NewNoopLogger(), 61 | []tls_client.HttpClientOption{ 62 | tls_client.WithClientProfile(profiles.Chrome_105), 63 | }..., 64 | ) 65 | if err != nil { 66 | return nil, err 67 | } 68 | 69 | redirectURI, ok := os.LookupEnv("REDIRECT_URI") 70 | if !ok { 71 | redirectURI = defaultRedirectURI 72 | } 73 | session := KeysManager{ 74 | ctx: context.Background(), 75 | redirectURI: redirectURI, 76 | intraSessionToken: intraSessionToken, 77 | userIDToken: userIDToken, 78 | client: client, 79 | db: db, 80 | } 81 | err = session.pullAuthenticityToken() 82 | if err != nil { 83 | return nil, err 84 | } 85 | 86 | return &session, nil 87 | } 88 | 89 | func (manager *KeysManager) GetKeys() ([]models.APIKey, error) { 90 | var keys []models.APIKey 91 | 92 | err := manager.db.Model(&models.APIKey{}).Find(&keys).Error 93 | if err != nil { 94 | return keys, fmt.Errorf("error querying API keys: %w", err) 95 | } 96 | if len(keys) == 0 { 97 | keys, err = manager.createMany(keysToGenerate) 98 | if err != nil { 99 | return keys, err 100 | } 101 | } 102 | return keys, nil 103 | } 104 | 105 | func (manager *KeysManager) CreateOne() (*models.APIKey, error) { 106 | panic("deleted, sorry ;(") 107 | } 108 | 109 | func (manager *KeysManager) createMany(n int) ([]models.APIKey, error) { 110 | var mu sync.Mutex 111 | var errs []error 112 | keys := make([]models.APIKey, 0, n) 113 | 114 | fmt.Printf("creating %d API keys...\n", n) 115 | 116 | var wg sync.WaitGroup 117 | sem := semaphore.NewWeighted(concurrentFetches) 118 | 119 | for i := 0; i < n; i++ { 120 | wg.Add(1) 121 | err := sem.Acquire(manager.ctx, 1) 122 | if err != nil { 123 | return keys, err 124 | } 125 | 126 | go func(i int) { 127 | defer sem.Release(1) 128 | defer wg.Done() 129 | 130 | key, err := manager.CreateOne() 131 | mu.Lock() 132 | defer mu.Unlock() 133 | 134 | if err != nil { 135 | errs = append(errs, err) 136 | } else { 137 | keys = append(keys, *key) 138 | } 139 | }(i) 140 | } 141 | 142 | wg.Wait() 143 | return keys, errors.Join(errs...) 144 | } 145 | 146 | func (manager *KeysManager) pullAuthenticityToken() error { 147 | panic("deleted, sorry ;(") 148 | } 149 | 150 | func (manager *KeysManager) fetchAPIKeysFromIntra() ([]string, error) { 151 | panic("deleted, sorry ;(") 152 | } 153 | 154 | func (manager *KeysManager) DeleteAllKeys() error { 155 | keys, err := manager.fetchAPIKeysFromIntra() 156 | if err != nil { 157 | return err 158 | } 159 | 160 | fmt.Printf("deleting %d keys...\n", len(keys)) 161 | 162 | sem := semaphore.NewWeighted(concurrentFetches) 163 | var wg sync.WaitGroup 164 | var mu sync.Mutex 165 | var errs []error 166 | 167 | for i, key := range keys { 168 | wg.Add(1) 169 | err = sem.Acquire(manager.ctx, 1) 170 | 171 | go func(i int, idRaw string) { 172 | defer sem.Release(1) 173 | defer wg.Done() 174 | 175 | id, _ := strconv.Atoi(idRaw) 176 | err = manager.DeleteOne(id) 177 | if err != nil { 178 | mu.Lock() 179 | errs = append(errs, err) 180 | mu.Unlock() 181 | } 182 | }(i, key) 183 | } 184 | 185 | wg.Wait() 186 | manager.db.Exec("DELETE FROM api_keys") 187 | return errors.Join(errs...) 188 | } 189 | 190 | func (manager *KeysManager) DeleteOne(id int) error { 191 | panic("deleted, sorry ;(") 192 | } 193 | -------------------------------------------------------------------------------- /internal/models/user.go: -------------------------------------------------------------------------------- 1 | package models 2 | 3 | import ( 4 | "encoding/json" 5 | "errors" 6 | "math" 7 | "time" 8 | 9 | "gorm.io/gorm" 10 | ) 11 | 12 | const ( 13 | DefaultImageLink = "https://cdn.intra.42.fr/users/ec0e0f87fa56b0b3e872b800e120dc0b/sheldon.jpeg" 14 | DefaultImageLinkSmall = "https://cdn.intra.42.fr/users/a58fb999e453e72955ab3d926d5cf872/small_sheldon.jpeg" 15 | DateFormat = time.RFC3339 16 | ) 17 | 18 | type CursusUser struct { 19 | // We cannot use our User struct since that would 20 | // recurse indefinitely (CursusUser->User->CursusUser->User->...) 21 | // because of our custom UnmarshalJSON below 22 | User struct { 23 | ID int `json:"id"` 24 | Login string `json:"login"` 25 | DisplayName string `json:"displayname"` 26 | IsStaff bool `json:"staff?"` 27 | Image struct { 28 | Link string `json:"link"` 29 | Versions struct { 30 | Small string `json:"small"` 31 | } `json:"versions"` 32 | } `json:"image"` 33 | CorrectionPoints int `json:"correction_point"` 34 | Wallets int `json:"wallet"` 35 | } `json:"user"` 36 | Level float64 `json:"level"` 37 | BlackholedAt string `json:"blackholed_at"` 38 | BeginAt string `json:"begin_at"` 39 | } 40 | 41 | type User struct { 42 | ID int 43 | Login string 44 | DisplayName string 45 | IsStaff bool 46 | BlackholedAt time.Time 47 | BeginAt time.Time 48 | CorrectionPoints int 49 | Wallets int 50 | 51 | ImageLink string 52 | ImageLinkSmall string 53 | IsTest bool 54 | Level float64 55 | WeeklyLogtime time.Duration 56 | 57 | CoalitionID int 58 | Coalition Coalition 59 | TitleID int 60 | Title Title 61 | CampusID int 62 | Campus Campus 63 | } 64 | 65 | func (user *User) UnmarshalJSON(data []byte) error { 66 | var cursusUser CursusUser 67 | 68 | if err := json.Unmarshal(data, &cursusUser); err != nil { 69 | return err 70 | } 71 | 72 | user.ID = cursusUser.User.ID 73 | user.Login = cursusUser.User.Login 74 | user.DisplayName = cursusUser.User.DisplayName 75 | user.IsStaff = cursusUser.User.IsStaff 76 | user.BlackholedAt, _ = time.Parse(DateFormat, cursusUser.BlackholedAt) 77 | user.CorrectionPoints = cursusUser.User.CorrectionPoints 78 | user.BeginAt, _ = time.Parse(DateFormat, cursusUser.BeginAt) 79 | user.Wallets = cursusUser.User.Wallets 80 | 81 | user.ImageLinkSmall = cursusUser.User.Image.Versions.Small 82 | if user.ImageLinkSmall == "" { 83 | user.ImageLinkSmall = DefaultImageLinkSmall 84 | } 85 | 86 | user.Level = math.Round(cursusUser.Level*100) / 100 87 | user.ImageLink = cursusUser.User.Image.Link 88 | if user.ImageLink == "" { 89 | user.ImageLink = DefaultImageLink 90 | } 91 | 92 | return nil 93 | } 94 | 95 | func (user *User) CreateIfNeeded(db *gorm.DB) error { 96 | if user.ID == 0 { 97 | return errors.New("user may not have ID 0") 98 | } 99 | 100 | err := db. 101 | Session(&gorm.Session{}). 102 | Model(&User{}). 103 | Where("id = ?", user.ID). 104 | First(nil).Error 105 | if errors.Is(err, gorm.ErrRecordNotFound) { 106 | return db.Create(&user).Error 107 | } 108 | return nil 109 | } 110 | 111 | func (user *User) UpdateFields(db *gorm.DB) error { 112 | return db. 113 | Where("id = ?", user.ID). 114 | Model(&User{}).Updates(map[string]any{ 115 | "ID": user.ID, 116 | "Login": user.Login, 117 | "DisplayName": user.DisplayName, 118 | "IsStaff": user.IsStaff, 119 | "BlackholedAt": user.BlackholedAt, 120 | "CorrectionPoints": user.CorrectionPoints, 121 | "ImageLink": user.ImageLink, 122 | "ImageLinkSmall": user.ImageLinkSmall, 123 | "Level": user.Level, 124 | "BeginAt": user.BeginAt, 125 | "Wallets": user.Wallets, 126 | }).Error 127 | } 128 | 129 | func (user *User) YesItsATestAccount(db *gorm.DB) error { 130 | user.IsTest = true 131 | return db.Model(&User{}). 132 | Where("id = ?", user.ID). 133 | Updates(map[string]any{ 134 | "IsTest": true, 135 | }).Error 136 | } 137 | 138 | func (user *User) SetCampus(campusID int, db *gorm.DB) error { 139 | var campus Campus 140 | err := db.Model(&Campus{}). 141 | Where("id = ?", campusID). 142 | First(&campus).Error 143 | if err != nil { 144 | return err 145 | } 146 | 147 | defer func() { 148 | // If this is done before the query below, GORM 149 | // will generate invalid SQL... 150 | user.Campus = campus 151 | }() 152 | return db.Model(&user). 153 | Where("id = ?", user.ID). 154 | Updates(map[string]any{ 155 | "CampusID": campusID, 156 | }).Error 157 | } 158 | 159 | func (user *User) SetCoalition(coalition Coalition, db *gorm.DB) error { 160 | user.Coalition = coalition 161 | return db.Model(&User{}). 162 | Where("id = ?", user.ID). 163 | Updates(map[string]any{ 164 | "CoalitionID": coalition.ID, 165 | }).Error 166 | } 167 | 168 | func (user *User) SetTitle(title Title, db *gorm.DB) error { 169 | user.Title = title 170 | return db.Model(&User{}). 171 | Where("id = ?", user.ID). 172 | Updates(map[string]any{ 173 | "TitleID": title.ID, 174 | }).Error 175 | } 176 | 177 | func (user *User) SetWeeklyLogtime(logtime time.Duration, db *gorm.DB) error { 178 | user.WeeklyLogtime = logtime 179 | return db.Model(&User{}). 180 | Where("id = ?", user.ID). 181 | Updates(map[string]any{ 182 | "WeeklyLogtime": logtime, 183 | }).Error 184 | } 185 | -------------------------------------------------------------------------------- /web/static/assets/LineSegmentsGeometry.js: -------------------------------------------------------------------------------- 1 | import { 2 | Box3, 3 | Float32BufferAttribute, 4 | InstancedBufferGeometry, 5 | InstancedInterleavedBuffer, 6 | InterleavedBufferAttribute, 7 | Sphere, 8 | Vector3, 9 | WireframeGeometry 10 | } from 'three'; 11 | 12 | const _box = new Box3(); 13 | const _vector = new Vector3(); 14 | 15 | class LineSegmentsGeometry extends InstancedBufferGeometry { 16 | 17 | constructor() { 18 | 19 | super(); 20 | 21 | this.isLineSegmentsGeometry = true; 22 | 23 | this.type = 'LineSegmentsGeometry'; 24 | 25 | const positions = [ - 1, 2, 0, 1, 2, 0, - 1, 1, 0, 1, 1, 0, - 1, 0, 0, 1, 0, 0, - 1, - 1, 0, 1, - 1, 0 ]; 26 | const uvs = [ - 1, 2, 1, 2, - 1, 1, 1, 1, - 1, - 1, 1, - 1, - 1, - 2, 1, - 2 ]; 27 | const index = [ 0, 2, 1, 2, 3, 1, 2, 4, 3, 4, 5, 3, 4, 6, 5, 6, 7, 5 ]; 28 | 29 | this.setIndex( index ); 30 | this.setAttribute( 'position', new Float32BufferAttribute( positions, 3 ) ); 31 | this.setAttribute( 'uv', new Float32BufferAttribute( uvs, 2 ) ); 32 | 33 | } 34 | 35 | applyMatrix4( matrix ) { 36 | 37 | const start = this.attributes.instanceStart; 38 | const end = this.attributes.instanceEnd; 39 | 40 | if ( start !== undefined ) { 41 | 42 | start.applyMatrix4( matrix ); 43 | 44 | end.applyMatrix4( matrix ); 45 | 46 | start.needsUpdate = true; 47 | 48 | } 49 | 50 | if ( this.boundingBox !== null ) { 51 | 52 | this.computeBoundingBox(); 53 | 54 | } 55 | 56 | if ( this.boundingSphere !== null ) { 57 | 58 | this.computeBoundingSphere(); 59 | 60 | } 61 | 62 | return this; 63 | 64 | } 65 | 66 | setPositions( array ) { 67 | 68 | let lineSegments; 69 | 70 | if ( array instanceof Float32Array ) { 71 | 72 | lineSegments = array; 73 | 74 | } else if ( Array.isArray( array ) ) { 75 | 76 | lineSegments = new Float32Array( array ); 77 | 78 | } 79 | 80 | const instanceBuffer = new InstancedInterleavedBuffer( lineSegments, 6, 1 ); // xyz, xyz 81 | 82 | this.setAttribute( 'instanceStart', new InterleavedBufferAttribute( instanceBuffer, 3, 0 ) ); // xyz 83 | this.setAttribute( 'instanceEnd', new InterleavedBufferAttribute( instanceBuffer, 3, 3 ) ); // xyz 84 | 85 | // 86 | 87 | this.computeBoundingBox(); 88 | this.computeBoundingSphere(); 89 | 90 | return this; 91 | 92 | } 93 | 94 | setColors( array ) { 95 | 96 | let colors; 97 | 98 | if ( array instanceof Float32Array ) { 99 | 100 | colors = array; 101 | 102 | } else if ( Array.isArray( array ) ) { 103 | 104 | colors = new Float32Array( array ); 105 | 106 | } 107 | 108 | const instanceColorBuffer = new InstancedInterleavedBuffer( colors, 6, 1 ); // rgb, rgb 109 | 110 | this.setAttribute( 'instanceColorStart', new InterleavedBufferAttribute( instanceColorBuffer, 3, 0 ) ); // rgb 111 | this.setAttribute( 'instanceColorEnd', new InterleavedBufferAttribute( instanceColorBuffer, 3, 3 ) ); // rgb 112 | 113 | return this; 114 | 115 | } 116 | 117 | fromWireframeGeometry( geometry ) { 118 | 119 | this.setPositions( geometry.attributes.position.array ); 120 | 121 | return this; 122 | 123 | } 124 | 125 | fromEdgesGeometry( geometry ) { 126 | 127 | this.setPositions( geometry.attributes.position.array ); 128 | 129 | return this; 130 | 131 | } 132 | 133 | fromMesh( mesh ) { 134 | 135 | this.fromWireframeGeometry( new WireframeGeometry( mesh.geometry ) ); 136 | 137 | // set colors, maybe 138 | 139 | return this; 140 | 141 | } 142 | 143 | fromLineSegments( lineSegments ) { 144 | 145 | const geometry = lineSegments.geometry; 146 | 147 | this.setPositions( geometry.attributes.position.array ); // assumes non-indexed 148 | 149 | // set colors, maybe 150 | 151 | return this; 152 | 153 | } 154 | 155 | computeBoundingBox() { 156 | 157 | if ( this.boundingBox === null ) { 158 | 159 | this.boundingBox = new Box3(); 160 | 161 | } 162 | 163 | const start = this.attributes.instanceStart; 164 | const end = this.attributes.instanceEnd; 165 | 166 | if ( start !== undefined && end !== undefined ) { 167 | 168 | this.boundingBox.setFromBufferAttribute( start ); 169 | 170 | _box.setFromBufferAttribute( end ); 171 | 172 | this.boundingBox.union( _box ); 173 | 174 | } 175 | 176 | } 177 | 178 | computeBoundingSphere() { 179 | 180 | if ( this.boundingSphere === null ) { 181 | 182 | this.boundingSphere = new Sphere(); 183 | 184 | } 185 | 186 | if ( this.boundingBox === null ) { 187 | 188 | this.computeBoundingBox(); 189 | 190 | } 191 | 192 | const start = this.attributes.instanceStart; 193 | const end = this.attributes.instanceEnd; 194 | 195 | if ( start !== undefined && end !== undefined ) { 196 | 197 | const center = this.boundingSphere.center; 198 | 199 | this.boundingBox.getCenter( center ); 200 | 201 | let maxRadiusSq = 0; 202 | 203 | for ( let i = 0, il = start.count; i < il; i ++ ) { 204 | 205 | _vector.fromBufferAttribute( start, i ); 206 | maxRadiusSq = Math.max( maxRadiusSq, center.distanceToSquared( _vector ) ); 207 | 208 | _vector.fromBufferAttribute( end, i ); 209 | maxRadiusSq = Math.max( maxRadiusSq, center.distanceToSquared( _vector ) ); 210 | 211 | } 212 | 213 | this.boundingSphere.radius = Math.sqrt( maxRadiusSq ); 214 | 215 | if ( isNaN( this.boundingSphere.radius ) ) { 216 | 217 | console.error( 'THREE.LineSegmentsGeometry.computeBoundingSphere(): Computed radius is NaN. The instanced position data is likely to have NaN values.', this ); 218 | 219 | } 220 | 221 | } 222 | 223 | } 224 | 225 | toJSON() { 226 | 227 | // todo 228 | 229 | } 230 | 231 | applyMatrix( matrix ) { 232 | 233 | console.warn( 'THREE.LineSegmentsGeometry: applyMatrix() has been renamed to applyMatrix4().' ); 234 | 235 | return this.applyMatrix4( matrix ); 236 | 237 | } 238 | 239 | } 240 | 241 | export { LineSegmentsGeometry }; 242 | -------------------------------------------------------------------------------- /web/templates/peerfinder.templ: -------------------------------------------------------------------------------- 1 | package templates 2 | 3 | import ( 4 | "fmt" 5 | "strconv" 6 | "github.com/demostanis/42evaluators/internal/models" 7 | ) 8 | 9 | func urlForProject(project models.Project) templ.SafeURL { 10 | return templ.SafeURL(fmt.Sprintf("https://projects.intra.42.fr/projects/%s/projects_users/%d", 11 | project.Subject.Slug, project.ID)) 12 | } 13 | 14 | templ Project(project models.Project) { 15 | 16 |
    17 |
    18 | for _, teamUser := range project.Teams[project.ActiveTeam].Users { 19 |
    20 |
    21 | 22 |
    23 |
    24 | } 25 |
    26 |

    27 | { project.Teams[project.ActiveTeam].Name } 28 | if len(project.Teams) > 1 { 29 | ({ strconv.Itoa(len(project.Teams) - 1) } 30 | } 31 | if len(project.Teams) > 2 { 32 | retries) 33 | } else if len(project.Teams) == 2 { 34 | retry) 35 | } 36 |

    37 |
    38 |
    39 | } 40 | 41 | func calcGap(projects []models.Project) string { 42 | maxUsers := 1 43 | for _, project := range projects { 44 | if len(project.Teams[0].Users) > maxUsers { 45 | maxUsers = len(project.Teams[0].Users) 46 | } 47 | } 48 | return fmt.Sprintf("gap-y-[1rem] gap-x-[%drem]", maxUsers*2) 49 | } 50 | 51 | script projectsSettingsHandler() { 52 | function handle(e) { 53 | e.preventDefault(); 54 | 55 | const fields = []; 56 | [].__proto__.slice.call( 57 | document.querySelectorAll("#projects-settings-form input")). 58 | filter(field => field.checked). 59 | forEach(field => fields.push(field.id)); 60 | 61 | const params = new URLSearchParams(window.location.search); 62 | params.delete("subjects"); 63 | params.delete("me"); 64 | if (fields.length) { 65 | params.append("subjects", fields); 66 | } 67 | window.location.search = params; 68 | } 69 | 70 | document.querySelector("#projects-settings-form label") 71 | .addEventListener("click", handle); 72 | } 73 | 74 | script deselectAllHandler() { 75 | let active = true; 76 | const elem = document.querySelector("#deselect-all") 77 | elem.indeterminate = true; 78 | elem.addEventListener("click", () => { 79 | active = !active; 80 | elem.checked = active; 81 | elem.indeterminate = active; 82 | document.querySelectorAll("#projects-settings-form input"). 83 | forEach(elem => elem.checked = active); 84 | }); 85 | } 86 | 87 | script statusSelectHandler() { 88 | const statusSelect = document.querySelector("#status-select"); 89 | 90 | function handle(e) { 91 | e.preventDefault(); 92 | 93 | const status = statusSelect.selectedOptions[0].value; 94 | const params = new URLSearchParams(window.location.search); 95 | params.delete("status"); 96 | params.append("status", status); 97 | window.location.search = params; 98 | } 99 | 100 | statusSelect.addEventListener("change", handle); 101 | } 102 | 103 | script campusSelectHandler() { 104 | function updateCampus(e) { 105 | const params = new URLSearchParams(window.location.search); 106 | const selected = e.target.selectedOptions[0]; 107 | 108 | params.delete("campus"); 109 | params.append("campus", selected.value); 110 | window.location.search = params; 111 | } 112 | 113 | document.querySelector(".campus-selector") 114 | .addEventListener("change", updateCampus) 115 | } 116 | 117 | templ PeerFinder( 118 | subjects []models.Subject, 119 | projects map[int][]models.Project, 120 | checkedSubjects map[string]bool, 121 | currentStatus string, 122 | campuses []models.Campus, 123 | activeCampus int, 124 | ) { 125 | @header() 126 | 127 |
    128 |

    Show teams

    129 | 157 |

    in

    158 | 174 | 175 |
    176 |
    177 | for _, subject := range subjects { 178 | if len(projects[subject.ID]) > 0 { 179 |

    { subject.Name }

    180 |
    181 | for _, project := range projects[subject.ID] { 182 | @Project(project) 183 | } 184 |
    185 | } 186 | } 187 |
    188 | 189 | 214 | 215 | @campusSelectHandler() 216 | @statusSelectHandler() 217 | @projectsSettingsHandler() 218 | @deselectAllHandler() 219 | @footer() 220 | } 221 | -------------------------------------------------------------------------------- /web/clusters.go: -------------------------------------------------------------------------------- 1 | package web 2 | 3 | import ( 4 | "cmp" 5 | "encoding/json" 6 | "fmt" 7 | "io" 8 | "net/http" 9 | "os" 10 | "slices" 11 | "strconv" 12 | "strings" 13 | 14 | "github.com/demostanis/42evaluators/internal/clusters" 15 | "github.com/demostanis/42evaluators/internal/models" 16 | "github.com/demostanis/42evaluators/web/templates" 17 | "github.com/gorilla/websocket" 18 | "gorm.io/gorm" 19 | ) 20 | 21 | var allClusters []clusters.Cluster 22 | 23 | func OpenClustersData() error { 24 | file, err := os.Open("assets/clusters.json") 25 | if err != nil { 26 | return err 27 | } 28 | bytes, err := io.ReadAll(file) 29 | if err != nil { 30 | return err 31 | } 32 | err = json.Unmarshal(bytes, &allClusters) 33 | if err != nil { 34 | return err 35 | } 36 | for i, c := range allClusters { 37 | allClusters[i].DisplayName = fmt.Sprintf( 38 | "%s - %s", c.Campus.Name, c.Name) 39 | } 40 | slices.SortFunc(allClusters, func(a, b clusters.Cluster) int { 41 | return cmp.Compare(a.DisplayName, b.DisplayName) 42 | }) 43 | return nil 44 | } 45 | 46 | func fetchSvg(cluster *clusters.Cluster) error { 47 | var source io.Reader 48 | 49 | if strings.HasPrefix(cluster.Image, "http") { 50 | resp, err := http.Get(cluster.Image) 51 | if err != nil { 52 | return err 53 | } 54 | if resp.StatusCode != http.StatusOK { 55 | cluster.Svg = "

    Cannot find this cluster map. It's likely that " + 56 | "its campus' staff has modified it, and thus the link has changed. " + 57 | "If you are part of this campus, please send the cluster SVG to " + 58 | "@cgodard on Slack.

    " 59 | return nil 60 | } 61 | source = resp.Body 62 | defer resp.Body.Close() 63 | } else { 64 | file, err := os.Open(cluster.Image) 65 | if err != nil { 66 | return err 67 | } 68 | defer file.Close() 69 | source = file 70 | } 71 | 72 | body, err := io.ReadAll(source) 73 | if err != nil { 74 | return err 75 | } 76 | cluster.Svg = strings.Replace(string(body), " 0 { 242 | for _, location := range locations { 243 | sendResponse(c, location, db) 244 | } 245 | } 246 | 247 | case location := <-locationChan: 248 | campusID := findCampusIDForCluster(wantedClusterID) 249 | if location.CampusID == campusID { 250 | // Respond with user information if the location's campus 251 | // is the same as the wanted cluster's campus (it would be 252 | // more performant to only send locations in the specifially 253 | // requested cluster, but the cable unfortunately does not 254 | // tell this) 255 | sendResponse(c, location, db) 256 | } 257 | } 258 | } 259 | }) 260 | } 261 | -------------------------------------------------------------------------------- /web/leaderboard.go: -------------------------------------------------------------------------------- 1 | package web 2 | 3 | import ( 4 | "fmt" 5 | "net/http" 6 | "slices" 7 | "strconv" 8 | "strings" 9 | 10 | "github.com/demostanis/42evaluators/internal/database" 11 | "github.com/demostanis/42evaluators/internal/models" 12 | "github.com/demostanis/42evaluators/web/templates" 13 | "gorm.io/gorm" 14 | ) 15 | 16 | // Processes the user's ?fields= URL param by splitting it 17 | // on commas and returning a map of valid (according to 18 | // templates.ToggleableFields) templates.Fields. 19 | func getShownFields(wantedFieldsRaw string) map[string]templates.Field { 20 | shownFields := make(map[string]templates.Field) 21 | 22 | wantedFields := []string{"level", "campus"} 23 | if wantedFieldsRaw != "" { 24 | wantedFields = strings.Split(wantedFieldsRaw, ",") 25 | } 26 | 27 | for _, field := range templates.ToggleableFields { 28 | found := false 29 | for _, allowedField := range wantedFields { 30 | if field.Name == allowedField { 31 | found = true 32 | } 33 | } 34 | shownFields[field.Name] = templates.Field{ 35 | Name: field.Name, 36 | PrettyName: field.PrettyName, 37 | Sortable: field.Sortable, 38 | Checked: found, 39 | } 40 | } 41 | 42 | return shownFields 43 | } 44 | 45 | func canSortOn(field string) bool { 46 | for _, toggleableField := range templates.ToggleableFields { 47 | if toggleableField.Name == field && toggleableField.Sortable { 48 | return true 49 | } 50 | } 51 | return false 52 | } 53 | 54 | func getPromosForCampus( 55 | db *gorm.DB, 56 | campus string, 57 | promo string, 58 | ) ([]templates.Promo, error) { 59 | var promos []templates.Promo 60 | 61 | var campusUsers []models.User 62 | err := db. 63 | Scopes(database.WithCampus(campus)). 64 | Scopes(database.OnlyRealUsers()). 65 | Find(&campusUsers).Error 66 | if err != nil { 67 | return promos, err 68 | } 69 | 70 | for _, user := range campusUsers { 71 | userPromo := fmt.Sprintf("%02d/%d", 72 | user.BeginAt.Month(), 73 | user.BeginAt.Year()) 74 | 75 | shouldAdd := true 76 | for _, alreadyAddedPromo := range promos { 77 | if userPromo == alreadyAddedPromo.Name { 78 | shouldAdd = false 79 | break 80 | } 81 | } 82 | if shouldAdd { 83 | promos = append(promos, templates.Promo{ 84 | Name: userPromo, 85 | Active: promo == userPromo, 86 | }) 87 | } 88 | } 89 | 90 | slices.SortFunc(promos, func(a, b templates.Promo) int { 91 | parseDate := func(promo templates.Promo) (int, int) { 92 | parts := strings.Split(promo.Name, "/") 93 | month, _ := strconv.Atoi(parts[0]) 94 | year, _ := strconv.Atoi(parts[1]) 95 | return month, year 96 | } 97 | monthA, yearA := parseDate(a) 98 | monthB, yearB := parseDate(b) 99 | 100 | return (monthB | yearB<<5) - (monthA | yearA<<5) 101 | }) 102 | 103 | return promos, nil 104 | } 105 | 106 | func getAllCampuses(db *gorm.DB) ([]models.Campus, error) { 107 | var campuses []models.Campus 108 | err := db.Find(&campuses).Error 109 | return campuses, err 110 | } 111 | 112 | func internalServerError(w http.ResponseWriter, err error) { 113 | // TODO: stream this to errstream 114 | w.WriteHeader(http.StatusInternalServerError) 115 | _, _ = w.Write([]byte(fmt.Sprintf("an error occured: %v", err))) 116 | } 117 | 118 | func handleLeaderboard(db *gorm.DB) http.Handler { 119 | return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 120 | page, err := strconv.Atoi(r.URL.Query().Get("page")) 121 | if err != nil || page <= 0 { 122 | page = 1 123 | } 124 | 125 | sorting := r.URL.Query().Get("sort") 126 | if sorting == "" || !canSortOn(sorting) { 127 | sorting = "level" 128 | } 129 | 130 | promo := r.URL.Query().Get("promo") 131 | campus := r.URL.Query().Get("campus") 132 | showMyself := r.URL.Query().Get("me") != "" 133 | shownFields := getShownFields(r.URL.Query().Get("fields")) 134 | search := r.URL.Query().Get("search") 135 | 136 | campuses, err := getAllCampuses(db) 137 | if err != nil { 138 | internalServerError(w, fmt.Errorf("could not fetch campuses: %w", err)) 139 | return 140 | } 141 | 142 | promos, err := getPromosForCampus(db, campus, promo) 143 | if err != nil { 144 | internalServerError(w, fmt.Errorf("could not list promos: %w", err)) 145 | return 146 | } 147 | 148 | withSearch := func(search string) func(db *gorm.DB) *gorm.DB { 149 | return func(db *gorm.DB) *gorm.DB { 150 | if search == "" { 151 | return db 152 | } 153 | return db. 154 | Joins("FULL OUTER JOIN titles ON titles.id = title_id"). 155 | Where(`login % ? OR display_name % ? OR titles.name % ?`, 156 | search, search, search) 157 | } 158 | } 159 | 160 | var users []models.User 161 | 162 | var totalUsers int64 163 | err = db. 164 | Model(&models.User{}). 165 | Scopes(database.OnlyRealUsers()). 166 | Scopes(database.WithCampus(campus)). 167 | Scopes(database.WithPromo(promo)). 168 | Scopes(withSearch(search)). 169 | Count(&totalUsers).Error 170 | if err != nil { 171 | internalServerError(w, fmt.Errorf("could not get user count: %w", err)) 172 | return 173 | } 174 | totalPages := 1 + (int(totalUsers)-1)/UsersPerPage 175 | page = min(page, totalPages) 176 | offset := (page - 1) * UsersPerPage 177 | 178 | var user models.User 179 | id := getLoggedInUser(r).them.ID 180 | err = db. 181 | Preload("Campus"). 182 | Where("id = ?", id). 183 | First(&user).Error 184 | if err != nil { 185 | internalServerError(w, fmt.Errorf("user is not in db: %d: %w", id, err)) 186 | return 187 | } 188 | 189 | if showMyself && search == "" { 190 | var myPosition int64 191 | 192 | // We create a SQL query abusing .Select, specifying 193 | // a ROW_NUMBER() statement instead of fields, 194 | // to then insert it as a table in the next query, 195 | // while hiding erroneous GORM-generated stuff with 196 | // a self-SQL-injection using Where() 197 | sql := db.ToSQL(func(db *gorm.DB) *gorm.DB { 198 | return db. 199 | Model(&models.User{}). 200 | Select(fmt.Sprintf( 201 | `*, ROW_NUMBER() OVER (ORDER BY %s DESC) pos`, 202 | sorting)). 203 | Scopes(database.OnlyRealUsers()). 204 | Scopes(database.WithCampus(campus)). 205 | Scopes(database.WithPromo(promo)). 206 | Find(nil) 207 | }) 208 | err = db. 209 | Model(&models.User{}). 210 | Table(fmt.Sprintf(`(%s) boobs`, sql)). 211 | Where("id = ?", user.ID). 212 | Where("1=1 --"). // That's hideous lmfao 213 | Select("pos"). 214 | Scan(&myPosition).Error 215 | if err != nil { 216 | internalServerError(w, fmt.Errorf("failed to find user in db: %w", err)) 217 | return 218 | } 219 | 220 | offset = int(myPosition-1) - (int(myPosition-1) % UsersPerPage) 221 | page = 1 + offset/UsersPerPage 222 | } 223 | 224 | err = db. 225 | Preload("Coalition"). 226 | Preload("Title"). 227 | Preload("Campus"). 228 | Offset(offset). 229 | Limit(UsersPerPage). 230 | Order(sorting + " DESC"). 231 | Scopes(database.OnlyRealUsers()). 232 | Scopes(database.WithCampus(campus)). 233 | Scopes(database.WithPromo(promo)). 234 | Scopes(withSearch(search)). 235 | Find(&users).Error 236 | if err != nil { 237 | internalServerError(w, fmt.Errorf("failed to list users: %w", err)) 238 | return 239 | } 240 | 241 | activeCampusID, _ := strconv.Atoi(campus) 242 | userPromo := fmt.Sprintf("%02d/%d", 243 | user.BeginAt.Month(), 244 | user.BeginAt.Year()) 245 | gotoMyPositionShown := search == "" && 246 | ((campus == "" && promo == "") || 247 | (promo == "" && user.Campus.ID == activeCampusID) || 248 | (promo != "" && campus == "" && userPromo == promo) || 249 | (user.Campus.ID == activeCampusID && userPromo == promo)) 250 | 251 | _ = templates.Leaderboard(users, 252 | promos, campuses, activeCampusID, 253 | r.URL, page, totalPages, shownFields, 254 | getLoggedInUser(r).them.ID, 255 | offset, gotoMyPositionShown, 256 | search, 257 | ).Render(r.Context(), w) 258 | }) 259 | } 260 | -------------------------------------------------------------------------------- /web/templates/calculator.templ: -------------------------------------------------------------------------------- 1 | package templates 2 | 3 | import ( 4 | "github.com/demostanis/42evaluators/internal/models" 5 | "github.com/demostanis/42evaluators/internal/projects" 6 | "fmt" 7 | ) 8 | 9 | script xpCalculator( 10 | currentLevel float64, 11 | subjects []models.Subject, 12 | xpData []projects.XPByLevel, 13 | ) { 14 | const series = [currentLevel, currentLevel]; 15 | const labels = ["Current level", "New level"]; 16 | const xpForSeries = []; 17 | const bhDays = []; 18 | let initialUserXP; 19 | let charts; 20 | let newLevel; 21 | 22 | function addProject(number) { 23 | let userXP; 24 | let reason = "New level"; 25 | const levelSelect = document.getElementsByName("level")[number]; 26 | const subjectSelect = document.getElementsByName("project")[number]; 27 | const xpSelect = document.getElementsByName("xp")[number]; 28 | const mark = document.getElementsByName("mark")[number]; 29 | 30 | const updateUserXP = () => { 31 | const level = levelSelect.value; 32 | let levelData = xpData.find(({ lvl }) => lvl == parseInt(level)); 33 | if (!levelData) 34 | levelData = xpData[xpData.length-1]; 35 | userXP = levelData.xp + levelData.xpToNextLevel * (level - parseInt(level)); 36 | if (number == 0) { 37 | initialUserXP = userXP; 38 | xpForSeries[0] = userXP; 39 | } 40 | } 41 | updateUserXP(); 42 | 43 | const updateLevel = () => { 44 | const newXP = userXP + parseInt(xpSelect.value||0)*((mark.value||100)/100); 45 | if (levelSelect.value < 0 || mark.value < 0) return; 46 | xpForSeries[number+1] = newXP; 47 | 48 | let levelForXP; 49 | let i; 50 | for (i in xpData) { 51 | i = parseInt(i); 52 | if (xpData[i].xp > newXP) { 53 | levelForXP = xpData[i-1]; 54 | break; 55 | } 56 | } 57 | 58 | let levelForInitialXP; 59 | for (let j in xpData) { 60 | j = parseInt(j); 61 | if (xpData[j].xp > initialUserXP) { 62 | levelForInitialXP = xpData[j-1]; 63 | break; 64 | } 65 | } 66 | 67 | const xpToNextLevel = Math.max(0, parseInt(xpData[i].xp-newXP)); 68 | document.querySelector(".xp-required"). 69 | textContent = `${xpToNextLevel} XP until next level`; 70 | 71 | if (!levelForXP) 72 | newLevel = 30; 73 | else 74 | newLevel = levelForXP.lvl + 75 | (newXP - levelForXP.xp)/levelForXP.xpToNextLevel; 76 | series[number+1] = newLevel.toFixed(2); 77 | labels[number+1] = reason; 78 | charts.updateSeries([{ 79 | name: "Level", 80 | data: series, 81 | }]); 82 | charts.updateOptions({ labels, }); 83 | 84 | const levelsEarned = series[series.length-1] - document.getElementsByName("level")[0].value; 85 | let sign = "+"; 86 | if (levelsEarned < 0) 87 | sign = ""; 88 | document.querySelector(".plus-level"). 89 | textContent = sign + levelsEarned.toFixed(2); 90 | 91 | function calcBlackhole(oldXP, newXP) { 92 | const blackholeEarned = parseInt(((( 93 | Math.min(newXP, 78880)/49980)**0.45) 94 | -((oldXP/49980)**0.45))*483); 95 | if (oldXP <= newXP && blackholeEarned < 0) { 96 | return "+ 0 days"; 97 | } 98 | 99 | sign = "+"; 100 | if (blackholeEarned < 0) 101 | sign = ""; 102 | return sign + blackholeEarned + (blackholeEarned == 1 ? " day" : " days"); 103 | } 104 | if (xpForSeries[number] != undefined) 105 | bhDays[number+1] = calcBlackhole(xpForSeries[number], newXP); 106 | document.querySelector(".plus-days"). 107 | textContent = calcBlackhole(initialUserXP, 108 | xpForSeries[xpForSeries.length-1]); 109 | } 110 | updateLevel(); 111 | 112 | levelSelect.addEventListener("input", () => { 113 | currentLevel = levelSelect.value; 114 | series[number] = currentLevel; 115 | updateUserXP(); 116 | updateLevel(); 117 | }); 118 | xpSelect.addEventListener("input", () => { 119 | mark.value = "100"; 120 | subjectSelect.selectedIndex = 0; 121 | reason = `+ ${xpSelect.value} XP`; 122 | updateLevel(); 123 | }); 124 | subjectSelect.addEventListener("change", () => { 125 | for (const subject of subjects) { 126 | if (subject.name.trim() == subjectSelect.selectedOptions[0].value.trim()) { 127 | xpSelect.value = subject.XP; 128 | reason = subject.name; 129 | updateLevel(); 130 | } 131 | } 132 | }); 133 | mark.addEventListener("input", () => { 134 | updateLevel(subjectSelect.selectedOptions[0].value); 135 | }); 136 | } 137 | 138 | charts = new ApexCharts(document.querySelector("#graph"), { 139 | series: [{ 140 | name: "Level", 141 | data: series, 142 | }], 143 | chart: { 144 | type: "area", 145 | height: 400, 146 | width: window.innerWidth - 147 | document.querySelector(".project-picker"). 148 | getBoundingClientRect().width, 149 | toolbar: { 150 | show: false, 151 | }, 152 | }, 153 | yaxis: { 154 | labels: { 155 | formatter: (value, index) => { 156 | const level = value.toFixed(2); 157 | if (!index || 158 | index.dataPointIndex == 0 || 159 | !bhDays[index.dataPointIndex]) 160 | return level; 161 | return `${level} (${bhDays[index.dataPointIndex]})`; 162 | }, 163 | }, 164 | }, 165 | tooltip: { 166 | theme: "dark", 167 | }, 168 | stroke: { 169 | curve: "straight", 170 | }, 171 | labels, 172 | }); 173 | charts.render(); 174 | 175 | let nth = 1; 176 | const addAnotherLevel = () => { 177 | const projects = document.querySelector("#projects"); 178 | const projectPickers = document.querySelectorAll(".project-picker"); 179 | const newProjectPicker = projectPickers[projectPickers.length-1].cloneNode(true); // deep 180 | if (newLevel) 181 | newProjectPicker.querySelector("*[name=\"level\"]").value = newLevel.toFixed(2); 182 | newProjectPicker.querySelector("*[name=\"xp\"]").value = ""; 183 | document.querySelector("#add-project").remove(); 184 | const divider = document.createElement("div"); 185 | divider.classList.add("divider", "!mt-6", "!mb-5"); 186 | projects.appendChild(divider); 187 | projects.appendChild(newProjectPicker); 188 | newProjectPicker.querySelector("#add-project") 189 | .addEventListener("click", addAnotherLevel); 190 | addProject(nth++); 191 | } 192 | document.querySelector("#add-project"). 193 | addEventListener("click", addAnotherLevel); 194 | addProject(0); 195 | } 196 | 197 | templ Calculator(subjects []models.Subject, level float64) { 198 | @header() 199 | 200 | 201 |
    202 |
    203 |
    204 | 205 | 206 | 216 | 217 | 218 | 219 | 220 | 226 | 227 | 228 | 229 |
    OR
    230 | 237 |
    238 | 239 | 240 | 241 | 251 | 252 | 253 | 254 |
    255 |
    256 | 257 |
    258 |
    259 |
    260 |
    261 |
    Levels
    262 |
    +0.00
    263 |
    Unknown XP until next level
    264 |
    265 |
    266 |
    Blackhole
    267 |
    +0 days
    268 | 269 |
    270 |
    271 | @xpCalculator(level, subjects, projects.XPData) 272 | @footer() 273 | } 274 | -------------------------------------------------------------------------------- /assets/xp.json: -------------------------------------------------------------------------------- 1 | [ 2 | { 3 | "id": 620, 4 | "lvl": 0, 5 | "xp": 0, 6 | "cursus_id": 21, 7 | "created_at": "2019-07-29T08:45:11.000Z", 8 | "cursus": { 9 | "id": 21, 10 | "created_at": "2019-07-29T08:45:17.896Z", 11 | "name": "42cursus", 12 | "slug": "42cursus" 13 | } 14 | }, 15 | { 16 | "id": 590, 17 | "lvl": 1, 18 | "xp": 462, 19 | "cursus_id": 21, 20 | "created_at": "2019-07-29T08:45:11.000Z", 21 | "cursus": { 22 | "id": 21, 23 | "created_at": "2019-07-29T08:45:17.896Z", 24 | "name": "42cursus", 25 | "slug": "42cursus" 26 | } 27 | }, 28 | { 29 | "id": 591, 30 | "lvl": 2, 31 | "xp": 2688, 32 | "cursus_id": 21, 33 | "created_at": "2019-07-29T08:45:11.000Z", 34 | "cursus": { 35 | "id": 21, 36 | "created_at": "2019-07-29T08:45:17.896Z", 37 | "name": "42cursus", 38 | "slug": "42cursus" 39 | } 40 | }, 41 | { 42 | "id": 592, 43 | "lvl": 3, 44 | "xp": 5885, 45 | "cursus_id": 21, 46 | "created_at": "2019-07-29T08:45:11.000Z", 47 | "cursus": { 48 | "id": 21, 49 | "created_at": "2019-07-29T08:45:17.896Z", 50 | "name": "42cursus", 51 | "slug": "42cursus" 52 | } 53 | }, 54 | { 55 | "id": 593, 56 | "lvl": 4, 57 | "xp": 11777, 58 | "cursus_id": 21, 59 | "created_at": "2019-07-29T08:45:11.000Z", 60 | "cursus": { 61 | "id": 21, 62 | "created_at": "2019-07-29T08:45:17.896Z", 63 | "name": "42cursus", 64 | "slug": "42cursus" 65 | } 66 | }, 67 | { 68 | "id": 594, 69 | "lvl": 5, 70 | "xp": 29217, 71 | "cursus_id": 21, 72 | "created_at": "2019-07-29T08:45:11.000Z", 73 | "cursus": { 74 | "id": 21, 75 | "created_at": "2019-07-29T08:45:17.896Z", 76 | "name": "42cursus", 77 | "slug": "42cursus" 78 | } 79 | }, 80 | { 81 | "id": 595, 82 | "lvl": 6, 83 | "xp": 46255, 84 | "cursus_id": 21, 85 | "created_at": "2019-07-29T08:45:11.000Z", 86 | "cursus": { 87 | "id": 21, 88 | "created_at": "2019-07-29T08:45:17.896Z", 89 | "name": "42cursus", 90 | "slug": "42cursus" 91 | } 92 | }, 93 | { 94 | "id": 596, 95 | "lvl": 7, 96 | "xp": 63559, 97 | "cursus_id": 21, 98 | "created_at": "2019-07-29T08:45:11.000Z", 99 | "cursus": { 100 | "id": 21, 101 | "created_at": "2019-07-29T08:45:17.896Z", 102 | "name": "42cursus", 103 | "slug": "42cursus" 104 | } 105 | }, 106 | { 107 | "id": 597, 108 | "lvl": 8, 109 | "xp": 74340, 110 | "cursus_id": 21, 111 | "created_at": "2019-07-29T08:45:11.000Z", 112 | "cursus": { 113 | "id": 21, 114 | "created_at": "2019-07-29T08:45:17.896Z", 115 | "name": "42cursus", 116 | "slug": "42cursus" 117 | } 118 | }, 119 | { 120 | "id": 598, 121 | "lvl": 9, 122 | "xp": 85483, 123 | "cursus_id": 21, 124 | "created_at": "2019-07-29T08:45:11.000Z", 125 | "cursus": { 126 | "id": 21, 127 | "created_at": "2019-07-29T08:45:17.896Z", 128 | "name": "42cursus", 129 | "slug": "42cursus" 130 | } 131 | }, 132 | { 133 | "id": 599, 134 | "lvl": 10, 135 | "xp": 95000, 136 | "cursus_id": 21, 137 | "created_at": "2019-07-29T08:45:11.000Z", 138 | "cursus": { 139 | "id": 21, 140 | "created_at": "2019-07-29T08:45:17.896Z", 141 | "name": "42cursus", 142 | "slug": "42cursus" 143 | } 144 | }, 145 | { 146 | "id": 600, 147 | "lvl": 11, 148 | "xp": 105630, 149 | "cursus_id": 21, 150 | "created_at": "2019-07-29T08:45:11.000Z", 151 | "cursus": { 152 | "id": 21, 153 | "created_at": "2019-07-29T08:45:17.896Z", 154 | "name": "42cursus", 155 | "slug": "42cursus" 156 | } 157 | }, 158 | { 159 | "id": 601, 160 | "lvl": 12, 161 | "xp": 124446, 162 | "cursus_id": 21, 163 | "created_at": "2019-07-29T08:45:11.000Z", 164 | "cursus": { 165 | "id": 21, 166 | "created_at": "2019-07-29T08:45:17.896Z", 167 | "name": "42cursus", 168 | "slug": "42cursus" 169 | } 170 | }, 171 | { 172 | "id": 602, 173 | "lvl": 13, 174 | "xp": 145782, 175 | "cursus_id": 21, 176 | "created_at": "2019-07-29T08:45:11.000Z", 177 | "cursus": { 178 | "id": 21, 179 | "created_at": "2019-07-29T08:45:17.896Z", 180 | "name": "42cursus", 181 | "slug": "42cursus" 182 | } 183 | }, 184 | { 185 | "id": 603, 186 | "lvl": 14, 187 | "xp": 169932, 188 | "cursus_id": 21, 189 | "created_at": "2019-07-29T08:45:11.000Z", 190 | "cursus": { 191 | "id": 21, 192 | "created_at": "2019-07-29T08:45:17.896Z", 193 | "name": "42cursus", 194 | "slug": "42cursus" 195 | } 196 | }, 197 | { 198 | "id": 604, 199 | "lvl": 15, 200 | "xp": 197316, 201 | "cursus_id": 21, 202 | "created_at": "2019-07-29T08:45:11.000Z", 203 | "cursus": { 204 | "id": 21, 205 | "created_at": "2019-07-29T08:45:17.896Z", 206 | "name": "42cursus", 207 | "slug": "42cursus" 208 | } 209 | }, 210 | { 211 | "id": 605, 212 | "lvl": 16, 213 | "xp": 228354, 214 | "cursus_id": 21, 215 | "created_at": "2019-07-29T08:45:11.000Z", 216 | "cursus": { 217 | "id": 21, 218 | "created_at": "2019-07-29T08:45:17.896Z", 219 | "name": "42cursus", 220 | "slug": "42cursus" 221 | } 222 | }, 223 | { 224 | "id": 606, 225 | "lvl": 17, 226 | "xp": 263508, 227 | "cursus_id": 21, 228 | "created_at": "2019-07-29T08:45:11.000Z", 229 | "cursus": { 230 | "id": 21, 231 | "created_at": "2019-07-29T08:45:17.896Z", 232 | "name": "42cursus", 233 | "slug": "42cursus" 234 | } 235 | }, 236 | { 237 | "id": 607, 238 | "lvl": 18, 239 | "xp": 303366, 240 | "cursus_id": 21, 241 | "created_at": "2019-07-29T08:45:11.000Z", 242 | "cursus": { 243 | "id": 21, 244 | "created_at": "2019-07-29T08:45:17.896Z", 245 | "name": "42cursus", 246 | "slug": "42cursus" 247 | } 248 | }, 249 | { 250 | "id": 608, 251 | "lvl": 19, 252 | "xp": 348516, 253 | "cursus_id": 21, 254 | "created_at": "2019-07-29T08:45:11.000Z", 255 | "cursus": { 256 | "id": 21, 257 | "created_at": "2019-07-29T08:45:17.896Z", 258 | "name": "42cursus", 259 | "slug": "42cursus" 260 | } 261 | }, 262 | { 263 | "id": 609, 264 | "lvl": 20, 265 | "xp": 399672, 266 | "cursus_id": 21, 267 | "created_at": "2019-07-29T08:45:11.000Z", 268 | "cursus": { 269 | "id": 21, 270 | "created_at": "2019-07-29T08:45:17.896Z", 271 | "name": "42cursus", 272 | "slug": "42cursus" 273 | } 274 | }, 275 | { 276 | "id": 610, 277 | "lvl": 21, 278 | "xp": 457632, 279 | "cursus_id": 21, 280 | "created_at": "2019-07-29T08:45:11.000Z", 281 | "cursus": { 282 | "id": 21, 283 | "created_at": "2019-07-29T08:45:17.896Z", 284 | "name": "42cursus", 285 | "slug": "42cursus" 286 | } 287 | }, 288 | { 289 | "id": 611, 290 | "lvl": 22, 291 | "xp": 523320, 292 | "cursus_id": 21, 293 | "created_at": "2019-07-29T08:45:11.000Z", 294 | "cursus": { 295 | "id": 21, 296 | "created_at": "2019-07-29T08:45:17.896Z", 297 | "name": "42cursus", 298 | "slug": "42cursus" 299 | } 300 | }, 301 | { 302 | "id": 612, 303 | "lvl": 23, 304 | "xp": 597786, 305 | "cursus_id": 21, 306 | "created_at": "2019-07-29T08:45:11.000Z", 307 | "cursus": { 308 | "id": 21, 309 | "created_at": "2019-07-29T08:45:17.896Z", 310 | "name": "42cursus", 311 | "slug": "42cursus" 312 | } 313 | }, 314 | { 315 | "id": 613, 316 | "lvl": 24, 317 | "xp": 682164, 318 | "cursus_id": 21, 319 | "created_at": "2019-07-29T08:45:11.000Z", 320 | "cursus": { 321 | "id": 21, 322 | "created_at": "2019-07-29T08:45:17.896Z", 323 | "name": "42cursus", 324 | "slug": "42cursus" 325 | } 326 | }, 327 | { 328 | "id": 614, 329 | "lvl": 25, 330 | "xp": 777756, 331 | "cursus_id": 21, 332 | "created_at": "2019-07-29T08:45:11.000Z", 333 | "cursus": { 334 | "id": 21, 335 | "created_at": "2019-07-29T08:45:17.896Z", 336 | "name": "42cursus", 337 | "slug": "42cursus" 338 | } 339 | }, 340 | { 341 | "id": 615, 342 | "lvl": 26, 343 | "xp": 886074, 344 | "cursus_id": 21, 345 | "created_at": "2019-07-29T08:45:11.000Z", 346 | "cursus": { 347 | "id": 21, 348 | "created_at": "2019-07-29T08:45:17.896Z", 349 | "name": "42cursus", 350 | "slug": "42cursus" 351 | } 352 | }, 353 | { 354 | "id": 616, 355 | "lvl": 27, 356 | "xp": 1008798, 357 | "cursus_id": 21, 358 | "created_at": "2019-07-29T08:45:11.000Z", 359 | "cursus": { 360 | "id": 21, 361 | "created_at": "2019-07-29T08:45:17.896Z", 362 | "name": "42cursus", 363 | "slug": "42cursus" 364 | } 365 | }, 366 | { 367 | "id": 617, 368 | "lvl": 28, 369 | "xp": 1147902, 370 | "cursus_id": 21, 371 | "created_at": "2019-07-29T08:45:11.000Z", 372 | "cursus": { 373 | "id": 21, 374 | "created_at": "2019-07-29T08:45:17.896Z", 375 | "name": "42cursus", 376 | "slug": "42cursus" 377 | } 378 | }, 379 | { 380 | "id": 618, 381 | "lvl": 29, 382 | "xp": 1305486, 383 | "cursus_id": 21, 384 | "created_at": "2019-07-29T08:45:11.000Z", 385 | "cursus": { 386 | "id": 21, 387 | "created_at": "2019-07-29T08:45:17.896Z", 388 | "name": "42cursus", 389 | "slug": "42cursus" 390 | } 391 | }, 392 | { 393 | "id": 619, 394 | "lvl": 30, 395 | "xp": 1484070, 396 | "cursus_id": 21, 397 | "created_at": "2019-07-29T08:45:11.000Z", 398 | "cursus": { 399 | "id": 21, 400 | "created_at": "2019-07-29T08:45:17.896Z", 401 | "name": "42cursus", 402 | "slug": "42cursus" 403 | } 404 | } 405 | ] 406 | -------------------------------------------------------------------------------- /internal/api/api.go: -------------------------------------------------------------------------------- 1 | package api 2 | 3 | import ( 4 | "context" 5 | "encoding/json" 6 | "errors" 7 | "fmt" 8 | "io" 9 | "maps" 10 | "net/http" 11 | "strconv" 12 | "strings" 13 | "sync" 14 | "time" 15 | 16 | "github.com/demostanis/42evaluators/internal/models" 17 | "gorm.io/gorm" 18 | 19 | "golang.org/x/sync/semaphore" 20 | ) 21 | 22 | const ( 23 | defaultPageSize = 100 24 | defaultMaxConcurrentFetches = 50 25 | apiURL = "https://api.intra.42.fr" 26 | ) 27 | 28 | type ParseError struct { 29 | err error 30 | body []byte 31 | } 32 | 33 | func (parseError ParseError) Error() string { 34 | return fmt.Sprintf("failed to parse body: %v (%s)", 35 | parseError.err, parseError.body) 36 | } 37 | 38 | type APIRequest struct { 39 | method string 40 | endpoint string 41 | headers map[string]string 42 | params map[string]string 43 | outputHeadersIn **http.Header 44 | authenticated bool 45 | authenticatedAs string 46 | maxConcurrentFetches int64 47 | pageSize string 48 | startingDate time.Time 49 | } 50 | 51 | func NewRequest(endpoint string) *APIRequest { 52 | return &APIRequest{ 53 | method: "GET", 54 | endpoint: endpoint, 55 | headers: map[string]string{}, 56 | params: map[string]string{}, 57 | outputHeadersIn: nil, 58 | authenticated: false, 59 | authenticatedAs: "", 60 | maxConcurrentFetches: defaultMaxConcurrentFetches, 61 | pageSize: strconv.Itoa(defaultPageSize), 62 | } 63 | } 64 | 65 | func (apiReq *APIRequest) Authenticated() *APIRequest { 66 | apiReq.authenticated = true 67 | return apiReq 68 | } 69 | 70 | func (apiReq *APIRequest) AuthenticatedAs(accessToken string) *APIRequest { 71 | apiReq.authenticatedAs = accessToken 72 | return apiReq 73 | } 74 | 75 | func (apiReq *APIRequest) WithMethod(method string) *APIRequest { 76 | apiReq.method = method 77 | return apiReq 78 | } 79 | 80 | func (apiReq *APIRequest) WithHeaders(headers map[string]string) *APIRequest { 81 | maps.Copy(apiReq.headers, headers) 82 | return apiReq 83 | } 84 | 85 | func (apiReq *APIRequest) WithParams(params map[string]string) *APIRequest { 86 | maps.Copy(apiReq.params, params) 87 | return apiReq 88 | } 89 | 90 | func (apiReq *APIRequest) WithMaxConcurrentFetches(n int64) *APIRequest { 91 | apiReq.maxConcurrentFetches = n 92 | return apiReq 93 | } 94 | 95 | func (apiReq *APIRequest) WithPageSize(n int) *APIRequest { 96 | apiReq.pageSize = strconv.Itoa(n) 97 | return apiReq 98 | } 99 | 100 | func (apiReq *APIRequest) SinceLastFetch(db *gorm.DB, defaultTime time.Time) *APIRequest { 101 | var startingDate time.Time 102 | 103 | column := apiReq.endpoint[strings.LastIndexByte(apiReq.endpoint, '/')+1:] 104 | now := time.Now() 105 | err := db. 106 | Limit(1). 107 | Select(column). 108 | Table("request_timestamps"). 109 | Find(&startingDate).Error 110 | if err != nil || startingDate.IsZero() { 111 | db.Exec(`CREATE TABLE IF NOT EXISTS request_timestamps ()`) 112 | db.Exec(`ALTER TABLE request_timestamps 113 | ADD IF NOT EXISTS ` + column + ` timestamp`) 114 | db.Exec(`INSERT INTO request_timestamps(`+column+`) 115 | VALUES (?)`, now) 116 | } 117 | db.Exec(`UPDATE request_timestamps SET `+column+` = ?`, 118 | now) 119 | 120 | if startingDate.IsZero() { 121 | startingDate = defaultTime 122 | } 123 | apiReq.startingDate = startingDate 124 | return apiReq 125 | } 126 | 127 | func (apiReq *APIRequest) OutputHeadersIn(output **http.Header) *APIRequest { 128 | apiReq.outputHeadersIn = output 129 | return apiReq 130 | } 131 | 132 | func shouldRegenerateKey(resp *http.Response) bool { 133 | return resp.StatusCode == http.StatusTooManyRequests || 134 | resp.StatusCode == http.StatusUnauthorized 135 | } 136 | 137 | func Do[T any](apiReq *APIRequest) (*T, error) { 138 | var client *RLHTTPClient 139 | 140 | if apiReq.authenticated { 141 | if len(clients) == 0 { 142 | return nil, errors.New("no clients available") 143 | } 144 | var targetTarget *Target 145 | for _, target := range targets { 146 | for _, url := range target.URLs { 147 | if strings.HasPrefix(apiReq.endpoint, url) { 148 | targetTarget = &target 149 | goto end 150 | } 151 | } 152 | } 153 | end: 154 | if targetTarget == nil { 155 | return nil, fmt.Errorf("no target for request %s", apiReq.endpoint) 156 | } 157 | client = findNonRateLimitedClientFor(*targetTarget) 158 | } else { 159 | client = RateLimitedClient("", models.APIKey{}) 160 | } 161 | 162 | req, err := http.NewRequest(apiReq.method, apiURL+apiReq.endpoint, nil) 163 | if err != nil { 164 | return nil, err 165 | } 166 | 167 | q := req.URL.Query() 168 | if !apiReq.startingDate.IsZero() { 169 | startingDateStr := apiReq.startingDate.Format(time.RFC3339) 170 | nowStr := time.Now().Format(time.RFC3339) 171 | q.Add("range[updated_at]", startingDateStr+","+nowStr) 172 | } 173 | 174 | for key, value := range apiReq.params { 175 | q.Add(key, value) 176 | } 177 | req.URL.RawQuery = q.Encode() 178 | 179 | for key, value := range apiReq.headers { 180 | req.Header.Add(key, value) 181 | } 182 | 183 | if apiReq.authenticated { 184 | req.Header.Add("Authorization", "Bearer "+client.accessToken) 185 | } else if apiReq.authenticatedAs != "" { 186 | req.Header.Add("Authorization", "Bearer "+apiReq.authenticatedAs) 187 | } 188 | 189 | DebugRequest(req) 190 | resp, err := client.Do(req) 191 | if err != nil { 192 | return nil, err 193 | } 194 | if shouldRegenerateKey(resp) { 195 | time.Sleep(time.Second * 1) 196 | 197 | resp, err = client.Do(req) 198 | if err != nil { 199 | return nil, err 200 | } 201 | if shouldRegenerateKey(resp) { 202 | fmt.Println("generating new API key...") 203 | 204 | oldAPIKey := client.apiKey 205 | 206 | apiKey, err := DefaultKeysManager.CreateOne() 207 | if err != nil { // Damn, that'd suck. 208 | return nil, err 209 | } 210 | client.apiKey = *apiKey 211 | client.accessToken, err = OauthToken(client.apiKey, "", "") 212 | if err != nil { 213 | return nil, err 214 | } 215 | 216 | _ = DefaultKeysManager.DeleteOne(oldAPIKey.ID) 217 | 218 | req.Header.Del("Authorization") 219 | req.Header.Add("Authorization", "Bearer "+client.accessToken) 220 | 221 | resp, err = client.Do(req) 222 | // that probably shouldn't be needed since 223 | // it's handled below, but I get linter errors... 224 | if err != nil { 225 | return nil, err 226 | } 227 | } 228 | } 229 | if err != nil { 230 | return nil, err 231 | } 232 | defer resp.Body.Close() 233 | DebugResponse(resp) 234 | 235 | body, err := io.ReadAll(resp.Body) 236 | if err != nil { 237 | return nil, err 238 | } 239 | 240 | if apiReq.outputHeadersIn != nil { 241 | *apiReq.outputHeadersIn = &resp.Header 242 | } 243 | 244 | var result T 245 | err = json.Unmarshal(body, &result) 246 | if err != nil { 247 | return nil, &ParseError{err, body} 248 | } 249 | return &result, nil 250 | } 251 | 252 | type PageCountError struct { 253 | err error 254 | } 255 | 256 | func (pageCountErr PageCountError) Error() string { 257 | return fmt.Sprintf("failed to get page count: %v", pageCountErr.err) 258 | } 259 | 260 | func getPageCount(apiReq *APIRequest) (int, error) { 261 | params := make(map[string]string) 262 | params["page[size]"] = apiReq.pageSize 263 | 264 | var headers *http.Header 265 | apiReqCopy := *apiReq 266 | newReq := &apiReqCopy 267 | newReq = newReq. 268 | WithMethod("HEAD"). 269 | WithParams(params). 270 | OutputHeadersIn(&headers) 271 | _, err := Do[any](newReq) 272 | 273 | var parseError *ParseError 274 | // we don't care about JSON parsing errors, since 275 | // since HEAD requests aren't supposed to have content 276 | if err != nil && !errors.As(err, &parseError) { 277 | return 0, PageCountError{err} 278 | } 279 | if headers == nil { 280 | return 0, PageCountError{errors.New("response did not contain any headers")} 281 | } 282 | total, err := strconv.Atoi(headers.Get("X-Total")) 283 | if err != nil { 284 | return 0, PageCountError{errors.New("no X-Total in response")} 285 | } 286 | perPage, err := strconv.Atoi(headers.Get("X-Per-Page")) 287 | if err != nil { 288 | return 0, PageCountError{errors.New("no X-Per-Page in response")} 289 | } 290 | return 1 + (total-1)/perPage, nil 291 | } 292 | 293 | func DoPaginated[T []E, E any](apiReq *APIRequest) (chan func() (*E, error), error) { 294 | resps := make(chan func() (*E, error)) 295 | pageCount, err := getPageCount(apiReq) 296 | if err != nil { 297 | return resps, err 298 | } 299 | 300 | APIStats.growTotalRequests(pageCount) 301 | fmt.Printf("fetching %d pages in %s...\n", 302 | pageCount, apiReq.endpoint) 303 | 304 | var weights *semaphore.Weighted 305 | if apiReq.maxConcurrentFetches != 0 { 306 | weights = semaphore.NewWeighted(apiReq.maxConcurrentFetches) 307 | } 308 | go func() { 309 | var wg sync.WaitGroup 310 | for i := 1; i <= pageCount; i++ { 311 | if weights != nil { 312 | err = weights.Acquire(context.Background(), 1) 313 | if err != nil { 314 | resps <- func() (*E, error) { return nil, err } 315 | return 316 | } 317 | } 318 | wg.Add(1) 319 | go func(i int) { 320 | defer APIStats.requestDone() 321 | 322 | newReq := *apiReq 323 | newReq.params = maps.Clone(newReq.params) 324 | newReq.params["page[number]"] = strconv.Itoa(i) 325 | if apiReq.pageSize != "" { 326 | newReq.params["page[size]"] = apiReq.pageSize 327 | } 328 | 329 | elems, err := Do[T](&newReq) 330 | if err != nil { 331 | resps <- func() (*E, error) { return nil, err } 332 | } else { 333 | for _, elem := range *elems { 334 | func(elem E) { 335 | resps <- func() (*E, error) { return &elem, nil } 336 | }(elem) 337 | } 338 | } 339 | if weights != nil { 340 | weights.Release(1) 341 | } 342 | wg.Done() 343 | }(i) 344 | } 345 | 346 | wg.Wait() 347 | // To indicate every page has been fetched 348 | resps <- func() (*E, error) { return nil, nil } 349 | }() 350 | 351 | return resps, nil 352 | } 353 | -------------------------------------------------------------------------------- /web/static/assets/LineSegments2.js: -------------------------------------------------------------------------------- 1 | import { 2 | Box3, 3 | InstancedInterleavedBuffer, 4 | InterleavedBufferAttribute, 5 | Line3, 6 | MathUtils, 7 | Matrix4, 8 | Mesh, 9 | Sphere, 10 | Vector3, 11 | Vector4 12 | } from 'three'; 13 | import { LineSegmentsGeometry } from 'three/addons/LineSegmentsGeometry.js'; 14 | import { LineMaterial } from 'three/addons/LineMaterial.js'; 15 | 16 | const _start = new Vector3(); 17 | const _end = new Vector3(); 18 | 19 | const _start4 = new Vector4(); 20 | const _end4 = new Vector4(); 21 | 22 | const _ssOrigin = new Vector4(); 23 | const _ssOrigin3 = new Vector3(); 24 | const _mvMatrix = new Matrix4(); 25 | const _line = new Line3(); 26 | const _closestPoint = new Vector3(); 27 | 28 | const _box = new Box3(); 29 | const _sphere = new Sphere(); 30 | const _clipToWorldVector = new Vector4(); 31 | 32 | let _ray, _lineWidth; 33 | 34 | // Returns the margin required to expand by in world space given the distance from the camera, 35 | // line width, resolution, and camera projection 36 | function getWorldSpaceHalfWidth( camera, distance, resolution ) { 37 | 38 | // transform into clip space, adjust the x and y values by the pixel width offset, then 39 | // transform back into world space to get world offset. Note clip space is [-1, 1] so full 40 | // width does not need to be halved. 41 | _clipToWorldVector.set( 0, 0, - distance, 1.0 ).applyMatrix4( camera.projectionMatrix ); 42 | _clipToWorldVector.multiplyScalar( 1.0 / _clipToWorldVector.w ); 43 | _clipToWorldVector.x = _lineWidth / resolution.width; 44 | _clipToWorldVector.y = _lineWidth / resolution.height; 45 | _clipToWorldVector.applyMatrix4( camera.projectionMatrixInverse ); 46 | _clipToWorldVector.multiplyScalar( 1.0 / _clipToWorldVector.w ); 47 | 48 | return Math.abs( Math.max( _clipToWorldVector.x, _clipToWorldVector.y ) ); 49 | 50 | } 51 | 52 | function raycastWorldUnits( lineSegments, intersects ) { 53 | 54 | const matrixWorld = lineSegments.matrixWorld; 55 | const geometry = lineSegments.geometry; 56 | const instanceStart = geometry.attributes.instanceStart; 57 | const instanceEnd = geometry.attributes.instanceEnd; 58 | const segmentCount = Math.min( geometry.instanceCount, instanceStart.count ); 59 | 60 | for ( let i = 0, l = segmentCount; i < l; i ++ ) { 61 | 62 | _line.start.fromBufferAttribute( instanceStart, i ); 63 | _line.end.fromBufferAttribute( instanceEnd, i ); 64 | 65 | _line.applyMatrix4( matrixWorld ); 66 | 67 | const pointOnLine = new Vector3(); 68 | const point = new Vector3(); 69 | 70 | _ray.distanceSqToSegment( _line.start, _line.end, point, pointOnLine ); 71 | const isInside = point.distanceTo( pointOnLine ) < _lineWidth * 0.5; 72 | 73 | if ( isInside ) { 74 | 75 | intersects.push( { 76 | point, 77 | pointOnLine, 78 | distance: _ray.origin.distanceTo( point ), 79 | object: lineSegments, 80 | face: null, 81 | faceIndex: i, 82 | uv: null, 83 | uv1: null, 84 | } ); 85 | 86 | } 87 | 88 | } 89 | 90 | } 91 | 92 | function raycastScreenSpace( lineSegments, camera, intersects ) { 93 | 94 | const projectionMatrix = camera.projectionMatrix; 95 | const material = lineSegments.material; 96 | const resolution = material.resolution; 97 | const matrixWorld = lineSegments.matrixWorld; 98 | 99 | const geometry = lineSegments.geometry; 100 | const instanceStart = geometry.attributes.instanceStart; 101 | const instanceEnd = geometry.attributes.instanceEnd; 102 | const segmentCount = Math.min( geometry.instanceCount, instanceStart.count ); 103 | 104 | const near = - camera.near; 105 | 106 | // 107 | 108 | // pick a point 1 unit out along the ray to avoid the ray origin 109 | // sitting at the camera origin which will cause "w" to be 0 when 110 | // applying the projection matrix. 111 | _ray.at( 1, _ssOrigin ); 112 | 113 | // ndc space [ - 1.0, 1.0 ] 114 | _ssOrigin.w = 1; 115 | _ssOrigin.applyMatrix4( camera.matrixWorldInverse ); 116 | _ssOrigin.applyMatrix4( projectionMatrix ); 117 | _ssOrigin.multiplyScalar( 1 / _ssOrigin.w ); 118 | 119 | // screen space 120 | _ssOrigin.x *= resolution.x / 2; 121 | _ssOrigin.y *= resolution.y / 2; 122 | _ssOrigin.z = 0; 123 | 124 | _ssOrigin3.copy( _ssOrigin ); 125 | 126 | _mvMatrix.multiplyMatrices( camera.matrixWorldInverse, matrixWorld ); 127 | 128 | for ( let i = 0, l = segmentCount; i < l; i ++ ) { 129 | 130 | _start4.fromBufferAttribute( instanceStart, i ); 131 | _end4.fromBufferAttribute( instanceEnd, i ); 132 | 133 | _start4.w = 1; 134 | _end4.w = 1; 135 | 136 | // camera space 137 | _start4.applyMatrix4( _mvMatrix ); 138 | _end4.applyMatrix4( _mvMatrix ); 139 | 140 | // skip the segment if it's entirely behind the camera 141 | const isBehindCameraNear = _start4.z > near && _end4.z > near; 142 | if ( isBehindCameraNear ) { 143 | 144 | continue; 145 | 146 | } 147 | 148 | // trim the segment if it extends behind camera near 149 | if ( _start4.z > near ) { 150 | 151 | const deltaDist = _start4.z - _end4.z; 152 | const t = ( _start4.z - near ) / deltaDist; 153 | _start4.lerp( _end4, t ); 154 | 155 | } else if ( _end4.z > near ) { 156 | 157 | const deltaDist = _end4.z - _start4.z; 158 | const t = ( _end4.z - near ) / deltaDist; 159 | _end4.lerp( _start4, t ); 160 | 161 | } 162 | 163 | // clip space 164 | _start4.applyMatrix4( projectionMatrix ); 165 | _end4.applyMatrix4( projectionMatrix ); 166 | 167 | // ndc space [ - 1.0, 1.0 ] 168 | _start4.multiplyScalar( 1 / _start4.w ); 169 | _end4.multiplyScalar( 1 / _end4.w ); 170 | 171 | // screen space 172 | _start4.x *= resolution.x / 2; 173 | _start4.y *= resolution.y / 2; 174 | 175 | _end4.x *= resolution.x / 2; 176 | _end4.y *= resolution.y / 2; 177 | 178 | // create 2d segment 179 | _line.start.copy( _start4 ); 180 | _line.start.z = 0; 181 | 182 | _line.end.copy( _end4 ); 183 | _line.end.z = 0; 184 | 185 | // get closest point on ray to segment 186 | const param = _line.closestPointToPointParameter( _ssOrigin3, true ); 187 | _line.at( param, _closestPoint ); 188 | 189 | // check if the intersection point is within clip space 190 | const zPos = MathUtils.lerp( _start4.z, _end4.z, param ); 191 | const isInClipSpace = zPos >= - 1 && zPos <= 1; 192 | 193 | const isInside = _ssOrigin3.distanceTo( _closestPoint ) < _lineWidth * 0.5; 194 | 195 | if ( isInClipSpace && isInside ) { 196 | 197 | _line.start.fromBufferAttribute( instanceStart, i ); 198 | _line.end.fromBufferAttribute( instanceEnd, i ); 199 | 200 | _line.start.applyMatrix4( matrixWorld ); 201 | _line.end.applyMatrix4( matrixWorld ); 202 | 203 | const pointOnLine = new Vector3(); 204 | const point = new Vector3(); 205 | 206 | _ray.distanceSqToSegment( _line.start, _line.end, point, pointOnLine ); 207 | 208 | intersects.push( { 209 | point: point, 210 | pointOnLine: pointOnLine, 211 | distance: _ray.origin.distanceTo( point ), 212 | object: lineSegments, 213 | face: null, 214 | faceIndex: i, 215 | uv: null, 216 | uv1: null, 217 | } ); 218 | 219 | } 220 | 221 | } 222 | 223 | } 224 | 225 | class LineSegments2 extends Mesh { 226 | 227 | constructor( geometry = new LineSegmentsGeometry(), material = new LineMaterial( { color: Math.random() * 0xffffff } ) ) { 228 | 229 | super( geometry, material ); 230 | 231 | this.isLineSegments2 = true; 232 | 233 | this.type = 'LineSegments2'; 234 | 235 | } 236 | 237 | // for backwards-compatibility, but could be a method of LineSegmentsGeometry... 238 | 239 | computeLineDistances() { 240 | 241 | const geometry = this.geometry; 242 | 243 | const instanceStart = geometry.attributes.instanceStart; 244 | const instanceEnd = geometry.attributes.instanceEnd; 245 | const lineDistances = new Float32Array( 2 * instanceStart.count ); 246 | 247 | for ( let i = 0, j = 0, l = instanceStart.count; i < l; i ++, j += 2 ) { 248 | 249 | _start.fromBufferAttribute( instanceStart, i ); 250 | _end.fromBufferAttribute( instanceEnd, i ); 251 | 252 | lineDistances[ j ] = ( j === 0 ) ? 0 : lineDistances[ j - 1 ]; 253 | lineDistances[ j + 1 ] = lineDistances[ j ] + _start.distanceTo( _end ); 254 | 255 | } 256 | 257 | const instanceDistanceBuffer = new InstancedInterleavedBuffer( lineDistances, 2, 1 ); // d0, d1 258 | 259 | geometry.setAttribute( 'instanceDistanceStart', new InterleavedBufferAttribute( instanceDistanceBuffer, 1, 0 ) ); // d0 260 | geometry.setAttribute( 'instanceDistanceEnd', new InterleavedBufferAttribute( instanceDistanceBuffer, 1, 1 ) ); // d1 261 | 262 | return this; 263 | 264 | } 265 | 266 | raycast( raycaster, intersects ) { 267 | 268 | const worldUnits = this.material.worldUnits; 269 | const camera = raycaster.camera; 270 | 271 | if ( camera === null && ! worldUnits ) { 272 | 273 | console.error( 'LineSegments2: "Raycaster.camera" needs to be set in order to raycast against LineSegments2 while worldUnits is set to false.' ); 274 | 275 | } 276 | 277 | const threshold = ( raycaster.params.Line2 !== undefined ) ? raycaster.params.Line2.threshold || 0 : 0; 278 | 279 | _ray = raycaster.ray; 280 | 281 | const matrixWorld = this.matrixWorld; 282 | const geometry = this.geometry; 283 | const material = this.material; 284 | 285 | _lineWidth = material.linewidth + threshold; 286 | 287 | // check if we intersect the sphere bounds 288 | if ( geometry.boundingSphere === null ) { 289 | 290 | geometry.computeBoundingSphere(); 291 | 292 | } 293 | 294 | _sphere.copy( geometry.boundingSphere ).applyMatrix4( matrixWorld ); 295 | 296 | // increase the sphere bounds by the worst case line screen space width 297 | let sphereMargin; 298 | if ( worldUnits ) { 299 | 300 | sphereMargin = _lineWidth * 0.5; 301 | 302 | } else { 303 | 304 | const distanceToSphere = Math.max( camera.near, _sphere.distanceToPoint( _ray.origin ) ); 305 | sphereMargin = getWorldSpaceHalfWidth( camera, distanceToSphere, material.resolution ); 306 | 307 | } 308 | 309 | _sphere.radius += sphereMargin; 310 | 311 | if ( _ray.intersectsSphere( _sphere ) === false ) { 312 | 313 | return; 314 | 315 | } 316 | 317 | // check if we intersect the box bounds 318 | if ( geometry.boundingBox === null ) { 319 | 320 | geometry.computeBoundingBox(); 321 | 322 | } 323 | 324 | _box.copy( geometry.boundingBox ).applyMatrix4( matrixWorld ); 325 | 326 | // increase the box bounds by the worst case line width 327 | let boxMargin; 328 | if ( worldUnits ) { 329 | 330 | boxMargin = _lineWidth * 0.5; 331 | 332 | } else { 333 | 334 | const distanceToBox = Math.max( camera.near, _box.distanceToPoint( _ray.origin ) ); 335 | boxMargin = getWorldSpaceHalfWidth( camera, distanceToBox, material.resolution ); 336 | 337 | } 338 | 339 | _box.expandByScalar( boxMargin ); 340 | 341 | if ( _ray.intersectsBox( _box ) === false ) { 342 | 343 | return; 344 | 345 | } 346 | 347 | if ( worldUnits ) { 348 | 349 | raycastWorldUnits( this, intersects ); 350 | 351 | } else { 352 | 353 | raycastScreenSpace( this, camera, intersects ); 354 | 355 | } 356 | 357 | } 358 | 359 | } 360 | 361 | export { LineSegments2 }; 362 | -------------------------------------------------------------------------------- /go.sum: -------------------------------------------------------------------------------- 1 | filippo.io/edwards25519 v1.1.0 h1:FNf4tywRC1HmFuKW5xopWpigGjJKiJSV0Cqo0cJWDaA= 2 | filippo.io/edwards25519 v1.1.0/go.mod h1:BxyFTGdWcka3PhytdK4V28tE5sGfRvvvRV7EaN4VDT4= 3 | github.com/PuerkitoBio/goquery v1.9.1 h1:mTL6XjbJTZdpfL+Gwl5U2h1l9yEkJjhmlTeV9VPW7UI= 4 | github.com/PuerkitoBio/goquery v1.9.1/go.mod h1:cW1n6TmIMDoORQU5IU/P1T3tGFunOeXEpGP2WHRwkbY= 5 | github.com/a-h/templ v0.2.663 h1:aa0WMm27InkYHGjimcM7us6hJ6BLhg98ZbfaiDPyjHE= 6 | github.com/a-h/templ v0.2.663/go.mod h1:SA7mtYwVEajbIXFRh3vKdYm/4FYyLQAtPH1+KxzGPA8= 7 | github.com/andybalholm/brotli v1.1.0 h1:eLKJA0d02Lf0mVpIDgYnqXcUn0GqVmEFny3VuID1U3M= 8 | github.com/andybalholm/brotli v1.1.0/go.mod h1:sms7XGricyQI9K10gOSf56VKKWS4oLer58Q+mhRPtnY= 9 | github.com/andybalholm/cascadia v1.3.2 h1:3Xi6Dw5lHF15JtdcmAHD3i1+T8plmv7BQ/nsViSLyss= 10 | github.com/andybalholm/cascadia v1.3.2/go.mod h1:7gtRlve5FxPPgIgX36uWBX58OdBsSS6lUvCFb+h7KvU= 11 | github.com/bogdanfinn/fhttp v0.5.28 h1:G6thT8s8v6z1IuvXMUsX9QKy3ZHseTQTzxuIhSiaaAw= 12 | github.com/bogdanfinn/fhttp v0.5.28/go.mod h1:oJiYPG3jQTKzk/VFmogH8jxjH5yiv2rrOH48Xso2lrE= 13 | github.com/bogdanfinn/tls-client v1.7.4 h1:8cn2/egs0LmqA7RFgrQh9Ww074lyQkeMKmsYl9JluTQ= 14 | github.com/bogdanfinn/tls-client v1.7.4/go.mod h1:pQwF0eqfL0gf0mu8hikvu6deZ3ijSPruJDzEKEnnXjU= 15 | github.com/bogdanfinn/utls v1.6.1 h1:dKDYAcXEyFFJ3GaWaN89DEyjyRraD1qb4osdEK89ass= 16 | github.com/bogdanfinn/utls v1.6.1/go.mod h1:VXIbRZaiY/wHZc6Hu+DZ4O2CgTzjhjCg/Ou3V4r/39Y= 17 | github.com/cloudflare/circl v1.3.7 h1:qlCDlTPz2n9fu58M0Nh1J/JzcFpfgkFHHX3O35r5vcU= 18 | github.com/cloudflare/circl v1.3.7/go.mod h1:sRTcRWXGLrKw6yIGJ+l7amYJFfAXbZG0kBSc8r4zxgA= 19 | github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 20 | github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= 21 | github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 22 | github.com/go-co-op/gocron/v2 v2.2.9 h1:aoKosYWSSdXFLecjFWX1i8+R6V7XdZb8sB2ZKAY5Yis= 23 | github.com/go-co-op/gocron/v2 v2.2.9/go.mod h1:mZx3gMSlFnb97k3hRqX3+GdlG3+DUwTh6B8fnsTScXg= 24 | github.com/go-logr/logr v1.2.4 h1:g01GSCwiDw2xSZfjJ2/T9M+S6pFdcNtFYsp+Y43HYDQ= 25 | github.com/go-logr/logr v1.2.4/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A= 26 | github.com/go-sql-driver/mysql v1.7.0/go.mod h1:OXbVy3sEdcQ2Doequ6Z5BW6fXNQTmx+9S1MCJN5yJMI= 27 | github.com/go-sql-driver/mysql v1.8.1 h1:LedoTUt/eveggdHS9qUFC1EFSa8bU2+1pZjSRpvNJ1Y= 28 | github.com/go-sql-driver/mysql v1.8.1/go.mod h1:wEBSXgmK//2ZFJyE+qWnIsVGmvmEKlqwuVSjsCm7DZg= 29 | github.com/go-task/slim-sprig v0.0.0-20230315185526-52ccab3ef572 h1:tfuBGBXKqDEevZMzYi5KSi8KkcZtzBcTgAUUtapy0OI= 30 | github.com/go-task/slim-sprig v0.0.0-20230315185526-52ccab3ef572/go.mod h1:9Pwr4B2jHnOSGXyyzV8ROjYa2ojvAY6HCGYYfMoC3Ls= 31 | github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI= 32 | github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= 33 | github.com/google/pprof v0.0.0-20210407192527-94a9f03dee38 h1:yAJXTCF9TqKcTiHJAE8dj7HMvPfh66eeA2JYW7eFpSE= 34 | github.com/google/pprof v0.0.0-20210407192527-94a9f03dee38/go.mod h1:kpwsk12EmLew5upagYY7GY0pfYCcupk39gWOCRROcvE= 35 | github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= 36 | github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= 37 | github.com/gorilla/websocket v1.5.1 h1:gmztn0JnHVt9JZquRuzLw3g4wouNVzKL15iLr/zn/QY= 38 | github.com/gorilla/websocket v1.5.1/go.mod h1:x3kM2JMyaluk02fnUJpQuwD2dCS5NDG2ZHL0uE0tcaY= 39 | github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM= 40 | github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg= 41 | github.com/jackc/pgservicefile v0.0.0-20221227161230-091c0ba34f0a h1:bbPeKD0xmW/Y25WS6cokEszi5g+S0QxI/d45PkRi7Nk= 42 | github.com/jackc/pgservicefile v0.0.0-20221227161230-091c0ba34f0a/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM= 43 | github.com/jackc/pgx/v5 v5.4.3 h1:cxFyXhxlvAifxnkKKdlxv8XqUf59tDlYjnV5YYfsJJY= 44 | github.com/jackc/pgx/v5 v5.4.3/go.mod h1:Ig06C2Vu0t5qXC60W8sqIthScaEnFvojjj9dSljmHRA= 45 | github.com/jinzhu/inflection v1.0.0 h1:K317FqzuhWc8YvSVlFMCCUb36O/S9MCKRDI7QkRKD/E= 46 | github.com/jinzhu/inflection v1.0.0/go.mod h1:h+uFLlag+Qp1Va5pdKtLDYj+kHp5pxUVkryuEj+Srlc= 47 | github.com/jinzhu/now v1.1.5 h1:/o9tlHleP7gOFmsnYNz3RGnqzefHA47wQpKrrdTIwXQ= 48 | github.com/jinzhu/now v1.1.5/go.mod h1:d3SSVoowX0Lcu0IBviAWJpolVfI5UJVZZ7cO71lE/z8= 49 | github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0= 50 | github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4= 51 | github.com/jonboulle/clockwork v0.4.0 h1:p4Cf1aMWXnXAUh8lVfewRBx1zaTSYKrKMF2g3ST4RZ4= 52 | github.com/jonboulle/clockwork v0.4.0/go.mod h1:xgRqUGwRcjKCO1vbZUEtSLrqKoPSsUpK7fnezOII0kc= 53 | github.com/klauspost/compress v1.17.8 h1:YcnTYrq7MikUT7k0Yb5eceMmALQPYBW/Xltxn0NAMnU= 54 | github.com/klauspost/compress v1.17.8/go.mod h1:Di0epgTjJY877eYKx5yC51cX2A2Vl2ibi7bDH9ttBbw= 55 | github.com/onsi/ginkgo/v2 v2.9.5 h1:+6Hr4uxzP4XIUyAkg61dWBw8lb/gc4/X5luuxN/EC+Q= 56 | github.com/onsi/ginkgo/v2 v2.9.5/go.mod h1:tvAoo1QUJwNEU2ITftXTpR7R1RbCzoZUOs3RonqW57k= 57 | github.com/onsi/gomega v1.27.6 h1:ENqfyGeS5AX/rlXDd/ETokDz93u0YufY1Pgxuy/PvWE= 58 | github.com/onsi/gomega v1.27.6/go.mod h1:PIQNjfQwkP3aQAH7lf7j87O/5FiNr+ZR8+ipb+qQlhg= 59 | github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= 60 | github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= 61 | github.com/quic-go/quic-go v0.42.0 h1:uSfdap0eveIl8KXnipv9K7nlwZ5IqLlYOpJ58u5utpM= 62 | github.com/quic-go/quic-go v0.42.0/go.mod h1:132kz4kL3F9vxhW3CtQJLDVwcFe5wdWeJXXijhsO57M= 63 | github.com/robfig/cron/v3 v3.0.1 h1:WdRxkvbJztn8LMz/QEvLN5sBU+xKpSqwwUO1Pjr4qDs= 64 | github.com/robfig/cron/v3 v3.0.1/go.mod h1:eQICP3HwyT7UooqI/z+Ov+PtYAWygg1TEWWzGIFLtro= 65 | github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= 66 | github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= 67 | github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= 68 | github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg= 69 | github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= 70 | github.com/tam7t/hpkp v0.0.0-20160821193359-2b70b4024ed5 h1:YqAladjX7xpA6BM04leXMWAEjS0mTZ5kUU9KRBriQJc= 71 | github.com/tam7t/hpkp v0.0.0-20160821193359-2b70b4024ed5/go.mod h1:2JjD2zLQYH5HO74y5+aE3remJQvl6q4Sn6aWA2wD1Ng= 72 | github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= 73 | go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto= 74 | go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE= 75 | golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= 76 | golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= 77 | golang.org/x/crypto v0.22.0 h1:g1v0xeRhjcugydODzvb3mEM9SQ0HGp9s/nh3COQ/C30= 78 | golang.org/x/crypto v0.22.0/go.mod h1:vr6Su+7cTlO45qkww3VDJlzDn0ctJvRgYbC2NvXHt+M= 79 | golang.org/x/exp v0.0.0-20240409090435-93d18d7e34b8 h1:ESSUROHIBHg7USnszlcdmjBEwdMj9VUvU+OPk4yl2mc= 80 | golang.org/x/exp v0.0.0-20240409090435-93d18d7e34b8/go.mod h1:/lliqkxwWAhPjf5oSOIJup2XcqJaw8RGS6k3TGEc7GI= 81 | golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4= 82 | golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= 83 | golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= 84 | golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= 85 | golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c= 86 | golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs= 87 | golang.org/x/net v0.9.0/go.mod h1:d48xBJpPfHeWQsugry2m+kC02ZBRGRgulfHnEXEuWns= 88 | golang.org/x/net v0.24.0 h1:1PcaxkF854Fu3+lvBIx5SYn9wRlBzzcnHZSiaFFAb0w= 89 | golang.org/x/net v0.24.0/go.mod h1:2Q7sJY5mzlzWjKtYUEXSlBWCdyaioyXzRB2RtU8KVE8= 90 | golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= 91 | golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= 92 | golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= 93 | golang.org/x/sync v0.7.0 h1:YsImfSBoP9QPYL0xyKJPq0gcaJdG3rInoqxTWbfQu9M= 94 | golang.org/x/sync v0.7.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= 95 | golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= 96 | golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 97 | golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 98 | golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 99 | golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 100 | golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 101 | golang.org/x/sys v0.7.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 102 | golang.org/x/sys v0.19.0 h1:q5f1RH2jigJ1MoAWp2KTp3gm5zAGFUTarQZ5U386+4o= 103 | golang.org/x/sys v0.19.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= 104 | golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= 105 | golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= 106 | golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k= 107 | golang.org/x/term v0.7.0/go.mod h1:P32HKFT3hSsZrRxla30E9HqToFYAQPCMs/zFMBUFqPY= 108 | golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= 109 | golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= 110 | golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= 111 | golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= 112 | golang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8= 113 | golang.org/x/text v0.14.0 h1:ScX5w1eTa3QqT8oi6+ziP7dTV1S2+ALU0bI+0zXKWiQ= 114 | golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= 115 | golang.org/x/time v0.5.0 h1:o7cqy6amK/52YcAKIPlM3a+Fpj35zvRj2TP+e1xFSfk= 116 | golang.org/x/time v0.5.0/go.mod h1:3BpzKBy/shNhVucY/MWOyx10tF3SFh9QdLuxbVysPQM= 117 | golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= 118 | golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= 119 | golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc= 120 | golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU= 121 | golang.org/x/tools v0.20.0 h1:hz/CVckiOxybQvFw6h7b/q80NTr9IUQb4s1IIzW7KNY= 122 | golang.org/x/tools v0.20.0/go.mod h1:WvitBU7JJf6A4jOdg4S1tviW9bhUxkgeCui/0JHctQg= 123 | golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= 124 | gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= 125 | gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= 126 | gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= 127 | gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= 128 | gorm.io/driver/mysql v1.5.6 h1:Ld4mkIickM+EliaQZQx3uOJDJHtrd70MxAUqWqlx3Y8= 129 | gorm.io/driver/mysql v1.5.6/go.mod h1:sEtPWMiqiN1N1cMXoXmBbd8C6/l+TESwriotuRRpkDM= 130 | gorm.io/driver/postgres v1.5.7 h1:8ptbNJTDbEmhdr62uReG5BGkdQyeasu/FZHxI0IMGnM= 131 | gorm.io/driver/postgres v1.5.7/go.mod h1:3e019WlBaYI5o5LIdNV+LyxCMNtLOQETBXL2h4chKpA= 132 | gorm.io/gorm v1.25.7/go.mod h1:hbnx/Oo0ChWMn1BIhpy1oYozzpM15i4YPuHDmfYtwg8= 133 | gorm.io/gorm v1.25.9 h1:wct0gxZIELDk8+ZqF/MVnHLkA1rvYlBWUMv2EdsK1g8= 134 | gorm.io/gorm v1.25.9/go.mod h1:hbnx/Oo0ChWMn1BIhpy1oYozzpM15i4YPuHDmfYtwg8= 135 | -------------------------------------------------------------------------------- /web/static/blackhole.js: -------------------------------------------------------------------------------- 1 | import * as three from 'three'; 2 | import { Line2 } from 'three/addons/Line2.js'; 3 | import { LineMaterial } from 'three/addons/LineMaterial.js'; 4 | import { LineGeometry } from 'three/addons/LineGeometry.js'; 5 | import { GLTFLoader } from 'three/addons/GLTFLoader.js'; 6 | 7 | const pointsCount = 1000; 8 | const lucky = Math.random() > 0.995; 9 | const navbarHeight = document.querySelector(".navbar").getBoundingClientRect().height; 10 | 11 | let rendererHeight = innerHeight - navbarHeight; 12 | function resizeBlackholes(rendererHeight) { 13 | if (window.innerWidth >= 1024) 14 | document.querySelector("#blackholes").style.height = rendererHeight + "px"; 15 | else 16 | document.querySelector("#blackholes").style.height = "160px"; 17 | } 18 | resizeBlackholes(rendererHeight) 19 | 20 | const createUserElem = user => { 21 | const cardImage = document.createElement("figure"); 22 | const cardImageImage = document.createElement("img"); 23 | cardImageImage.classList.add("w-44", "object-contain"); 24 | cardImageImage.src = user.image; 25 | cardImage.appendChild(cardImageImage); 26 | 27 | const cardTitle = document.createElement("h2"); 28 | const cardTitleTitle = document.createElement("a"); 29 | cardTitle.classList.add("card-title"); 30 | cardTitleTitle.href = "https://profile.intra.42.fr/users/" + user.login; 31 | cardTitleTitle.textContent = user.login; 32 | cardTitle.appendChild(cardTitleTitle); 33 | 34 | const cardBodyBody = document.createElement("p"); 35 | cardBodyBody.textContent = user.date.toDateString(); 36 | const cardBody = document.createElement("div"); 37 | cardBody.classList.add("card-body"); 38 | cardBody.appendChild(cardTitle); 39 | cardBody.appendChild(cardBodyBody); 40 | 41 | const userElem = document.createElement("div"); 42 | userElem.classList.add("card", "card-side", 43 | "pt-1", "pl-1", "h-32"); 44 | userElem.appendChild(cardImage); 45 | userElem.appendChild(cardBody); 46 | 47 | return userElem; 48 | } 49 | 50 | const showBlackholes = stage => { 51 | const blackholes = document.querySelector("#blackholes"); 52 | blackholes.innerHTML = ""; 53 | 54 | const weeks = document.createElement("p"); 55 | weeks.classList.add("uppercase", "font-bold", 56 | "ml-3", "mt-1"); 57 | weeks.textContent = `in ${stage.diff} weeks`; 58 | if (stage.blackholed) 59 | weeks.textContent = `blackholed`; 60 | if (stage.diff == 1) 61 | weeks.textContent = `next week`; 62 | if (stage.diff == 0) 63 | weeks.textContent = `this week`; 64 | blackholes.appendChild(weeks); 65 | 66 | const users = stage.users.slice(); 67 | if (!stage.blackholed) 68 | users.reverse(); 69 | for (const user of users) { 70 | blackholes.appendChild(createUserElem(user)); 71 | } 72 | } 73 | 74 | function renderBlackholeMap(blackholeMap) { 75 | const circles = []; 76 | 77 | const scene = new three.Scene(); 78 | const camera = new three.PerspectiveCamera(50, innerWidth/rendererHeight); 79 | camera.position.z = 15; 80 | const renderer = new three.WebGLRenderer({ 81 | powerPreference: "high-performance", 82 | antialias: true, 83 | stencil: false, 84 | depth: false, 85 | }); 86 | renderer.outputColorSpace = three.SRGBColorSpace; 87 | const loader = new three.TextureLoader(); 88 | const materials = []; 89 | const stages = {}; 90 | const blackholed = []; 91 | 92 | let blackholeModel; 93 | let spaceModel; 94 | const gltfLoader = new GLTFLoader(); 95 | gltfLoader.load("../static/assets/blackhole/blackhole.glb", gltf => { 96 | blackholeModel = gltf.scene; 97 | blackholeModel.rotation.x = -5; 98 | blackholeModel.scale.set(0.7, 0.7, 0.7); 99 | scene.add(blackholeModel); 100 | }); 101 | 102 | for (const user of blackholeMap) { 103 | let diff = parseInt((user.date - Date.now()) / (24*3600*1000*7)); 104 | if (user.date < Date.now() || diff < 0) { 105 | blackholed.push({ diff, user }); 106 | continue; 107 | } 108 | 109 | stages[diff] ||= { total: 0 }; 110 | stages[diff].total++; 111 | stages[diff].diff = diff; 112 | stages[diff].users ||= []; 113 | stages[diff].users.push(user); 114 | 115 | user.stage = stages[diff]; 116 | } 117 | const smallestDiff = Math.min( 118 | ...Object.keys(stages).map(a => parseInt(a))); 119 | 120 | for (const i in blackholeMap) { 121 | const user = blackholeMap[i]; 122 | if (user.stage == undefined) continue; 123 | const stage = user.stage; 124 | 125 | stage.cur ||= 1; 126 | if (!stage.points) { 127 | stage.points = new three. 128 | EllipseCurve(0, 0, 3 + stage.diff, 3 + stage.diff). 129 | getPoints(pointsCount * (stage.diff + 1)); 130 | const line = new three.Line( 131 | new three.BufferGeometry(). 132 | setFromPoints(stage.points), null); 133 | const geometry = new LineGeometry().fromLine(line); 134 | const material = new LineMaterial({ color: 0xffffff }); 135 | if (stage.diff == smallestDiff) { 136 | material.linewidth = 5; 137 | showBlackholes(stage); 138 | } 139 | stage.material = material; 140 | scene.add(new Line2(geometry, material)); 141 | } 142 | 143 | const map = loader.load(user.image); 144 | const material = new three.MeshBasicMaterial({ map }); 145 | 146 | let geometry; 147 | if (lucky) 148 | geometry = new three.SphereGeometry(0.5, 64, 64); 149 | else 150 | geometry = new three.CircleGeometry(0.5, 64); 151 | const circle = new three.Mesh(geometry, material); 152 | if (lucky) 153 | circle.rotation.y = 5; 154 | 155 | circle.user = user; 156 | circle.points = stage.points; 157 | circle.curveIndex = Math.min(stage.points.length - 2, 158 | parseInt((stage.points.length / stage.total) * stage.cur++)); 159 | circles.push(circle); 160 | scene.add(circle); 161 | } 162 | 163 | for (const { user, diff } of blackholed) { 164 | const map = loader.load(user.image); 165 | const material = new three.MeshBasicMaterial({ map }); 166 | 167 | let geometry; 168 | if (lucky) 169 | geometry = new three.SphereGeometry(0.5, 64, 64); 170 | else 171 | geometry = new three.CircleGeometry(0.5, 64); 172 | const circle = new three.Mesh(geometry, material); 173 | if (lucky) 174 | circle.rotation.y = 5; 175 | 176 | circle.renderOrder = diff; 177 | circle.position.x = Math.random() * 8 - 5; 178 | circle.position.y = Math.random() * 8 - 5; 179 | circle.position.z = diff - 100; 180 | circle.visible = false; 181 | 182 | user.circle = circle; 183 | circle.user = user; 184 | circles.push(circle); 185 | scene.add(circle); 186 | } 187 | 188 | let startY = 0; 189 | window.addEventListener("touchstart", function(event) { 190 | startY = event.touches[0].pageY; 191 | }); 192 | 193 | const searchInput = document.querySelector("#search"); 194 | searchInput.addEventListener("input", event => { 195 | const search = event.target.value; 196 | 197 | if (search == "") { 198 | scrollY = previousScrollY; 199 | // yup yup yup... 200 | camera.position.z = 114 / 50 * (scrollY/114) + 15; 201 | gotoStage(parseInt(scrollY / 114), false); 202 | return; 203 | } 204 | 205 | a: for (const i of Object.keys(stages)) { 206 | const stage = stages[i]; 207 | for (const user of stage.users) { 208 | if (user.login.includes(search.toLowerCase())) { 209 | scrollY = parseInt(i)*114; 210 | camera.position.z = 114 / 50 * (scrollY/114) + 15; 211 | gotoStage(parseInt(i), false); 212 | 213 | const userElem = [].__proto__.slice.call( 214 | document.querySelector("#blackholes"). 215 | querySelectorAll("a")). 216 | find(a => 217 | a.textContent.includes(search.toLowerCase())); 218 | userElem.parentNode.parentNode.classList.add("bg-blue-900"); 219 | userElem.scrollIntoView(); 220 | 221 | break a; 222 | } 223 | } 224 | } 225 | }); 226 | 227 | const gotoStage = (stageIndex, changeCamera) => { 228 | let stage = stages[stageIndex]; 229 | 230 | const farthestStage = Object.keys(stages)[Object.keys(stages).length-1]; 231 | const nearestStage = Object.keys(stages)[0]; 232 | if (!stage && stageIndex < nearestStage) 233 | stage = stages[nearestStage]; 234 | if (!stage && stageIndex > farthestStage) 235 | stage = stages[farthestStage]; 236 | const material = stage?.material; 237 | if (material && previousMaterial) 238 | previousMaterial.linewidth = 1; 239 | if (material && stage) { 240 | material.linewidth = 5; 241 | previousMaterial = material; 242 | showBlackholes(stage); 243 | } else if (stageIndex >= -900/114 && stageIndex < 300/114) { 244 | showBlackholes(stages[0]); 245 | } 246 | 247 | if (stageIndex < -900/114) { 248 | showBlackholes({ 249 | users: blackholed.map(b => b.user), 250 | blackholed: true 251 | }); 252 | for (const { user, diff } of blackholed) { 253 | user.circle.visible = true; 254 | } 255 | camera.far = 100; 256 | } else { 257 | for (const { user, diff } of blackholed) { 258 | user.circle.visible = false; 259 | } 260 | camera.far = 2000; 261 | } 262 | 263 | camera.updateProjectionMatrix(); 264 | 265 | if (changeCamera) 266 | camera.position.z += (scrollY < previousScrollY ? -114 : 114) / 50; 267 | } 268 | 269 | let scrollY = 0; 270 | let previousScrollY = 0; 271 | let previousMaterial = stages[smallestDiff].material; 272 | const handleScroll = event => { 273 | if (document.querySelector("#blackholes:hover")) return; 274 | 275 | if (!event.deltaY) 276 | event.deltaY = (startY - event.touches[0].pageY) < 0 ? -114 : 114; 277 | previousScrollY = scrollY; 278 | scrollY += event.deltaY; 279 | 280 | gotoStage(parseInt(scrollY / 114), true); 281 | } 282 | 283 | window.addEventListener("wheel", handleScroll); 284 | window.addEventListener("touchmove", handleScroll); 285 | 286 | let previousTarget; 287 | let currentTarget; 288 | let lastButtons; 289 | const raycaster = new three.Raycaster(); 290 | const mouse = new three.Vector2(); 291 | const handleMouse = event => { 292 | event.preventDefault(); 293 | 294 | mouse.x = (event.clientX / renderer.domElement.clientWidth) * 2 - 1; 295 | mouse.y = - ((event.clientY - navbarHeight) / renderer.domElement.clientHeight) * 2 + 1; 296 | 297 | if (currentTarget && event.buttons == 0) { 298 | if (lastButtons == 4) { 299 | const a = document.createElement("a"); 300 | a.href = "https://profile.intra.42.fr/users/" 301 | + currentTarget.object.user.login; 302 | a.target = "_blank"; 303 | a.click(); 304 | } else if (lastButtons == 1) { 305 | window.location.href = "https://profile.intra.42.fr/users/" 306 | + currentTarget.object.user.login; 307 | } 308 | } 309 | 310 | lastButtons = event.buttons; 311 | } 312 | renderer.domElement.addEventListener("mousemove", handleMouse); 313 | renderer.domElement.addEventListener("mousedown", handleMouse); 314 | renderer.domElement.addEventListener("mouseup", handleMouse); 315 | 316 | const processObjectsAtMouse = () => { 317 | raycaster.setFromCamera(mouse, camera); 318 | 319 | const [target] = raycaster.intersectObjects(circles); 320 | currentTarget = target; 321 | if (previousTarget && target != previousTarget) { 322 | previousTarget.object.scale.set(1, 1, 1); 323 | previousTarget.object.renderOrder = 1; 324 | } 325 | if (target) { 326 | const stage = parseInt(scrollY / 114) / 3 + 1; 327 | if (stage < 1) return; 328 | target.object.scale.set(stage, stage, stage); 329 | target.object.renderOrder = 2; 330 | previousTarget = target; 331 | } 332 | } 333 | 334 | window.addEventListener("resize", () => { 335 | rendererHeight = innerHeight - navbarHeight; 336 | camera.aspect = window.innerWidth / rendererHeight; 337 | camera.updateProjectionMatrix(); 338 | renderer.setSize(innerWidth, rendererHeight); 339 | resizeBlackholes(rendererHeight); 340 | mouse.x = -1; 341 | mouse.y = -1; 342 | }); 343 | 344 | renderer.setSize(innerWidth, rendererHeight); 345 | document.body.appendChild(renderer.domElement); 346 | 347 | function render() { 348 | requestAnimationFrame(render); 349 | for (const i in circles) { 350 | const circle = circles[i]; 351 | if (!circle.points) continue; 352 | 353 | circle.position.x = circle.points[circle.curveIndex].x; 354 | circle.position.y = circle.points[circle.curveIndex].y; 355 | 356 | circle.curveIndex--; 357 | if (circle.curveIndex == 0) 358 | circle.curveIndex = circle.points.length - 1; 359 | } 360 | for (const stage of Object.keys(stages)) 361 | stages[stage].material?.resolution.set(innerWidth, rendererHeight); 362 | if (blackholeModel) 363 | blackholeModel.rotation.y -= 0.01; 364 | 365 | processObjectsAtMouse(); 366 | 367 | renderer.render(scene, camera); 368 | } 369 | render(); 370 | } 371 | 372 | const campusId = new URLSearchParams(location.search).get("campus") 373 | let params = `?campus=${campusId}`; 374 | if (!campusId) 375 | params = ""; 376 | fetch(`../blackhole.json${params}`). 377 | then(res => res.json(). 378 | then(blackholeMap => { 379 | blackholeMap. 380 | forEach(a => { a.date = new Date(a.date); }); 381 | blackholeMap = blackholeMap. 382 | sort((a, b) => b.date - a.date); 383 | 384 | renderBlackholeMap(blackholeMap); 385 | })); 386 | -------------------------------------------------------------------------------- /web/templates/leaderboard.templ: -------------------------------------------------------------------------------- 1 | package templates 2 | 3 | import ( 4 | "github.com/demostanis/42evaluators/internal/models" 5 | "time" 6 | "fmt" 7 | "strconv" 8 | "strings" 9 | "net/url" 10 | ) 11 | 12 | type Promo struct { 13 | Name string 14 | Active bool 15 | } 16 | 17 | type Field struct { 18 | Name string 19 | PrettyName string 20 | Checked bool 21 | Sortable bool 22 | } 23 | 24 | var ( 25 | ToggleableFields = []Field{ 26 | {Name: "display_name", PrettyName: "Full name", Sortable: true}, 27 | {Name: "level", PrettyName: "Level", Sortable: true}, 28 | {Name: "weekly_logtime", PrettyName: "Weekly logtime", Sortable: true}, 29 | {Name: "correction_points", PrettyName: "Correction points", Sortable: true}, 30 | {Name: "wallets", PrettyName: "Wallets", Sortable: true}, 31 | {Name: "campus", PrettyName: "Campus", Sortable: false}, 32 | {Name: "coalition", PrettyName: "Coalition", Sortable: false}, 33 | {Name: "blackholed", PrettyName: "Blackholed", Sortable: false}, 34 | } 35 | ) 36 | 37 | func getIsActiveClass(isActive bool) string { 38 | var isActiveClass string 39 | if isActive { 40 | isActiveClass = "btn-active" 41 | } 42 | return isActiveClass 43 | } 44 | 45 | func urlWithPage(url *url.URL, page int) templ.SafeURL { 46 | params := url.Query() 47 | params.Del("page") 48 | params.Del("me") 49 | if page != 1 { 50 | params.Add("page", strconv.Itoa(page)) 51 | } 52 | newURL := *url 53 | newURL.RawQuery = params.Encode() 54 | return templ.SafeURL(newURL.String()) 55 | } 56 | 57 | const ( 58 | Enabled = false 59 | Disabled = true 60 | ) 61 | const ( 62 | Inactive = false 63 | Active = true 64 | ) 65 | 66 | templ pageButton( 67 | url *url.URL, 68 | page int, 69 | text string, 70 | isActive bool, 71 | isDisabled bool, 72 | ) { 73 | 78 | 84 | 85 | } 86 | 87 | func isDisabled(targetedPage int, totalPage int) bool { 88 | return targetedPage < 1 || targetedPage > totalPage 89 | } 90 | 91 | templ pagination(url *url.URL, page int, totalPages int, gotoMyPositionShown bool) { 92 |
    93 | @pageButton(url, 1, "<<", Inactive, page == 1) 94 | @pageButton(url, 95 | page-1, "<", Inactive, 96 | isDisabled(page-1, totalPages)) 97 | if page > 4 { 98 | @pageButton(url, 0, "...", Inactive, Disabled) 99 | } 100 | for i := max(1, page - 3); i <= min(page + 3, totalPages); i++ { 101 | @pageButton(url, i, strconv.Itoa(i), i == page, Enabled) 102 | } 103 | if page < totalPages - 3 { 104 | @pageButton(url, 0, "...", Inactive, Disabled) 105 | } 106 | @pageButton(url, 107 | page+1, ">", Inactive, 108 | isDisabled(page+1, totalPages)) 109 | @pageButton(url, totalPages, ">>", Inactive, page == totalPages) 110 | if gotoMyPositionShown { 111 | Go to my position... 112 | } 113 |
    114 | } 115 | 116 | func getProfileURL(user models.User) templ.SafeURL { 117 | return templ.SafeURL("https://profile.intra.42.fr/users/" + user.Login) 118 | } 119 | 120 | func getBgURL(user models.User, currentUserID int) string { 121 | // TODO: stick the coalition cover somewhere prettier 122 | //if user.Coalition.CoverURL == "" { 123 | // return "bg-[url(https://profile.intra.42.fr/assets/background_login-a4e0666f73c02f025f590b474b394fd86e1cae20e95261a6e4862c2d0faa1b04.jpg)] bg-cover bg-center" 124 | //} else { 125 | // return "bg-[url(" + user.Coalition.CoverURL + ")] bg-cover bg-center" 126 | //} 127 | if user.ID == currentUserID { 128 | return "bg-blue-900 me" 129 | } 130 | return "" 131 | } 132 | 133 | func urlWithSorting(myURL *url.URL, sort string) templ.SafeURL { 134 | params := myURL.Query() 135 | params.Del("sort") 136 | params.Del("me") 137 | if sort != "level" { // since that's the default 138 | params.Add("sort", sort) 139 | } 140 | newURL := *myURL 141 | newURL.RawQuery = params.Encode() 142 | return templ.SafeURL(newURL.String()) 143 | } 144 | 145 | func urlForMe(myURL *url.URL) templ.SafeURL { 146 | params := myURL.Query() 147 | params.Del("me") 148 | params.Add("me", "1") 149 | newURL := *myURL 150 | newURL.RawQuery = params.Encode() 151 | return templ.SafeURL(newURL.String()) 152 | } 153 | 154 | script jumpToMe() { 155 | if (new URLSearchParams(window.location.search).get("me")) { 156 | document.querySelector(".me").scrollIntoView({ 157 | behavior: "smooth", 158 | block: "center", 159 | }) 160 | } 161 | } 162 | 163 | script updateLeaderboardWhenNeeded(campusIDs []int) { 164 | function updatePromo(e) { 165 | const params = new URLSearchParams(window.location.search); 166 | const selected = e.target.selectedOptions[0]; 167 | 168 | params.delete("page"); 169 | params.delete("me"); 170 | if (selected.textContent != "Any promo") { 171 | params.delete("promo"); 172 | params.append("promo", selected.textContent.replace(/promo in /, "")); 173 | } else 174 | params.delete("promo"); 175 | window.location.search = params; 176 | } 177 | function updateCampus(e) { 178 | const params = new URLSearchParams(window.location.search); 179 | const selected = e.target.selectedOptions[0]; 180 | 181 | params.delete("page"); 182 | params.delete("me"); 183 | params.delete("promo"); 184 | if (selected.textContent != "Any campus") { 185 | params.delete("campus"); 186 | params.append("campus", campusIDs[e.target.selectedIndex - 1]); 187 | } else 188 | params.delete("campus"); 189 | window.location.search = params; 190 | } 191 | function updateSearch(e) { 192 | e.preventDefault(); 193 | const params = new URLSearchParams(window.location.search); 194 | const search = document.querySelector("#search-form input"); 195 | 196 | params.delete("page"); 197 | params.delete("me"); 198 | if (search.value != "") { 199 | params.delete("search"); 200 | params.append("search", search.value); 201 | } else 202 | params.delete("search"); 203 | window.location.search = params; 204 | } 205 | 206 | document.querySelector(".promo-selector") 207 | .addEventListener("change", updatePromo) 208 | document.querySelector(".campus-selector") 209 | .addEventListener("change", updateCampus) 210 | document.querySelector("#search-form") 211 | .addEventListener("submit", updateSearch) 212 | } 213 | 214 | script fieldsSettingsHandler() { 215 | function handle(e) { 216 | e.preventDefault(); 217 | 218 | const fields = []; 219 | [].__proto__.slice.call( 220 | document.querySelectorAll("#fields-settings-form input")). 221 | filter(field => field.checked). 222 | forEach(field => fields.push(field.id)); 223 | 224 | const params = new URLSearchParams(window.location.search); 225 | params.delete("fields"); 226 | params.delete("me"); 227 | if (fields.length) { 228 | params.append("fields", fields); 229 | } 230 | window.location.search = params; 231 | } 232 | 233 | document.querySelector("#fields-settings-form>label") 234 | .addEventListener("click", handle); 235 | } 236 | 237 | func getCampusIDs(campuses []models.Campus) []int { 238 | ids := make([]int, 0) 239 | for _, campus := range campuses { 240 | ids = append(ids, campus.ID) 241 | } 242 | return ids 243 | } 244 | 245 | func getDisplayName(user models.User) string { 246 | if user.Title.Name == "" { 247 | return user.Login 248 | } 249 | return strings.Replace(user.Title.Name, "%login", user.Login, -1) 250 | } 251 | 252 | func sort(shownFields map[string]Field) []Field { 253 | sortedFields := make([]Field, 0) 254 | for _, field := range ToggleableFields { 255 | sortedFields = append(sortedFields, shownFields[field.Name]) 256 | } 257 | return sortedFields 258 | } 259 | 260 | templ Leaderboard(users []models.User, 261 | promos []Promo, campuses []models.Campus, activeCampus int, 262 | url *url.URL, page int, totalPages int, shownFields map[string]Field, 263 | currentUserID int, offset int, gotoMyPositionShown bool, 264 | search string) { 265 | @header() 266 |
    267 |
    268 | Filter by: 269 | 279 | of 280 | 290 | 291 |
    292 |
    293 |
    294 | 299 |
    300 |
    301 | if len(users) == 0 && page == 1 { 302 |
    No users found...
    303 | } else { 304 | @pagination(url, page, totalPages, gotoMyPositionShown) 305 | 306 | 307 | 308 | 309 | 310 | 315 | for _, field := range sort(shownFields) { 316 | if field.Checked { 317 | 326 | } 327 | } 328 | 329 | 330 | 331 | for i, user := range users { 332 | 335 | 336 | 341 | 346 | if shownFields["display_name"].Checked { 347 | 348 | } 349 | if shownFields["level"].Checked { 350 | 351 | } 352 | if shownFields["weekly_logtime"].Checked { 353 | 358 | } 359 | if shownFields["correction_points"].Checked { 360 | 361 | } 362 | if shownFields["wallets"].Checked { 363 | 364 | } 365 | if shownFields["campus"].Checked { 366 | 367 | } 368 | if shownFields["coalition"].Checked { 369 | 370 | } 371 | if shownFields["blackholed"].Checked { 372 | 380 | } 381 | 382 | } 383 | 384 |
    PositionProfile picture 311 | 312 | User 313 | 314 | 318 | if field.Sortable { 319 | 320 | { field.PrettyName } 321 | 322 | } else { 323 | { field.PrettyName } 324 | } 325 |
    { strconv.Itoa(i + offset + 1) }. 337 |
    338 | 339 |
    340 |
    342 | 343 | { getDisplayName(user) } 344 | 345 | { user.DisplayName }{ fmt.Sprintf("%.2f", user.Level) } 354 | { fmt.Sprintf("%02dh%02d", 355 | int(user.WeeklyLogtime.Hours()), 356 | int(user.WeeklyLogtime.Seconds()/60)%60) } 357 | { fmt.Sprintf("%d", user.CorrectionPoints) }{ fmt.Sprintf("%d", user.Wallets) }{ user.Campus.Name }{ user.Coalition.Name } 373 | if !user.BlackholedAt.IsZero() && 374 | user.BlackholedAt.Compare(time.Now()) < 0 { 375 | Yes 376 | } else { 377 | No 378 | } 379 |
    385 | @pagination(url, page, totalPages, gotoMyPositionShown) 386 |
    387 | } 388 |
    389 | 390 | 415 | @updateLeaderboardWhenNeeded(getCampusIDs(campuses)) 416 | @fieldsSettingsHandler() 417 | @jumpToMe() 418 | @footer() 419 | } 420 | -------------------------------------------------------------------------------- /web/static/assets/LineMaterial.js: -------------------------------------------------------------------------------- 1 | /** 2 | * parameters = { 3 | * color: , 4 | * linewidth: , 5 | * dashed: , 6 | * dashScale: , 7 | * dashSize: , 8 | * dashOffset: , 9 | * gapSize: , 10 | * resolution: , // to be set by renderer 11 | * } 12 | */ 13 | 14 | import { 15 | ShaderLib, 16 | ShaderMaterial, 17 | UniformsLib, 18 | UniformsUtils, 19 | Vector2 20 | } from 'three'; 21 | 22 | 23 | UniformsLib.line = { 24 | 25 | worldUnits: { value: 1 }, 26 | linewidth: { value: 1 }, 27 | resolution: { value: new Vector2( 1, 1 ) }, 28 | dashOffset: { value: 0 }, 29 | dashScale: { value: 1 }, 30 | dashSize: { value: 1 }, 31 | gapSize: { value: 1 } // todo FIX - maybe change to totalSize 32 | 33 | }; 34 | 35 | ShaderLib[ 'line' ] = { 36 | 37 | uniforms: UniformsUtils.merge( [ 38 | UniformsLib.common, 39 | UniformsLib.fog, 40 | UniformsLib.line 41 | ] ), 42 | 43 | vertexShader: 44 | /* glsl */` 45 | #include 46 | #include 47 | #include 48 | #include 49 | #include 50 | 51 | uniform float linewidth; 52 | uniform vec2 resolution; 53 | 54 | attribute vec3 instanceStart; 55 | attribute vec3 instanceEnd; 56 | 57 | attribute vec3 instanceColorStart; 58 | attribute vec3 instanceColorEnd; 59 | 60 | #ifdef WORLD_UNITS 61 | 62 | varying vec4 worldPos; 63 | varying vec3 worldStart; 64 | varying vec3 worldEnd; 65 | 66 | #ifdef USE_DASH 67 | 68 | varying vec2 vUv; 69 | 70 | #endif 71 | 72 | #else 73 | 74 | varying vec2 vUv; 75 | 76 | #endif 77 | 78 | #ifdef USE_DASH 79 | 80 | uniform float dashScale; 81 | attribute float instanceDistanceStart; 82 | attribute float instanceDistanceEnd; 83 | varying float vLineDistance; 84 | 85 | #endif 86 | 87 | void trimSegment( const in vec4 start, inout vec4 end ) { 88 | 89 | // trim end segment so it terminates between the camera plane and the near plane 90 | 91 | // conservative estimate of the near plane 92 | float a = projectionMatrix[ 2 ][ 2 ]; // 3nd entry in 3th column 93 | float b = projectionMatrix[ 3 ][ 2 ]; // 3nd entry in 4th column 94 | float nearEstimate = - 0.5 * b / a; 95 | 96 | float alpha = ( nearEstimate - start.z ) / ( end.z - start.z ); 97 | 98 | end.xyz = mix( start.xyz, end.xyz, alpha ); 99 | 100 | } 101 | 102 | void main() { 103 | 104 | #ifdef USE_COLOR 105 | 106 | vColor.xyz = ( position.y < 0.5 ) ? instanceColorStart : instanceColorEnd; 107 | 108 | #endif 109 | 110 | #ifdef USE_DASH 111 | 112 | vLineDistance = ( position.y < 0.5 ) ? dashScale * instanceDistanceStart : dashScale * instanceDistanceEnd; 113 | vUv = uv; 114 | 115 | #endif 116 | 117 | float aspect = resolution.x / resolution.y; 118 | 119 | // camera space 120 | vec4 start = modelViewMatrix * vec4( instanceStart, 1.0 ); 121 | vec4 end = modelViewMatrix * vec4( instanceEnd, 1.0 ); 122 | 123 | #ifdef WORLD_UNITS 124 | 125 | worldStart = start.xyz; 126 | worldEnd = end.xyz; 127 | 128 | #else 129 | 130 | vUv = uv; 131 | 132 | #endif 133 | 134 | // special case for perspective projection, and segments that terminate either in, or behind, the camera plane 135 | // clearly the gpu firmware has a way of addressing this issue when projecting into ndc space 136 | // but we need to perform ndc-space calculations in the shader, so we must address this issue directly 137 | // perhaps there is a more elegant solution -- WestLangley 138 | 139 | bool perspective = ( projectionMatrix[ 2 ][ 3 ] == - 1.0 ); // 4th entry in the 3rd column 140 | 141 | if ( perspective ) { 142 | 143 | if ( start.z < 0.0 && end.z >= 0.0 ) { 144 | 145 | trimSegment( start, end ); 146 | 147 | } else if ( end.z < 0.0 && start.z >= 0.0 ) { 148 | 149 | trimSegment( end, start ); 150 | 151 | } 152 | 153 | } 154 | 155 | // clip space 156 | vec4 clipStart = projectionMatrix * start; 157 | vec4 clipEnd = projectionMatrix * end; 158 | 159 | // ndc space 160 | vec3 ndcStart = clipStart.xyz / clipStart.w; 161 | vec3 ndcEnd = clipEnd.xyz / clipEnd.w; 162 | 163 | // direction 164 | vec2 dir = ndcEnd.xy - ndcStart.xy; 165 | 166 | // account for clip-space aspect ratio 167 | dir.x *= aspect; 168 | dir = normalize( dir ); 169 | 170 | #ifdef WORLD_UNITS 171 | 172 | vec3 worldDir = normalize( end.xyz - start.xyz ); 173 | vec3 tmpFwd = normalize( mix( start.xyz, end.xyz, 0.5 ) ); 174 | vec3 worldUp = normalize( cross( worldDir, tmpFwd ) ); 175 | vec3 worldFwd = cross( worldDir, worldUp ); 176 | worldPos = position.y < 0.5 ? start: end; 177 | 178 | // height offset 179 | float hw = linewidth * 0.5; 180 | worldPos.xyz += position.x < 0.0 ? hw * worldUp : - hw * worldUp; 181 | 182 | // don't extend the line if we're rendering dashes because we 183 | // won't be rendering the endcaps 184 | #ifndef USE_DASH 185 | 186 | // cap extension 187 | worldPos.xyz += position.y < 0.5 ? - hw * worldDir : hw * worldDir; 188 | 189 | // add width to the box 190 | worldPos.xyz += worldFwd * hw; 191 | 192 | // endcaps 193 | if ( position.y > 1.0 || position.y < 0.0 ) { 194 | 195 | worldPos.xyz -= worldFwd * 2.0 * hw; 196 | 197 | } 198 | 199 | #endif 200 | 201 | // project the worldpos 202 | vec4 clip = projectionMatrix * worldPos; 203 | 204 | // shift the depth of the projected points so the line 205 | // segments overlap neatly 206 | vec3 clipPose = ( position.y < 0.5 ) ? ndcStart : ndcEnd; 207 | clip.z = clipPose.z * clip.w; 208 | 209 | #else 210 | 211 | vec2 offset = vec2( dir.y, - dir.x ); 212 | // undo aspect ratio adjustment 213 | dir.x /= aspect; 214 | offset.x /= aspect; 215 | 216 | // sign flip 217 | if ( position.x < 0.0 ) offset *= - 1.0; 218 | 219 | // endcaps 220 | if ( position.y < 0.0 ) { 221 | 222 | offset += - dir; 223 | 224 | } else if ( position.y > 1.0 ) { 225 | 226 | offset += dir; 227 | 228 | } 229 | 230 | // adjust for linewidth 231 | offset *= linewidth; 232 | 233 | // adjust for clip-space to screen-space conversion // maybe resolution should be based on viewport ... 234 | offset /= resolution.y; 235 | 236 | // select end 237 | vec4 clip = ( position.y < 0.5 ) ? clipStart : clipEnd; 238 | 239 | // back to clip space 240 | offset *= clip.w; 241 | 242 | clip.xy += offset; 243 | 244 | #endif 245 | 246 | gl_Position = clip; 247 | 248 | vec4 mvPosition = ( position.y < 0.5 ) ? start : end; // this is an approximation 249 | 250 | #include 251 | #include 252 | #include 253 | 254 | } 255 | `, 256 | 257 | fragmentShader: 258 | /* glsl */` 259 | uniform vec3 diffuse; 260 | uniform float opacity; 261 | uniform float linewidth; 262 | 263 | #ifdef USE_DASH 264 | 265 | uniform float dashOffset; 266 | uniform float dashSize; 267 | uniform float gapSize; 268 | 269 | #endif 270 | 271 | varying float vLineDistance; 272 | 273 | #ifdef WORLD_UNITS 274 | 275 | varying vec4 worldPos; 276 | varying vec3 worldStart; 277 | varying vec3 worldEnd; 278 | 279 | #ifdef USE_DASH 280 | 281 | varying vec2 vUv; 282 | 283 | #endif 284 | 285 | #else 286 | 287 | varying vec2 vUv; 288 | 289 | #endif 290 | 291 | #include 292 | #include 293 | #include 294 | #include 295 | #include 296 | 297 | vec2 closestLineToLine(vec3 p1, vec3 p2, vec3 p3, vec3 p4) { 298 | 299 | float mua; 300 | float mub; 301 | 302 | vec3 p13 = p1 - p3; 303 | vec3 p43 = p4 - p3; 304 | 305 | vec3 p21 = p2 - p1; 306 | 307 | float d1343 = dot( p13, p43 ); 308 | float d4321 = dot( p43, p21 ); 309 | float d1321 = dot( p13, p21 ); 310 | float d4343 = dot( p43, p43 ); 311 | float d2121 = dot( p21, p21 ); 312 | 313 | float denom = d2121 * d4343 - d4321 * d4321; 314 | 315 | float numer = d1343 * d4321 - d1321 * d4343; 316 | 317 | mua = numer / denom; 318 | mua = clamp( mua, 0.0, 1.0 ); 319 | mub = ( d1343 + d4321 * ( mua ) ) / d4343; 320 | mub = clamp( mub, 0.0, 1.0 ); 321 | 322 | return vec2( mua, mub ); 323 | 324 | } 325 | 326 | void main() { 327 | 328 | #include 329 | 330 | #ifdef USE_DASH 331 | 332 | if ( vUv.y < - 1.0 || vUv.y > 1.0 ) discard; // discard endcaps 333 | 334 | if ( mod( vLineDistance + dashOffset, dashSize + gapSize ) > dashSize ) discard; // todo - FIX 335 | 336 | #endif 337 | 338 | float alpha = opacity; 339 | 340 | #ifdef WORLD_UNITS 341 | 342 | // Find the closest points on the view ray and the line segment 343 | vec3 rayEnd = normalize( worldPos.xyz ) * 1e5; 344 | vec3 lineDir = worldEnd - worldStart; 345 | vec2 params = closestLineToLine( worldStart, worldEnd, vec3( 0.0, 0.0, 0.0 ), rayEnd ); 346 | 347 | vec3 p1 = worldStart + lineDir * params.x; 348 | vec3 p2 = rayEnd * params.y; 349 | vec3 delta = p1 - p2; 350 | float len = length( delta ); 351 | float norm = len / linewidth; 352 | 353 | #ifndef USE_DASH 354 | 355 | #ifdef USE_ALPHA_TO_COVERAGE 356 | 357 | float dnorm = fwidth( norm ); 358 | alpha = 1.0 - smoothstep( 0.5 - dnorm, 0.5 + dnorm, norm ); 359 | 360 | #else 361 | 362 | if ( norm > 0.5 ) { 363 | 364 | discard; 365 | 366 | } 367 | 368 | #endif 369 | 370 | #endif 371 | 372 | #else 373 | 374 | #ifdef USE_ALPHA_TO_COVERAGE 375 | 376 | // artifacts appear on some hardware if a derivative is taken within a conditional 377 | float a = vUv.x; 378 | float b = ( vUv.y > 0.0 ) ? vUv.y - 1.0 : vUv.y + 1.0; 379 | float len2 = a * a + b * b; 380 | float dlen = fwidth( len2 ); 381 | 382 | if ( abs( vUv.y ) > 1.0 ) { 383 | 384 | alpha = 1.0 - smoothstep( 1.0 - dlen, 1.0 + dlen, len2 ); 385 | 386 | } 387 | 388 | #else 389 | 390 | if ( abs( vUv.y ) > 1.0 ) { 391 | 392 | float a = vUv.x; 393 | float b = ( vUv.y > 0.0 ) ? vUv.y - 1.0 : vUv.y + 1.0; 394 | float len2 = a * a + b * b; 395 | 396 | if ( len2 > 1.0 ) discard; 397 | 398 | } 399 | 400 | #endif 401 | 402 | #endif 403 | 404 | vec4 diffuseColor = vec4( diffuse, alpha ); 405 | 406 | #include 407 | #include 408 | 409 | gl_FragColor = vec4( diffuseColor.rgb, alpha ); 410 | 411 | #include 412 | #include 413 | #include 414 | #include 415 | 416 | } 417 | ` 418 | }; 419 | 420 | class LineMaterial extends ShaderMaterial { 421 | 422 | constructor( parameters ) { 423 | 424 | super( { 425 | 426 | type: 'LineMaterial', 427 | 428 | uniforms: UniformsUtils.clone( ShaderLib[ 'line' ].uniforms ), 429 | 430 | vertexShader: ShaderLib[ 'line' ].vertexShader, 431 | fragmentShader: ShaderLib[ 'line' ].fragmentShader, 432 | 433 | clipping: true // required for clipping support 434 | 435 | } ); 436 | 437 | this.isLineMaterial = true; 438 | 439 | this.setValues( parameters ); 440 | 441 | } 442 | 443 | get color() { 444 | 445 | return this.uniforms.diffuse.value; 446 | 447 | } 448 | 449 | set color( value ) { 450 | 451 | this.uniforms.diffuse.value = value; 452 | 453 | } 454 | 455 | get worldUnits() { 456 | 457 | return 'WORLD_UNITS' in this.defines; 458 | 459 | } 460 | 461 | set worldUnits( value ) { 462 | 463 | if ( value === true ) { 464 | 465 | this.defines.WORLD_UNITS = ''; 466 | 467 | } else { 468 | 469 | delete this.defines.WORLD_UNITS; 470 | 471 | } 472 | 473 | } 474 | 475 | get linewidth() { 476 | 477 | return this.uniforms.linewidth.value; 478 | 479 | } 480 | 481 | set linewidth( value ) { 482 | 483 | if ( ! this.uniforms.linewidth ) return; 484 | this.uniforms.linewidth.value = value; 485 | 486 | } 487 | 488 | get dashed() { 489 | 490 | return 'USE_DASH' in this.defines; 491 | 492 | } 493 | 494 | set dashed( value ) { 495 | 496 | if ( ( value === true ) !== this.dashed ) { 497 | 498 | this.needsUpdate = true; 499 | 500 | } 501 | 502 | if ( value === true ) { 503 | 504 | this.defines.USE_DASH = ''; 505 | 506 | } else { 507 | 508 | delete this.defines.USE_DASH; 509 | 510 | } 511 | 512 | } 513 | 514 | get dashScale() { 515 | 516 | return this.uniforms.dashScale.value; 517 | 518 | } 519 | 520 | set dashScale( value ) { 521 | 522 | this.uniforms.dashScale.value = value; 523 | 524 | } 525 | 526 | get dashSize() { 527 | 528 | return this.uniforms.dashSize.value; 529 | 530 | } 531 | 532 | set dashSize( value ) { 533 | 534 | this.uniforms.dashSize.value = value; 535 | 536 | } 537 | 538 | get dashOffset() { 539 | 540 | return this.uniforms.dashOffset.value; 541 | 542 | } 543 | 544 | set dashOffset( value ) { 545 | 546 | this.uniforms.dashOffset.value = value; 547 | 548 | } 549 | 550 | get gapSize() { 551 | 552 | return this.uniforms.gapSize.value; 553 | 554 | } 555 | 556 | set gapSize( value ) { 557 | 558 | this.uniforms.gapSize.value = value; 559 | 560 | } 561 | 562 | get opacity() { 563 | 564 | return this.uniforms.opacity.value; 565 | 566 | } 567 | 568 | set opacity( value ) { 569 | 570 | if ( ! this.uniforms ) return; 571 | this.uniforms.opacity.value = value; 572 | 573 | } 574 | 575 | get resolution() { 576 | 577 | return this.uniforms.resolution.value; 578 | 579 | } 580 | 581 | set resolution( value ) { 582 | 583 | this.uniforms.resolution.value.copy( value ); 584 | 585 | } 586 | 587 | get alphaToCoverage() { 588 | 589 | return 'USE_ALPHA_TO_COVERAGE' in this.defines; 590 | 591 | } 592 | 593 | set alphaToCoverage( value ) { 594 | 595 | if ( ! this.defines ) return; 596 | 597 | if ( ( value === true ) !== this.alphaToCoverage ) { 598 | 599 | this.needsUpdate = true; 600 | 601 | } 602 | 603 | if ( value === true ) { 604 | 605 | this.defines.USE_ALPHA_TO_COVERAGE = ''; 606 | this.extensions.derivatives = true; 607 | 608 | } else { 609 | 610 | delete this.defines.USE_ALPHA_TO_COVERAGE; 611 | this.extensions.derivatives = false; 612 | 613 | } 614 | 615 | } 616 | 617 | } 618 | 619 | export { LineMaterial }; 620 | --------------------------------------------------------------------------------