├── .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 |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 |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 |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 |Changelog
86 | //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 |Show teams
129 | 157 |in
158 | 174 | 175 |{ subject.Name }
180 |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), "